@redpanda-data/docs-extensions-and-macros 5.51.1 → 5.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/bin/doc-tools-mcp.js +4 -0
  2. package/bin/doc-tools.js +1 -0
  3. package/bin/mcp-tools/rpcn-docs.js +1 -0
  4. package/extensions/REFERENCE.adoc +19 -4
  5. package/extensions/generate-rp-connect-categories.js +76 -25
  6. package/extensions/generate-rp-connect-info.js +150 -40
  7. package/extensions/modify-connect-tag-playbook.js +183 -17
  8. package/extensions/util/connect-catalog.js +153 -0
  9. package/macros/rp-connect-components.js +23 -8
  10. package/package.json +1 -1
  11. package/tools/lint-strings/diff.js +4 -1
  12. package/tools/lint-strings/engine.js +5 -0
  13. package/tools/lint-strings/fixtures/connect/lint_connect.go +1 -1
  14. package/tools/lint-strings/fixtures/connect/repo/go.mod.txt +5 -0
  15. package/tools/lint-strings/fixtures/connect/repo/internal/cli/lint.go +19 -0
  16. package/tools/lint-strings/fixtures/connect/repo/internal/impl/azure/common.go +20 -0
  17. package/tools/lint-strings/fixtures/connect/repo/internal/impl/azure/docs.go +8 -0
  18. package/tools/lint-strings/fixtures/connect/repo/internal/impl/azure/input_thing.go +32 -0
  19. package/tools/lint-strings/fixtures/connect/repo/internal/impl/azure/output_thing.go +12 -0
  20. package/tools/lint-strings/fixtures/connect/repo/internal/impl/widget/bloblang.go +21 -0
  21. package/tools/lint-strings/fixtures/connect/repo/internal/impl/widget/scanner.go +12 -0
  22. package/tools/lint-strings/fixtures/connect/repo/internal/plugins/info.csv +3 -0
  23. package/tools/lint-strings/fixtures/connect/repo/internal/retries/retries.go +13 -0
  24. package/tools/lint-strings/go-index.js +1143 -0
  25. package/tools/lint-strings/index.js +28 -2
  26. package/tools/lint-strings/rules/common.js +10 -2
  27. package/tools/lint-strings/surfaces/connect.js +1484 -137
  28. package/tools/lint-strings/surfaces/rpk.js +1 -0
  29. package/tools/redpanda-connect/generate-rpcn-connector-docs.js +23 -13
  30. package/tools/redpanda-connect/report-delta.js +1 -0
  31. package/tools/redpanda-connect/rpcn-connector-docs-handler.js +19 -13
  32. package/tools/redpanda-connect/templates/connector.hbs +20 -2
  33. package/tools/rpk-docs/helpers/index.js +33 -36
@@ -0,0 +1,1143 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const os = require('os')
5
+ const path = require('path')
6
+
7
+ const { maskComments, skipString, findBalancedClose, splitTopLevelArgs, unescapeGo } = require('./go-source')
8
+
9
+ /**
10
+ * A package-aware index over a Go module, for surfaces whose doc strings are
11
+ * assembled from more than one file.
12
+ *
13
+ * The plain helpers in go-source.js evaluate a string expression against the
14
+ * constants of ONE file. Connect builds most of its published text across
15
+ * files and packages: a description concatenates a constant from a sibling
16
+ * file, calls a helper that returns a string, or embeds a field name constant
17
+ * from another package (`kafka.FranzMaxInFlightDescription`). This index
18
+ * loads packages on demand, records their top-level constants, variables and
19
+ * functions, and evaluates string expressions against them.
20
+ *
21
+ * Same philosophy as the rest of lint-strings: a comment-masked scanner over
22
+ * real source text, never a compiler. When an expression cannot be resolved
23
+ * it is never guessed at. The evaluator returns the parts it could resolve
24
+ * and names each part it could not, so a caller can report the gap instead
25
+ * of dropping the declaration.
26
+ *
27
+ * Packages outside the module resolve through the Go module cache when it
28
+ * holds the version go.mod requires (GOMODCACHE, or ~/go/pkg/mod). In CI the
29
+ * cache is usually absent, and those parts stay unresolved.
30
+ */
31
+
32
+ const PLACEHOLDER = (n) => `{{unresolved:${n}}}`
33
+ const PLACEHOLDER_RE = /\{\{unresolved:\d+\}\}/g
34
+ const MAX_DEPTH = 12
35
+
36
+ /** Byte offset -> 1-indexed line, by binary search over line starts. */
37
+ function makeLineIndex (content) {
38
+ const starts = [0]
39
+ for (let i = 0; i < content.length; i++) if (content[i] === '\n') starts.push(i + 1)
40
+ return (offset) => {
41
+ let lo = 0
42
+ let hi = starts.length - 1
43
+ while (lo < hi) {
44
+ const mid = (lo + hi + 1) >> 1
45
+ if (starts[mid] <= offset) lo = mid
46
+ else hi = mid - 1
47
+ }
48
+ return lo + 1
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Bracket matches for (), [] and {} over comment-masked content, skipping
54
+ * string and rune literals. Returns Map(offset -> partner offset) in both
55
+ * directions, which is what lets a scanner walk a builder chain backwards.
56
+ */
57
+ function bracketPairs (masked) {
58
+ const pairs = new Map()
59
+ const stack = []
60
+ let i = 0
61
+ while (i < masked.length) {
62
+ const ch = masked[i]
63
+ if (ch === '"' || ch === '`' || ch === "'") {
64
+ i = skipString(masked, i)
65
+ continue
66
+ }
67
+ if (ch === '(' || ch === '[' || ch === '{') stack.push(i)
68
+ else if (ch === ')' || ch === ']' || ch === '}') {
69
+ const open = stack.pop()
70
+ if (open !== undefined) {
71
+ pairs.set(open, i)
72
+ pairs.set(i, open)
73
+ }
74
+ }
75
+ i++
76
+ }
77
+ return pairs
78
+ }
79
+
80
+ /**
81
+ * End offset of the Go statement starting at `start`: the first newline
82
+ * outside strings and brackets that does not follow a continuation token
83
+ * (`+`, `.`, `,`, an opening bracket, a binary operator). Go's automatic
84
+ * semicolon insertion works the same way.
85
+ */
86
+ function statementEnd (masked, start, limit = masked.length) {
87
+ let depth = 0
88
+ let i = start
89
+ while (i < limit) {
90
+ const ch = masked[i]
91
+ if (ch === '"' || ch === '`' || ch === "'") {
92
+ i = skipString(masked, i)
93
+ continue
94
+ }
95
+ if (ch === '(' || ch === '[' || ch === '{') depth++
96
+ else if (ch === ')' || ch === ']' || ch === '}') {
97
+ if (depth === 0) return i
98
+ depth--
99
+ } else if ((ch === '\n' || ch === ';') && depth === 0) {
100
+ if (ch === ';') return i
101
+ // The last token before the newline decides, as in Go's semicolon
102
+ // insertion: a blank line, or a comment line (masked to spaces),
103
+ // inside a builder chain does not end the statement.
104
+ let k = i - 1
105
+ while (k >= start && /\s/.test(masked[k])) k--
106
+ if (k < start || !/[+.,([{|&=:*/-]/.test(masked[k])) return i
107
+ }
108
+ i++
109
+ }
110
+ return limit
111
+ }
112
+
113
+ /** Split text on top-level occurrences of a single-character operator. */
114
+ function splitTopLevel (text, op) {
115
+ const parts = []
116
+ let depth = 0
117
+ let start = 0
118
+ let i = 0
119
+ while (i < text.length) {
120
+ const ch = text[i]
121
+ if (ch === '"' || ch === '`' || ch === "'") {
122
+ i = skipString(text, i)
123
+ continue
124
+ }
125
+ if (ch === '(' || ch === '[' || ch === '{') depth++
126
+ else if (ch === ')' || ch === ']' || ch === '}') depth--
127
+ else if (ch === op && depth === 0) {
128
+ parts.push(text.slice(start, i))
129
+ start = i + 1
130
+ }
131
+ i++
132
+ }
133
+ parts.push(text.slice(start))
134
+ return parts
135
+ }
136
+
137
+ /** Parse `a, b string, c *T` into [{ name, type }]. */
138
+ function parseParams (text) {
139
+ const raw = splitTopLevelArgs(text).map((p) => p.trim()).filter(Boolean)
140
+ const params = []
141
+ let pendingNames = []
142
+ for (const entry of raw) {
143
+ const m = entry.match(/^([A-Za-z_]\w*)\s+(.+)$/s)
144
+ if (m) {
145
+ for (const name of pendingNames) params.push({ name, type: m[2].trim() })
146
+ pendingNames = []
147
+ params.push({ name: m[1], type: m[2].trim() })
148
+ } else if (/^[A-Za-z_]\w*$/.test(entry)) {
149
+ pendingNames.push(entry)
150
+ } else {
151
+ params.push({ name: null, type: entry })
152
+ }
153
+ }
154
+ for (const name of pendingNames) params.push({ name: null, type: name })
155
+ return params
156
+ }
157
+
158
+ /**
159
+ * Top-level declarations of one comment-masked file: functions (with body
160
+ * offsets), const/var entries (with their raw expression), and imports.
161
+ */
162
+ function scanTopLevel (masked, pairs) {
163
+ const funcs = []
164
+ const values = []
165
+ const imports = new Map()
166
+ let pkg = null
167
+ const pkgMatch = masked.match(/^\s*package\s+([A-Za-z_]\w*)/m)
168
+ if (pkgMatch) pkg = pkgMatch[1]
169
+
170
+ let i = 0
171
+ let depth = 0
172
+ const n = masked.length
173
+ const keywordAt = (kw) => masked.startsWith(kw, i) && (i === 0 || /\s/.test(masked[i - 1])) &&
174
+ /[\s(]/.test(masked[i + kw.length] || '')
175
+ while (i < n) {
176
+ const ch = masked[i]
177
+ if (ch === '"' || ch === '`' || ch === "'") {
178
+ i = skipString(masked, i)
179
+ continue
180
+ }
181
+ if (ch === '(' || ch === '[' || ch === '{') { depth++; i++; continue }
182
+ if (ch === ')' || ch === ']' || ch === '}') { depth--; i++; continue }
183
+ if (depth !== 0) { i++; continue }
184
+
185
+ if (keywordAt('import')) {
186
+ let j = i + 6
187
+ while (/\s/.test(masked[j])) j++
188
+ let region
189
+ let end
190
+ if (masked[j] === '(') {
191
+ end = pairs.get(j)
192
+ region = masked.slice(j + 1, end)
193
+ } else {
194
+ end = statementEnd(masked, j)
195
+ region = masked.slice(j, end)
196
+ }
197
+ for (const m of region.matchAll(/(?:^|\n|;)\s*([A-Za-z_]\w*|\.|_)?\s*"([^"]+)"/g)) {
198
+ imports.set(m[1] || null, m[2])
199
+ if (!m[1]) imports.set(`\0${m[2]}`, m[2])
200
+ }
201
+ i = end + 1
202
+ continue
203
+ }
204
+
205
+ if (keywordAt('func')) {
206
+ const fn = parseFunc(masked, pairs, i)
207
+ if (fn) {
208
+ funcs.push(fn)
209
+ i = fn.bodyEnd + 1
210
+ continue
211
+ }
212
+ i += 4
213
+ continue
214
+ }
215
+
216
+ if (keywordAt('const') || keywordAt('var')) {
217
+ const kind = masked.startsWith('const', i) ? 'const' : 'var'
218
+ let j = i + kind.length
219
+ while (/[ \t]/.test(masked[j])) j++
220
+ if (masked[j] === '(') {
221
+ const end = pairs.get(j)
222
+ if (end === undefined) { i++; continue }
223
+ let k = j + 1
224
+ while (k < end) {
225
+ while (k < end && /\s/.test(masked[k])) k++
226
+ if (k >= end) break
227
+ const stop = statementEnd(masked, k, end)
228
+ const entry = parseValueEntry(masked, k, stop, kind)
229
+ if (entry) values.push(entry)
230
+ k = stop + 1
231
+ }
232
+ i = end + 1
233
+ } else {
234
+ const stop = statementEnd(masked, j)
235
+ const entry = parseValueEntry(masked, j, stop, kind)
236
+ if (entry) values.push(entry)
237
+ i = stop + 1
238
+ }
239
+ continue
240
+ }
241
+ i++
242
+ }
243
+ return { pkg, funcs, values, imports }
244
+ }
245
+
246
+ /** Parse one `name [type] [= expr]` const/var entry between start and stop. */
247
+ function parseValueEntry (masked, start, stop, kind) {
248
+ const text = masked.slice(start, stop)
249
+ const m = text.match(/^([A-Za-z_]\w*)\s*([^=]*?)\s*(?:=(?!=)\s*([\s\S]*))?$/)
250
+ if (!m) return null
251
+ const exprText = m[3] === undefined ? null : m[3]
252
+ const exprStart = exprText === null ? null : start + text.length - exprText.length
253
+ return {
254
+ kind,
255
+ name: m[1],
256
+ type: (m[2] || '').trim() || null,
257
+ expr: exprText,
258
+ exprStart,
259
+ start,
260
+ end: stop
261
+ }
262
+ }
263
+
264
+ /** Parse a top-level func declaration starting at the `func` keyword. */
265
+ function parseFunc (masked, pairs, at) {
266
+ let j = at + 4
267
+ const skipWs = () => { while (/\s/.test(masked[j])) j++ }
268
+ skipWs()
269
+ let recv = null
270
+ if (masked[j] === '(') {
271
+ const close = pairs.get(j)
272
+ if (close === undefined) return null
273
+ recv = masked.slice(j + 1, close).trim()
274
+ j = close + 1
275
+ skipWs()
276
+ }
277
+ const nameMatch = masked.slice(j).match(/^([A-Za-z_]\w*)/)
278
+ if (!nameMatch) return null
279
+ const name = nameMatch[1]
280
+ j += name.length
281
+ skipWs()
282
+ if (masked[j] === '[') {
283
+ const close = pairs.get(j)
284
+ if (close === undefined) return null
285
+ j = close + 1
286
+ skipWs()
287
+ }
288
+ if (masked[j] !== '(') return null
289
+ const paramsClose = pairs.get(j)
290
+ if (paramsClose === undefined) return null
291
+ const params = parseParams(masked.slice(j + 1, paramsClose))
292
+ j = paramsClose + 1
293
+ // Results run to the body brace. A newline at depth 0 first means a
294
+ // declaration with no body (assembly stub).
295
+ const resultsStart = j
296
+ while (j < masked.length && masked[j] !== '{') {
297
+ if (masked[j] === '\n') return null
298
+ if (masked[j] === '(' || masked[j] === '[') {
299
+ const close = pairs.get(j)
300
+ if (close === undefined) return null
301
+ j = close + 1
302
+ continue
303
+ }
304
+ j++
305
+ }
306
+ const bodyStart = j
307
+ const bodyEnd = pairs.get(bodyStart)
308
+ if (bodyEnd === undefined) return null
309
+ return {
310
+ name,
311
+ recv,
312
+ params,
313
+ results: masked.slice(resultsStart, bodyStart).trim(),
314
+ start: at,
315
+ bodyStart,
316
+ bodyEnd
317
+ }
318
+ }
319
+
320
+ /** The Go module path and requirements from go.mod. */
321
+ function readGoMod (repo) {
322
+ const file = path.join(repo, 'go.mod')
323
+ if (!fs.existsSync(file)) return { module: null, requires: new Map(), replaces: new Map() }
324
+ const text = maskComments(fs.readFileSync(file, 'utf8'))
325
+ const moduleMatch = text.match(/^\s*module\s+(\S+)/m)
326
+ const requires = new Map()
327
+ for (const m of text.matchAll(/^\s*(?:require\s+)?([a-z0-9][\w.\-/]*\.[\w.\-/]+)\s+(v[\w.\-+]+)\s*$/gm)) {
328
+ requires.set(m[1], m[2])
329
+ }
330
+ const replaces = new Map()
331
+ for (const m of text.matchAll(/^\s*(?:replace\s+)?(\S+)\s*=>\s*(\S+)\s+(v\S+)\s*$/gm)) {
332
+ replaces.set(m[1], { path: m[2], version: m[3] })
333
+ }
334
+ return { module: moduleMatch ? moduleMatch[1] : null, requires, replaces }
335
+ }
336
+
337
+ /** Module cache escaping: uppercase letters become !lowercase. */
338
+ function escapeModulePath (p) {
339
+ return p.replace(/[A-Z]/g, (c) => `!${c.toLowerCase()}`)
340
+ }
341
+
342
+ function moduleCacheDir () {
343
+ if (process.env.GOMODCACHE) return process.env.GOMODCACHE
344
+ if (process.env.GOPATH) return path.join(process.env.GOPATH.split(path.delimiter)[0], 'pkg', 'mod')
345
+ return path.join(os.homedir(), 'go', 'pkg', 'mod')
346
+ }
347
+
348
+ class GoIndex {
349
+ /**
350
+ * @param {string} repo - Module root (the directory holding go.mod)
351
+ * @param {Object} [options]
352
+ * @param {boolean} [options.external=true] - Resolve packages outside the
353
+ * module through the Go module cache when it holds them
354
+ */
355
+ constructor (repo, { external = true, overlay = null } = {}) {
356
+ this.repo = path.resolve(repo)
357
+ // In-memory files (abs path -> content), consulted before the disk, so
358
+ // a single file can be scanned without a checkout (tests, previews).
359
+ this.overlay = new Map()
360
+ if (overlay) for (const [file, content] of overlay) this.overlay.set(path.resolve(this.repo, file), content)
361
+ this.gomod = readGoMod(this.repo)
362
+ this.external = external
363
+ this.files = new Map() // abs path -> FileInfo
364
+ this.packages = new Map() // abs dir -> PackageInfo
365
+ this.memo = new Map()
366
+ }
367
+
368
+ /** Repo-relative path for an absolute path inside the module. */
369
+ rel (abs) {
370
+ return path.relative(this.repo, abs)
371
+ }
372
+
373
+ /** Load (and cache) one file. */
374
+ file (absPath) {
375
+ if (this.files.has(absPath)) return this.files.get(absPath)
376
+ let content
377
+ if (this.overlay.has(absPath)) {
378
+ content = this.overlay.get(absPath)
379
+ } else {
380
+ try {
381
+ content = fs.readFileSync(absPath, 'utf8')
382
+ } catch {
383
+ this.files.set(absPath, null)
384
+ return null
385
+ }
386
+ }
387
+ const masked = maskComments(content)
388
+ const pairs = bracketPairs(masked)
389
+ const top = scanTopLevel(masked, pairs)
390
+ const build = content.match(/^\/\/go:build\s+(.+)$/m)
391
+ const info = {
392
+ abs: absPath,
393
+ dir: path.dirname(absPath),
394
+ content,
395
+ masked,
396
+ pairs,
397
+ lineOf: makeLineIndex(content),
398
+ pkg: top.pkg,
399
+ funcs: top.funcs,
400
+ values: top.values,
401
+ imports: top.imports,
402
+ buildConstraint: build ? build[1].trim() : null
403
+ }
404
+ this.files.set(absPath, info)
405
+ return info
406
+ }
407
+
408
+ /** Load (and cache) every non-test file of the package in `dir`. */
409
+ package (dir) {
410
+ if (this.packages.has(dir)) return this.packages.get(dir)
411
+ const pkg = { dir, name: null, files: [], funcs: new Map(), values: new Map(), methods: new Map() }
412
+ this.packages.set(dir, pkg)
413
+ const names = new Set()
414
+ try {
415
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) if (entry.isFile()) names.add(entry.name)
416
+ } catch {}
417
+ for (const file of this.overlay.keys()) if (path.dirname(file) === dir) names.add(path.basename(file))
418
+ for (const name of [...names].sort()) {
419
+ if (!name.endsWith('.go') || name.endsWith('_test.go')) continue
420
+ const info = this.file(path.join(dir, name))
421
+ if (!info) continue
422
+ pkg.files.push(info)
423
+ if (!pkg.name) pkg.name = info.pkg
424
+ for (const fn of info.funcs) {
425
+ const target = fn.recv ? pkg.methods : pkg.funcs
426
+ if (!target.has(fn.name)) target.set(fn.name, { ...fn, file: info })
427
+ }
428
+ for (const value of info.values) {
429
+ if (!pkg.values.has(value.name)) pkg.values.set(value.name, { ...value, file: info })
430
+ }
431
+ }
432
+ return pkg
433
+ }
434
+
435
+ /** Directory of an import path, or null when it is not resolvable. */
436
+ importDir (importPath) {
437
+ const mod = this.gomod.module
438
+ if (mod && (importPath === mod || importPath.startsWith(`${mod}/`))) {
439
+ return path.join(this.repo, importPath.slice(mod.length))
440
+ }
441
+ if (!this.external) return null
442
+ let best = null
443
+ for (const [modPath, version] of this.gomod.requires) {
444
+ if ((importPath === modPath || importPath.startsWith(`${modPath}/`)) && (!best || modPath.length > best[0].length)) {
445
+ best = [modPath, version]
446
+ }
447
+ }
448
+ if (!best) return null
449
+ const [modPath, version] = best
450
+ const replaced = this.gomod.replaces.get(modPath)
451
+ const source = replaced ? replaced.path : modPath
452
+ const ver = replaced ? replaced.version : version
453
+ const dir = path.join(moduleCacheDir(), `${escapeModulePath(source)}@${ver}`, importPath.slice(modPath.length))
454
+ return fs.existsSync(dir) ? dir : null
455
+ }
456
+
457
+ /** The package an identifier qualifier names in `file`, or null. */
458
+ importedPackage (file, alias) {
459
+ let importPath = file.imports.get(alias)
460
+ if (!importPath) {
461
+ // An unaliased import is named by its package clause, which is
462
+ // usually, but not always, the last path element.
463
+ for (const [key, value] of file.imports) {
464
+ if (key !== null && key.startsWith('\0')) {
465
+ const last = value.split('/').filter((s) => !/^v\d+$/.test(s)).pop()
466
+ if (last === alias || last.replace(/-/g, '_') === alias) { importPath = value; break }
467
+ }
468
+ }
469
+ }
470
+ if (!importPath) {
471
+ for (const [key, value] of file.imports) {
472
+ if (key === null || !key.startsWith('\0')) continue
473
+ const dir = this.importDir(value)
474
+ if (dir && this.package(dir).name === alias) { importPath = value; break }
475
+ }
476
+ }
477
+ if (!importPath) return null
478
+ return { importPath, dir: this.importDir(importPath) }
479
+ }
480
+
481
+ /** True when `alias` in `file` imports a path ending in `suffix`. */
482
+ importIs (file, alias, suffix) {
483
+ const imported = this.importedPackage(file, alias)
484
+ return Boolean(imported && imported.importPath.endsWith(suffix))
485
+ }
486
+
487
+ /**
488
+ * Look up a function by callee text (`name` or `pkg.Name`) as seen from
489
+ * `file`. Returns the function record or null.
490
+ */
491
+ lookupFunc (file, callee) {
492
+ const dot = callee.indexOf('.')
493
+ if (dot === -1) {
494
+ return this.package(file.dir).funcs.get(callee) || null
495
+ }
496
+ const alias = callee.slice(0, dot)
497
+ const name = callee.slice(dot + 1)
498
+ if (name.includes('.')) return null
499
+ const imported = this.importedPackage(file, alias)
500
+ if (!imported || !imported.dir) return null
501
+ return this.package(imported.dir).funcs.get(name) || null
502
+ }
503
+
504
+ /** Same as lookupFunc, for a top-level const or var. */
505
+ lookupValue (file, ident) {
506
+ const dot = ident.indexOf('.')
507
+ if (dot === -1) return this.package(file.dir).values.get(ident) || null
508
+ const alias = ident.slice(0, dot)
509
+ const name = ident.slice(dot + 1)
510
+ if (name.includes('.')) return null
511
+ const imported = this.importedPackage(file, alias)
512
+ if (!imported || !imported.dir) return null
513
+ return this.package(imported.dir).values.get(name) || null
514
+ }
515
+
516
+ /**
517
+ * Evaluate a Go string expression.
518
+ *
519
+ * @param {string} exprText - Comment-masked expression text
520
+ * @param {Object} ctx - { file, locals?: Map(name -> Result), depth? }
521
+ * @returns {{ parts: Array<{text}|{unresolved}>, sawString: boolean }}
522
+ * Each unresolved part names the source text that could not be
523
+ * evaluated. Use renderResult() to turn it into a string.
524
+ */
525
+ evalString (exprText, ctx) {
526
+ const depth = ctx.depth || 0
527
+ const parts = []
528
+ let sawString = false
529
+ if (depth > MAX_DEPTH) return { parts: [{ unresolved: exprText.trim() }], sawString: false }
530
+ for (const rawTerm of splitTopLevel(exprText, '+')) {
531
+ const term = rawTerm.trim()
532
+ if (term === '') continue
533
+ const r = this.evalTerm(term, { ...ctx, depth })
534
+ if (r.sawString) sawString = true
535
+ parts.push(...r.parts)
536
+ }
537
+ return { parts: mergeParts(parts), sawString }
538
+ }
539
+
540
+ evalTerm (term, ctx) {
541
+ const unresolved = { parts: [{ unresolved: term }], sawString: false }
542
+ // A constant slice of a string, as in `...`[1:] (drops a raw string's
543
+ // leading newline).
544
+ const slice = term.match(/\[\s*(\d*)\s*:\s*(\d*)\s*\]$/)
545
+ if (slice && term.length > slice[0].length) {
546
+ const baseText = term.slice(0, term.length - slice[0].length)
547
+ const open = term.length - slice[0].length
548
+ const base = term[open - 1] === ')' || term[open - 1] === '`' || term[open - 1] === '"' || /\w/.test(term[open - 1])
549
+ ? this.evalTerm(baseText.trim(), ctx)
550
+ : null
551
+ if (!base || !isResolved(base)) return unresolved
552
+ const text = renderResult(base)
553
+ const from = slice[1] === '' ? 0 : Number(slice[1])
554
+ const to = slice[2] === '' ? text.length : Number(slice[2])
555
+ if (from > to || to > text.length) return unresolved
556
+ return { parts: [{ text: text.slice(from, to) }], sawString: true }
557
+ }
558
+ const first = term[0]
559
+ if (first === '"' || first === '`') {
560
+ const end = skipString(term, 0)
561
+ if (end !== term.length) return unresolved
562
+ const body = term.slice(1, end - 1)
563
+ return { parts: [{ text: first === '"' ? unescapeGo(body) : body.replace(/\r/g, '') }], sawString: true }
564
+ }
565
+ if (first === '(') {
566
+ const close = findBalancedClose(term, 0)
567
+ if (close !== term.length - 1) return unresolved
568
+ return this.evalString(term.slice(1, -1), { ...ctx, depth: ctx.depth + 1 })
569
+ }
570
+ const call = term.match(/^([A-Za-z_][\w]*(?:\.[A-Za-z_]\w*)?)\s*\(/)
571
+ if (call) {
572
+ const open = term.indexOf('(', call[1].length)
573
+ const close = findBalancedClose(term, open)
574
+ if (close !== term.length - 1) return unresolved
575
+ return this.evalCall(call[1], term.slice(open + 1, close), ctx, term)
576
+ }
577
+ if (/^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)?$/.test(term)) {
578
+ return this.evalIdent(term, ctx)
579
+ }
580
+ return unresolved
581
+ }
582
+
583
+ evalIdent (ident, ctx) {
584
+ const unresolved = { parts: [{ unresolved: ident }], sawString: false }
585
+ if (ctx.locals && ctx.locals.has(ident)) return ctx.locals.get(ident)
586
+ // A function-local variable or parameter shadows the package scope.
587
+ // The resolver returns undefined for "not a local", null for "a local
588
+ // whose value cannot be known here".
589
+ if (ctx.resolveLocal && !ident.includes('.')) {
590
+ const local = ctx.resolveLocal(ident, ctx)
591
+ if (local === null) return unresolved
592
+ if (local !== undefined) return local
593
+ }
594
+ const value = this.lookupValue(ctx.file, ident)
595
+ if (value && value.expr === null) {
596
+ // `//go:embed file` on a string var: the file is the value.
597
+ const embedded = this.embeddedString(value)
598
+ if (embedded !== null) return { parts: [{ text: embedded }], sawString: true }
599
+ }
600
+ if (!value || value.expr === null) return unresolved
601
+ const key = `${value.file.abs}\0${value.name}`
602
+ if (this.memo.has(key)) return this.memo.get(key) || unresolved
603
+ this.memo.set(key, null) // cycle guard
604
+ const r = this.evalString(value.expr, { file: value.file, depth: ctx.depth + 1 })
605
+ const result = r.sawString ? r : unresolved
606
+ this.memo.set(key, result)
607
+ return result
608
+ }
609
+
610
+ evalCall (callee, argText, ctx, term) {
611
+ const unresolved = { parts: [{ unresolved: term }], sawString: false }
612
+ const args = splitTopLevelArgs(argText).map((a) => a.trim()).filter((a) => a !== '')
613
+ const sub = { ...ctx, depth: ctx.depth + 1 }
614
+
615
+ // string(x): a typed string constant converted back to string.
616
+ if (callee === 'string' && args.length === 1) {
617
+ const r = this.evalString(args[0], sub)
618
+ return r.sawString ? r : unresolved
619
+ }
620
+
621
+ const dot = callee.indexOf('.')
622
+ const alias = dot === -1 ? null : callee.slice(0, dot)
623
+ const fnName = dot === -1 ? callee : callee.slice(dot + 1)
624
+ const stdlib = alias && this.stdlibName(ctx.file, alias)
625
+ if (stdlib === 'fmt' && fnName === 'Sprintf' && args.length >= 1) {
626
+ return this.evalSprintf(args, sub, term)
627
+ }
628
+ if (stdlib === 'strings' && fnName === 'Join' && args.length === 2) {
629
+ const list = this.evalStringList(args[0], sub)
630
+ const sep = this.evalString(args[1], sub)
631
+ if (!list || !isResolved(sep) || !list.some((r) => r.sawString)) return unresolved
632
+ const parts = []
633
+ list.forEach((r, i) => {
634
+ if (i > 0) parts.push({ text: renderResult(sep) })
635
+ parts.push(...r.parts)
636
+ })
637
+ return { parts: mergeParts(parts), sawString: true }
638
+ }
639
+ if (stdlib === 'strings' && ((fnName === 'ReplaceAll' && args.length === 3) || (fnName === 'Replace' && args.length === 4))) {
640
+ const r = this.evalString(args[0], sub)
641
+ const from = this.evalString(args[1], sub)
642
+ const to = this.evalString(args[2], sub)
643
+ const n = fnName === 'Replace' ? Number(args[3]) : -1
644
+ if (!isResolved(r) || !isResolved(from) || !Number.isInteger(n)) return unresolved
645
+ const needle = renderResult(from)
646
+ if (needle === '') return unresolved
647
+ // The replacement may itself be partial: splice its parts in.
648
+ const pieces = renderResult(r).split(needle)
649
+ const parts = []
650
+ pieces.forEach((piece, i) => {
651
+ if (i > 0) parts.push(...(n < 0 || i <= n ? to.parts : [{ text: needle }]))
652
+ parts.push({ text: piece })
653
+ })
654
+ return { parts: mergeParts(parts.filter((p) => p.text !== '')), sawString: true }
655
+ }
656
+ if (stdlib === 'strconv' && fnName === 'Itoa' && args.length === 1) {
657
+ const n = this.evalInt(args[0], sub)
658
+ return n === null ? unresolved : { parts: [{ text: String(n) }], sawString: true }
659
+ }
660
+ if (stdlib === 'strings' && /^(TrimSpace|ToLower|ToUpper|Title)$/.test(fnName) && args.length === 1) {
661
+ const r = this.evalString(args[0], sub)
662
+ if (!isResolved(r)) return unresolved
663
+ const s = renderResult(r)
664
+ const out = fnName === 'TrimSpace'
665
+ ? s.trim()
666
+ : fnName === 'ToLower' ? s.toLowerCase() : fnName === 'ToUpper' ? s.toUpperCase() : s.replace(/\b\w/g, (c) => c.toUpperCase())
667
+ return { parts: [{ text: out }], sawString: true }
668
+ }
669
+
670
+ // A function in this module (or a cached dependency) whose body runs
671
+ // straight to one return statement, with its parameters bound to the
672
+ // call arguments and its local string variables followed. Anything with
673
+ // control flow stays unresolved.
674
+ const fn = this.lookupFunc(ctx.file, callee)
675
+ if (!fn) return unresolved
676
+ const ret = straightLineReturn(fn)
677
+ if (ret === null) {
678
+ const r = this.interpretBranches(fn, args, sub)
679
+ return r && r.sawString ? r : unresolved
680
+ }
681
+ const locals = new Map()
682
+ // Every parameter shadows the package scope, so one that does not
683
+ // evaluate to a string is bound to an unresolved part, never left to
684
+ // fall through to a same-named package constant.
685
+ fn.params.forEach((param, i) => {
686
+ if (!param.name) return
687
+ const arg = i < args.length && !/^\.\.\./.test(param.type) ? args[i] : null
688
+ const r = arg === null ? null : this.evalString(arg, sub)
689
+ locals.set(param.name, r && r.sawString ? r : { parts: [{ unresolved: arg === null ? param.name : arg }], sawString: false })
690
+ })
691
+ const r = this.evalString(ret.expr, {
692
+ file: fn.file,
693
+ locals,
694
+ depth: ctx.depth + 1,
695
+ resolveLocal: this.localResolver(fn.file, fn, ret.offset)
696
+ })
697
+ return r.sawString ? r : unresolved
698
+ }
699
+
700
+ /**
701
+ * A resolver for the local variables of `fn` as they stand at `offset`
702
+ * (an offset into fn.file.masked). Follows `x := expr`, `x = expr`,
703
+ * `var x = expr` and `x += expr` assignments in the function body.
704
+ *
705
+ * Returns a function (ident, ctx) -> Result | null | undefined:
706
+ * undefined when the identifier is not a local of fn (so the package
707
+ * scope applies), null when it is a local or parameter whose value cannot
708
+ * be known here (assigned under a condition, in a loop, from a tuple).
709
+ */
710
+ localResolver (file, fn, offset) {
711
+ if (!fn) return undefined
712
+ const masked = file.masked
713
+ const body = { start: fn.bodyStart, end: Math.min(fn.bodyEnd, offset) }
714
+ const params = new Set(fn.params.map((p) => p.name).filter(Boolean))
715
+ const pairs = file.pairs
716
+ // Brace depth of a position relative to the body: an assignment deeper
717
+ // than the declaration it updates runs conditionally.
718
+ const depthAt = (pos) => {
719
+ let depth = 0
720
+ for (let i = fn.bodyStart + 1; i < pos; i++) {
721
+ const ch = masked[i]
722
+ if (ch === '"' || ch === '`' || ch === "'") { i = skipString(masked, i) - 1; continue }
723
+ if (ch === '{') {
724
+ const close = pairs.get(i)
725
+ if (close !== undefined && close < pos) { i = close; continue }
726
+ depth++
727
+ }
728
+ }
729
+ return depth
730
+ }
731
+ const assignmentsOf = (ident, before) => {
732
+ const out = []
733
+ const text = masked.slice(body.start, before)
734
+ const forms = [
735
+ new RegExp(`(^|[^\\w.])${ident}\\s*(:=|\\+=|=)(?!=)`, 'g'),
736
+ new RegExp(`(^|[^\\w.])var\\s+${ident}\\b[^=\\n;]*(=)(?!=)`, 'g')
737
+ ]
738
+ for (const re of forms) {
739
+ let m
740
+ while ((m = re.exec(text)) !== null) {
741
+ const at = body.start + m.index + m[1].length
742
+ // `a, b := f()` binds a tuple: never a plain string value.
743
+ let k = at - 1
744
+ while (k > body.start && /[ \t]/.test(masked[k])) k--
745
+ if (masked[k] === ',') { out.push({ tuple: true, at }); continue }
746
+ const exprStart = body.start + m.index + m[0].length
747
+ const exprEnd = statementEnd(masked, exprStart, fn.bodyEnd)
748
+ out.push({ op: m[2] === '=' && re === forms[1] ? ':=' : m[2], at, exprStart, exprEnd, depth: depthAt(at) })
749
+ }
750
+ }
751
+ return out.sort((a, b) => a.at - b.at)
752
+ }
753
+ const resolveAt = (ident, before, ctx) => {
754
+ const list = assignmentsOf(ident, before)
755
+ if (list.length === 0) return params.has(ident) ? null : undefined
756
+ if (list.some((a) => a.tuple)) return null
757
+ const declIndex = list.map((a) => a.op).lastIndexOf(':=')
758
+ const startIndex = declIndex === -1 ? 0 : declIndex
759
+ const relevant = list.slice(startIndex)
760
+ const baseDepth = relevant[0].depth
761
+ if (relevant.some((a) => a.depth !== baseDepth)) return null
762
+ // The last plain assignment wins; += after it appends.
763
+ let from = relevant.length - 1
764
+ while (from > 0 && relevant[from].op === '+=') from--
765
+ // An append with no plain assignment before it extends a value this
766
+ // body never set (a parameter or a package variable).
767
+ if (relevant[from].op === '+=') return null
768
+ const parts = []
769
+ let sawString = false
770
+ for (const a of relevant.slice(from)) {
771
+ const sub = {
772
+ file,
773
+ depth: (ctx.depth || 0) + 1,
774
+ locals: ctx.locals,
775
+ resolveLocal: (id, c) => resolveAt(id, a.at, c)
776
+ }
777
+ const r = this.evalString(masked.slice(a.exprStart, a.exprEnd), sub)
778
+ if (r.sawString) sawString = true
779
+ parts.push(...r.parts)
780
+ }
781
+ return sawString ? { parts: mergeParts(parts), sawString } : null
782
+ }
783
+ return (ident, ctx) => resolveAt(ident, body.end, ctx || {})
784
+ }
785
+
786
+ /**
787
+ * The contents of the file a `//go:embed <file>` directive above a
788
+ * string var names, or null. Patterns and multiple files are not a
789
+ * single string, so they stay unresolved.
790
+ */
791
+ embeddedString (value) {
792
+ if (value.kind !== 'var' || !/^string$/.test(value.type || '')) return null
793
+ const before = value.file.content.slice(0, value.start)
794
+ const m = before.match(/\/\/go:embed[ \t]+(\S+)[ \t]*\r?\n\s*(?:var\s*)?$/)
795
+ if (!m || /[*?[]/.test(m[1])) return null
796
+ const abs = path.join(value.file.dir, m[1])
797
+ if (this.overlay.has(abs)) return this.overlay.get(abs)
798
+ try {
799
+ return fs.readFileSync(abs, 'utf8')
800
+ } catch {
801
+ return null
802
+ }
803
+ }
804
+
805
+ /**
806
+ * The values of a call to a function that returns several strings, as in
807
+ * `spec.Example(exampleConfig())` where exampleConfig returns a title,
808
+ * a summary and a config. Returns an array of Results, or null.
809
+ */
810
+ evalTuple (callText, ctx) {
811
+ const call = callText.trim().match(/^([A-Za-z_][\w]*(?:\.[A-Za-z_]\w*)?)\s*\(/)
812
+ if (!call) return null
813
+ const open = callText.trim().indexOf('(', call[1].length)
814
+ const close = findBalancedClose(callText.trim(), open)
815
+ if (close !== callText.trim().length - 1) return null
816
+ const fn = this.lookupFunc(ctx.file, call[1])
817
+ if (!fn || !/^\(/.test(fn.results)) return null
818
+ const ret = straightLineReturn(fn)
819
+ if (ret === null) return null
820
+ const args = splitTopLevelArgs(callText.trim().slice(open + 1, close)).map((a) => a.trim()).filter(Boolean)
821
+ const locals = new Map()
822
+ fn.params.forEach((param, i) => {
823
+ if (!param.name) return
824
+ const r = i < args.length ? this.evalString(args[i], { ...ctx, depth: (ctx.depth || 0) + 1 }) : null
825
+ locals.set(param.name, r && r.sawString ? r : { parts: [{ unresolved: param.name }], sawString: false })
826
+ })
827
+ const sub = { file: fn.file, locals, depth: (ctx.depth || 0) + 1, resolveLocal: this.localResolver(fn.file, fn, ret.offset) }
828
+ return splitTopLevelArgs(ret.expr).map((e) => this.evalString(e, sub))
829
+ }
830
+
831
+ /**
832
+ * An integer expression of literals and constants joined by + and -, as
833
+ * in `strconv.Itoa(maxRetries-1)`. Returns a number or null.
834
+ */
835
+ evalInt (text, ctx) {
836
+ const t = text.trim()
837
+ if ((ctx.depth || 0) > MAX_DEPTH) return null
838
+ const terms = t.match(/^[-+]?\s*[\w.]+(?:\s*[-+]\s*[\w.]+)*$/) ? t.split(/(?=[-+])/) : null
839
+ if (!terms) return null
840
+ let total = 0
841
+ for (const raw of terms) {
842
+ const m = raw.trim().match(/^([-+]?)\s*([\w.]+)$/)
843
+ if (!m) return null
844
+ let v
845
+ if (/^\d+$/.test(m[2])) v = Number(m[2])
846
+ else if (/^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)?$/.test(m[2])) {
847
+ const value = this.lookupValue(ctx.file, m[2])
848
+ if (!value || value.kind !== 'const' || !value.expr) return null
849
+ v = this.evalInt(value.expr, { file: value.file, depth: (ctx.depth || 0) + 1 })
850
+ if (v === null) return null
851
+ } else return null
852
+ total += m[1] === '-' ? -v : v
853
+ }
854
+ return total
855
+ }
856
+
857
+ /** `[]string{...}` literal or an identifier bound to one. */
858
+ evalStringList (text, ctx) {
859
+ let t = text.trim()
860
+ // A variadic parameter bound to the call's arguments.
861
+ if (ctx.locals && ctx.locals.has(t) && ctx.locals.get(t).list) return [...ctx.locals.get(t).list]
862
+ // append(list, a, b) and append(list, rest...): a variadic spread that
863
+ // cannot be evaluated is one unresolved element.
864
+ const app = t.match(/^append\s*\(/)
865
+ if (app) {
866
+ const open = app[0].length - 1
867
+ const close = findBalancedClose(t, open)
868
+ if (close !== t.length - 1) return null
869
+ const parts = splitTopLevelArgs(t.slice(open + 1, close)).map((a) => a.trim()).filter(Boolean)
870
+ if (parts.length === 0) return null
871
+ const base = this.evalStringList(parts[0], ctx)
872
+ if (!base) return null
873
+ for (const item of parts.slice(1)) {
874
+ if (item.endsWith('...')) {
875
+ const spread = this.evalStringList(item.slice(0, -3), ctx)
876
+ base.push(...(spread || [{ parts: [{ unresolved: item }], sawString: false }]))
877
+ } else {
878
+ base.push(this.evalString(item, ctx))
879
+ }
880
+ }
881
+ return base
882
+ }
883
+ if (/^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)?$/.test(t)) {
884
+ const value = this.lookupValue(ctx.file, t)
885
+ if (!value || !value.expr) return null
886
+ t = value.expr.trim()
887
+ ctx = { ...ctx, file: value.file }
888
+ }
889
+ const m = t.match(/^\[\]string\s*\{/)
890
+ if (!m) return null
891
+ const open = m[0].length - 1
892
+ const close = findBalancedClose(t, open, '{', '}')
893
+ if (close !== t.length - 1) return null
894
+ return splitTopLevelArgs(t.slice(open + 1, close)).map((a) => a.trim()).filter(Boolean)
895
+ .map((a) => this.evalString(a, ctx))
896
+ }
897
+
898
+ /**
899
+ * fmt.Sprintf with a resolvable format. An argument that cannot be
900
+ * evaluated becomes an unresolved part in place of its verb, so the rest
901
+ * of the sentence still reaches the review.
902
+ */
903
+ evalSprintf (args, ctx, term) {
904
+ const unresolved = { parts: [{ unresolved: term }], sawString: false }
905
+ const format = this.evalString(args[0], ctx)
906
+ if (!isResolved(format)) return unresolved
907
+ const fmtText = renderResult(format)
908
+ const parts = []
909
+ let argIndex = 1
910
+ let last = 0
911
+ // Match every verb, so one this parser can't render (%.2f, %w, %T, or an
912
+ // explicit argument index such as %[1]s) marks the whole string unresolved
913
+ // instead of staying as literal text and shifting the later arguments.
914
+ const verbs = /%(\[\d+\])?([-+# 0]*(?:\d+|\*)?(?:\.(?:\d+|\*)?)?)([a-zA-Z%])/g
915
+ let m
916
+ while ((m = verbs.exec(fmtText)) !== null) {
917
+ parts.push({ text: fmtText.slice(last, m.index) })
918
+ last = m.index + m[0].length
919
+ const [whole, explicitIndex, flags, verb] = m
920
+ if (verb === '%' && !explicitIndex && !flags) { parts.push({ text: '%' }); continue }
921
+ if (explicitIndex || !'svqdtx'.includes(verb)) return unresolved
922
+ const arg = args[argIndex++]
923
+ if (arg === undefined || flags) return unresolved
924
+ const a = arg.trim()
925
+ if (/^-?\d+$/.test(a) && (verb === 'd' || verb === 'v')) { parts.push({ text: a }); continue }
926
+ if (/^(true|false)$/.test(a) && (verb === 't' || verb === 'v')) { parts.push({ text: a }); continue }
927
+ const r = this.evalString(a, ctx)
928
+ if (!isResolved(r)) {
929
+ parts.push({ unresolved: a || whole })
930
+ continue
931
+ }
932
+ const text = renderResult(r)
933
+ if (verb === 'q') parts.push({ text: JSON.stringify(text) })
934
+ else if (verb === 's' || verb === 'v') parts.push({ text })
935
+ else parts.push({ unresolved: a })
936
+ }
937
+ parts.push({ text: fmtText.slice(last) })
938
+ return { parts: mergeParts(parts.filter((p) => p.text !== '')), sawString: true }
939
+ }
940
+
941
+ /**
942
+ * Interpret a string-returning function whose control flow branches only
943
+ * on boolean parameters bound to literal arguments, such as benthos'
944
+ * service.OutputPerformanceDocs(true, false):
945
+ *
946
+ * if <cond> { ... } (no else; cond over params, !, &&, ||, true, false)
947
+ * x += expr | x = expr | x := expr | var x = expr
948
+ * return [expr] (a bare return yields the named result)
949
+ *
950
+ * Anything else returns null and the call stays unresolved.
951
+ */
952
+ interpretBranches (fn, args, ctx) {
953
+ const masked = fn.file.masked
954
+ const bools = new Map()
955
+ fn.params.forEach((param, i) => {
956
+ const a = (args[i] || '').trim()
957
+ if (param.name && /^bool$/.test(param.type) && /^(true|false)$/.test(a)) bools.set(param.name, a === 'true')
958
+ })
959
+ const named = fn.results.match(/^\(\s*([A-Za-z_]\w*)\s+string\s*\)$/)
960
+ const vars = new Map()
961
+ if (named) vars.set(named[1], { parts: [], sawString: true })
962
+ const evalCond = (text) => {
963
+ const t = text.replace(/\s+/g, ' ').trim()
964
+ if (!/^[\w!&|() ]+$/.test(t)) return null
965
+ const js = t.replace(/[A-Za-z_]\w*/g, (id) => {
966
+ if (id === 'true' || id === 'false') return id
967
+ return bools.has(id) ? String(bools.get(id)) : '@'
968
+ })
969
+ if (js.includes('@')) return null
970
+ if (!/^[truefals!&|() ]+$/.test(js)) return null
971
+ try { return Boolean(Function(`"use strict"; return (${js})`)()) } catch { return null }
972
+ }
973
+ const sub = () => ({
974
+ file: fn.file,
975
+ depth: (ctx.depth || 0) + 1,
976
+ locals: new Map([...vars]),
977
+ resolveLocal: () => undefined
978
+ })
979
+ const run = (start, end) => {
980
+ let i = start
981
+ while (i < end) {
982
+ while (i < end && /[\s;]/.test(masked[i])) i++
983
+ if (i >= end) return undefined
984
+ const rest = masked.slice(i, end)
985
+ let m
986
+ if ((m = rest.match(/^if\b/))) {
987
+ const brace = masked.indexOf('{', i)
988
+ if (brace === -1 || brace >= end) return null
989
+ const close = fn.file.pairs.get(brace)
990
+ if (close === undefined) return null
991
+ const cond = evalCond(masked.slice(i + 2, brace))
992
+ if (cond === null) return null
993
+ if (/^\s*else\b/.test(masked.slice(close + 1, end))) return null
994
+ if (cond) {
995
+ const r = run(brace + 1, close)
996
+ if (r !== undefined) return r
997
+ }
998
+ i = close + 1
999
+ continue
1000
+ }
1001
+ if ((m = rest.match(/^return\b/))) {
1002
+ const stop = statementEnd(masked, i + 6, end)
1003
+ const expr = masked.slice(i + 6, stop).trim()
1004
+ if (expr === '') return named ? vars.get(named[1]) : null
1005
+ return this.evalString(expr, sub())
1006
+ }
1007
+ if ((m = rest.match(/^(?:var\s+)?([A-Za-z_]\w*)\s*(\+=|:=|=)(?!=)\s*/))) {
1008
+ const exprStart = i + m[0].length
1009
+ const stop = statementEnd(masked, exprStart, end)
1010
+ const r = this.evalString(masked.slice(exprStart, stop), sub())
1011
+ if (m[2] === '+=') {
1012
+ const prev = vars.get(m[1])
1013
+ if (!prev) return null
1014
+ vars.set(m[1], { parts: mergeParts([...prev.parts, ...r.parts]), sawString: true })
1015
+ } else {
1016
+ vars.set(m[1], r)
1017
+ }
1018
+ i = stop + 1
1019
+ continue
1020
+ }
1021
+ return null
1022
+ }
1023
+ return undefined
1024
+ }
1025
+ const r = run(fn.bodyStart + 1, fn.bodyEnd)
1026
+ if (r === undefined) return named ? vars.get(named[1]) : null
1027
+ return r
1028
+ }
1029
+
1030
+ /** The standard-library package an alias names in `file`, if any. */
1031
+ stdlibName (file, alias) {
1032
+ const importPath = file.imports.get(alias) || [...file.imports.values()].find((p) => p === alias)
1033
+ if (!importPath) return null
1034
+ return importPath.includes('.') ? null : importPath
1035
+ }
1036
+ }
1037
+
1038
+ /**
1039
+ * The return statement of a function whose body runs straight to it: no
1040
+ * if/for/switch/select/goto, no function literal, and exactly one return.
1041
+ * Returns { expr, offset } (offset into fn.file.masked) or null.
1042
+ */
1043
+ function straightLineReturn (fn) {
1044
+ const masked = fn.file.masked
1045
+ const body = masked.slice(fn.bodyStart + 1, fn.bodyEnd)
1046
+ if (/\b(?:if|for|switch|select|goto|func)\b/.test(stripStrings(body))) return null
1047
+ const returns = [...stripStrings(body).matchAll(/(^|[^\w.])return\b/g)]
1048
+ if (returns.length !== 1) return null
1049
+ const at = fn.bodyStart + 1 + returns[0].index + returns[0][1].length + 'return'.length
1050
+ const end = statementEnd(masked, at, fn.bodyEnd)
1051
+ const expr = masked.slice(at, end)
1052
+ if (masked.slice(end, fn.bodyEnd).replace(/[;\s]/g, '') !== '') return null
1053
+ if (expr.trim() === '') return null
1054
+ return { expr, offset: at }
1055
+ }
1056
+
1057
+ /** Blank string and rune literal contents, preserving offsets. */
1058
+ function stripStrings (text) {
1059
+ let out = ''
1060
+ let i = 0
1061
+ while (i < text.length) {
1062
+ const ch = text[i]
1063
+ if (ch === '"' || ch === '`' || ch === "'") {
1064
+ const end = skipString(text, i)
1065
+ out += ch + text.slice(i + 1, end - 1).replace(/[^\n]/g, ' ') + (end - 1 > i ? text[end - 1] : '')
1066
+ i = end
1067
+ continue
1068
+ }
1069
+ out += ch
1070
+ i++
1071
+ }
1072
+ return out
1073
+ }
1074
+
1075
+ /** The expression of a function body that is exactly `return <expr>`. */
1076
+ function singleReturn (fn) {
1077
+ const body = fn.file.masked.slice(fn.bodyStart + 1, fn.bodyEnd).trim()
1078
+ const m = body.match(/^return\s+([\s\S]+)$/)
1079
+ if (!m) return null
1080
+ // A second statement after the return expression is not a single return.
1081
+ const expr = m[1]
1082
+ const end = statementEnd(expr, 0)
1083
+ if (expr.slice(end).trim() !== '') return null
1084
+ return expr
1085
+ }
1086
+
1087
+ function mergeParts (parts) {
1088
+ const out = []
1089
+ for (const part of parts) {
1090
+ const last = out[out.length - 1]
1091
+ if (part.text !== undefined && last && last.text !== undefined) last.text += part.text
1092
+ else out.push({ ...part })
1093
+ }
1094
+ return out
1095
+ }
1096
+
1097
+ function isResolved (result) {
1098
+ return result.sawString && result.parts.every((p) => p.text !== undefined)
1099
+ }
1100
+
1101
+ /** The string a result renders to, with numbered placeholders for gaps. */
1102
+ function renderResult (result) {
1103
+ let n = 0
1104
+ return result.parts.map((p) => (p.text !== undefined ? p.text : PLACEHOLDER(++n))).join('')
1105
+ }
1106
+
1107
+ function unresolvedParts (result) {
1108
+ return result.parts.filter((p) => p.unresolved !== undefined).map((p) => p.unresolved.replace(/\s+/g, ' '))
1109
+ }
1110
+
1111
+ const INDEX_CACHE = new Map()
1112
+
1113
+ /** A shared index per repo path, so one lint run parses each file once. */
1114
+ function indexFor (repo, options = {}) {
1115
+ const key = `${path.resolve(repo)}\0${options.external !== false}`
1116
+ if (!INDEX_CACHE.has(key)) INDEX_CACHE.set(key, new GoIndex(repo, options))
1117
+ return INDEX_CACHE.get(key)
1118
+ }
1119
+
1120
+ function clearIndexCache () {
1121
+ INDEX_CACHE.clear()
1122
+ }
1123
+
1124
+ module.exports = {
1125
+ GoIndex,
1126
+ indexFor,
1127
+ clearIndexCache,
1128
+ bracketPairs,
1129
+ statementEnd,
1130
+ splitTopLevel,
1131
+ parseParams,
1132
+ scanTopLevel,
1133
+ makeLineIndex,
1134
+ renderResult,
1135
+ isResolved,
1136
+ unresolvedParts,
1137
+ singleReturn,
1138
+ straightLineReturn,
1139
+ stripStrings,
1140
+ mergeParts,
1141
+ PLACEHOLDER,
1142
+ PLACEHOLDER_RE
1143
+ }