@sailshq/language-server 0.1.0 → 0.2.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.
package/SailsParser.js CHANGED
@@ -1,5 +1,7 @@
1
1
  const fs = require('fs').promises
2
2
  const path = require('path')
3
+ const acorn = require('acorn')
4
+ const walk = require('acorn-walk')
3
5
 
4
6
  class SailsParser {
5
7
  constructor(rootDir) {
@@ -83,36 +85,21 @@ class SailsParser {
83
85
  const dir = path.join(this.rootDir, 'api', 'models')
84
86
  const models = {}
85
87
 
86
- // Define Waterline static and chainable methods
87
88
  const STATIC_METHODS = [
88
- {
89
- name: 'find',
90
- description: 'Retrieve all records matching criteria.'
91
- },
89
+ { name: 'find', description: 'Retrieve all records matching criteria.' },
92
90
  {
93
91
  name: 'findOne',
94
92
  description: 'Retrieve a single record matching criteria.'
95
93
  },
96
- {
97
- name: 'create',
98
- description: 'Create a new record.'
99
- },
94
+ { name: 'create', description: 'Create a new record.' },
100
95
  {
101
96
  name: 'createEach',
102
97
  description: 'Create multiple new records in a batch.'
103
98
  },
104
- {
105
- name: 'update',
106
- description: 'Update records matching criteria.'
107
- },
108
- {
109
- name: 'destroy',
110
- description: 'Delete records matching criteria.'
111
- },
112
- {
113
- name: 'count',
114
- description: 'Count records matching criteria.'
115
- },
99
+ { name: 'update', description: 'Update records matching criteria.' },
100
+ { name: 'destroy', description: 'Delete records matching criteria.' },
101
+ { name: 'destroyOne', description: 'Delete a single record.' },
102
+ { name: 'count', description: 'Count records matching criteria.' },
116
103
  {
117
104
  name: 'replaceCollection',
118
105
  description: 'Replace all items in a collection association.'
@@ -129,85 +116,208 @@ class SailsParser {
129
116
  name: 'findOrCreate',
130
117
  description: 'Find a record or create it if it does not exist.'
131
118
  },
119
+ { name: 'stream', description: 'Stream results as they are found.' },
120
+ { name: 'sum', description: 'Calculate the sum of a numeric attribute.' },
121
+ { name: 'archive', description: 'Archive records instead of deleting.' },
122
+ { name: 'archiveOne', description: 'Archive a single record.' },
132
123
  {
133
- name: 'findOrCreateEach',
134
- description: 'Find or create multiple records in a batch.'
135
- }
136
- ]
137
-
138
- const CHAINABLE_METHODS = [
139
- {
140
- name: 'where',
141
- description: 'Filter records by criteria.'
142
- },
143
- {
144
- name: 'limit',
145
- description: 'Limit the number of records returned.'
124
+ name: 'validate',
125
+ description: 'Validate a record against its schema.'
146
126
  },
147
127
  {
148
- name: 'skip',
149
- description: 'Skip a number of records (for pagination).'
128
+ name: 'avg',
129
+ description: 'Calculate the average of a numeric attribute.'
150
130
  },
151
131
  {
152
- name: 'sort',
153
- description: 'Sort records by specified attributes.'
154
- },
155
- {
156
- name: 'populate',
157
- description: 'Populate associated records.'
158
- },
159
- {
160
- name: 'select',
161
- description: 'Select only specific attributes to return.'
162
- },
163
- {
164
- name: 'omit',
165
- description: 'Omit specific attributes from the result.'
166
- },
167
- {
168
- name: 'meta',
169
- description: 'Pass additional options to the query.'
170
- },
171
- {
172
- name: 'decrypt',
173
- description: 'Decrypt encrypted attributes in the result.'
132
+ name: 'getDatastore',
133
+ description: 'Get the datastore used by this model.'
174
134
  }
175
135
  ]
176
136
 
137
+ const CHAINABLE_METHODS = [
138
+ { name: 'where', description: 'Filter records by criteria.' },
139
+ { name: 'limit', description: 'Limit the number of records returned.' },
140
+ { name: 'skip', description: 'Skip a number of records.' },
141
+ { name: 'sort', description: 'Sort records by attributes.' },
142
+ { name: 'populate', description: 'Populate associated records.' },
143
+ { name: 'select', description: 'Select specific attributes.' },
144
+ { name: 'omit', description: 'Omit specific attributes.' },
145
+ { name: 'meta', description: 'Pass additional options.' },
146
+ { name: 'decrypt', description: 'Decrypt encrypted attributes.' },
147
+ { name: 'fetch', description: 'Return affected records.' },
148
+ { name: 'intercept', description: 'Intercept results with a function.' },
149
+ { name: 'eachRecord', description: 'Iterate over each record.' },
150
+ { name: 'toPromise', description: 'Return a promise for the results.' },
151
+ { name: 'catch', description: 'Handle errors in the query.' },
152
+ {
153
+ name: 'usingConnection',
154
+ description: 'Use a specific database connection.'
155
+ }
156
+ ]
157
+ const context = this
177
158
  if (!(await this.#directoryExists(dir))) return models
178
159
 
179
160
  const files = await fs.readdir(dir)
161
+
162
+ // Retrieve attributes from config/models.js
163
+ let defaultAttributes = {}
164
+ const modelsConfigPath = path.join(this.rootDir, 'config', 'models.js')
165
+ if (await this.#fileExists(modelsConfigPath)) {
166
+ try {
167
+ const configCode = await fs.readFile(modelsConfigPath, 'utf8')
168
+ const configAST = acorn.parse(configCode, {
169
+ ecmaVersion: 'latest',
170
+ sourceType: 'module'
171
+ })
172
+
173
+ walk.simple(configAST, {
174
+ AssignmentExpression(node) {
175
+ // Support: module.exports = { attributes: ... } and { models: { attributes: ... } }
176
+ if (
177
+ node.left.type === 'MemberExpression' &&
178
+ node.left.object.name === 'module' &&
179
+ node.left.property.name === 'exports' &&
180
+ node.right.type === 'ObjectExpression'
181
+ ) {
182
+ for (const prop of node.right.properties) {
183
+ if (
184
+ prop.key?.name === 'attributes' &&
185
+ prop.value?.type === 'ObjectExpression'
186
+ ) {
187
+ defaultAttributes = context.#extractObjectLiteral(prop.value)
188
+ }
189
+ if (
190
+ prop.key?.name === 'models' &&
191
+ prop.value?.type === 'ObjectExpression'
192
+ ) {
193
+ for (const inner of prop.value.properties) {
194
+ if (
195
+ inner.key?.name === 'attributes' &&
196
+ inner.value?.type === 'ObjectExpression'
197
+ ) {
198
+ defaultAttributes = context.#extractObjectLiteral(
199
+ inner.value
200
+ )
201
+ }
202
+ }
203
+ }
204
+ }
205
+ }
206
+ // Support: module.exports.models = { attributes: ... }
207
+ if (
208
+ node.left.type === 'MemberExpression' &&
209
+ node.left.object.type === 'MemberExpression' &&
210
+ node.left.object.object.name === 'module' &&
211
+ node.left.object.property.name === 'exports' &&
212
+ node.left.property.name === 'models' &&
213
+ node.right.type === 'ObjectExpression'
214
+ ) {
215
+ for (const prop of node.right.properties) {
216
+ if (
217
+ prop.key?.name === 'attributes' &&
218
+ prop.value?.type === 'ObjectExpression'
219
+ ) {
220
+ defaultAttributes = context.#extractObjectLiteral(prop.value)
221
+ }
222
+ }
223
+ }
224
+ }
225
+ })
226
+ } catch (err) {
227
+ console.error('Error parsing config/models.js', err)
228
+ }
229
+ }
230
+
180
231
  for (const file of files) {
181
232
  if (!file.endsWith('.js')) continue
182
233
 
183
234
  const name = file.slice(0, -3)
184
235
  const modelPath = path.join(dir, file)
236
+ let attributes = {}
237
+ const context = this
185
238
 
186
239
  try {
187
- const model = require(modelPath)
188
- const info = {
189
- path: modelPath,
190
- methods: STATIC_METHODS,
191
- chainableMethods: CHAINABLE_METHODS,
192
- attributes: { ...model.attributes }
193
- }
194
-
195
- const modelsConfigPath = path.join(this.rootDir, 'config', 'models.js')
196
-
197
- if (await fs.stat(modelsConfigPath)) {
198
- const modelsConfig = require(modelsConfigPath)
199
- if (modelsConfig.attributes) {
200
- info.attributes = { ...modelsConfig.attributes, ...info.attributes }
240
+ const code = await fs.readFile(modelPath, 'utf8')
241
+ const ast = acorn.parse(code, {
242
+ ecmaVersion: 'latest',
243
+ sourceType: 'module'
244
+ })
245
+
246
+ walk.simple(ast, {
247
+ AssignmentExpression(node) {
248
+ // Handle: module.exports = { attributes: ... }
249
+ if (
250
+ node.left.type === 'MemberExpression' &&
251
+ node.left.object.name === 'module' &&
252
+ node.left.property.name === 'exports' &&
253
+ node.right.type === 'ObjectExpression'
254
+ ) {
255
+ for (const prop of node.right.properties) {
256
+ if (
257
+ prop.key?.name === 'attributes' &&
258
+ prop.value?.type === 'ObjectExpression'
259
+ ) {
260
+ attributes = context.#extractObjectLiteral(prop.value)
261
+ }
262
+ }
263
+ }
264
+ // Legacy: module.exports.attributes = { ... }
265
+ else if (
266
+ node.left.type === 'MemberExpression' &&
267
+ node.left.object.type === 'MemberExpression' &&
268
+ node.left.object.object.name === 'module' &&
269
+ node.left.object.property.name === 'exports' &&
270
+ node.left.property.name === 'attributes' &&
271
+ node.right.type === 'ObjectExpression'
272
+ ) {
273
+ attributes = context.#extractObjectLiteral(node.right)
274
+ }
275
+ },
276
+ ExportDefaultDeclaration(node) {
277
+ if (node.declaration.type === 'ObjectExpression') {
278
+ for (const prop of node.declaration.properties) {
279
+ if (
280
+ prop.key?.name === 'attributes' &&
281
+ prop.value?.type === 'ObjectExpression'
282
+ ) {
283
+ attributes = context.#extractObjectLiteral(prop.value)
284
+ }
285
+ }
286
+ }
201
287
  }
202
- }
203
- models[name] = info
288
+ })
204
289
  } catch (err) {
205
- console.error(`Error requiring model: ${file}`, err)
290
+ console.error(`Error parsing model: ${file}`, err)
291
+ continue
292
+ }
293
+
294
+ // Merge defaultAttributes first, then model attributes (model overrides default)
295
+ const mergedAttributes = {}
296
+ for (const key of Object.keys(defaultAttributes)) {
297
+ mergedAttributes[key] = defaultAttributes[key]
298
+ }
299
+ for (const key of Object.keys(attributes)) {
300
+ mergedAttributes[key] = attributes[key]
301
+ }
302
+ models[name] = {
303
+ path: modelPath,
304
+ methods: STATIC_METHODS,
305
+ chainableMethods: CHAINABLE_METHODS,
306
+ attributes: mergedAttributes
206
307
  }
207
308
  }
309
+
208
310
  return models
209
311
  }
210
312
 
313
+ async #fileExists(filePath) {
314
+ try {
315
+ const stat = await fs.stat(filePath)
316
+ return stat.isFile()
317
+ } catch {
318
+ return false
319
+ }
320
+ }
211
321
  async #parseViews() {
212
322
  const dir = path.join(this.rootDir, 'views')
213
323
  const views = {}
@@ -303,7 +413,114 @@ class SailsParser {
303
413
  break
304
414
  }
305
415
  }
306
- helpers[name] = { path: fullPath, fnLine }
416
+ // Extract inputs and description using acorn
417
+ let inputs = {}
418
+ let description = undefined
419
+ const context = this
420
+ try {
421
+ const ast = acorn.parse(content, {
422
+ ecmaVersion: 'latest',
423
+ sourceType: 'module'
424
+ })
425
+ walk.simple(ast, {
426
+ AssignmentExpression(node) {
427
+ // Only handle: module.exports = { ... }
428
+ if (
429
+ node.left.type === 'MemberExpression' &&
430
+ node.left.object.name === 'module' &&
431
+ node.left.property.name === 'exports' &&
432
+ node.right.type === 'ObjectExpression'
433
+ ) {
434
+ for (const prop of node.right.properties) {
435
+ if (
436
+ prop.key &&
437
+ prop.key.name === 'inputs' &&
438
+ prop.value.type === 'ObjectExpression'
439
+ ) {
440
+ inputs = context.#extractObjectLiteral(prop.value)
441
+ }
442
+ if (
443
+ prop.key &&
444
+ prop.key.name === 'description' &&
445
+ (prop.value.type === 'Literal' ||
446
+ prop.value.type === 'TemplateLiteral')
447
+ ) {
448
+ if (prop.value.type === 'Literal') {
449
+ description = prop.value.value
450
+ } else if (prop.value.type === 'TemplateLiteral') {
451
+ description = prop.value.quasis
452
+ .map((q) => q.value.cooked)
453
+ .join('')
454
+ }
455
+ }
456
+ }
457
+ }
458
+ },
459
+ ExportDefaultDeclaration(node) {
460
+ // Handle: export default { ... }
461
+ if (
462
+ node.declaration &&
463
+ node.declaration.type === 'ObjectExpression'
464
+ ) {
465
+ for (const prop of node.declaration.properties) {
466
+ if (
467
+ prop.key &&
468
+ prop.key.name === 'inputs' &&
469
+ prop.value.type === 'ObjectExpression'
470
+ ) {
471
+ inputs = context.#extractObjectLiteral(prop.value)
472
+ }
473
+ if (
474
+ prop.key &&
475
+ prop.key.name === 'description' &&
476
+ (prop.value.type === 'Literal' ||
477
+ prop.value.type === 'TemplateLiteral')
478
+ ) {
479
+ if (prop.value.type === 'Literal') {
480
+ description = prop.value.value
481
+ } else if (prop.value.type === 'TemplateLiteral') {
482
+ description = prop.value.quasis
483
+ .map((q) => q.value.cooked)
484
+ .join('')
485
+ }
486
+ }
487
+ }
488
+ }
489
+ }
490
+ })
491
+ } catch (e) {}
492
+ // Fallback: regex extract inputs/description if still empty
493
+ if (!inputs || Object.keys(inputs).length === 0) {
494
+ const match = content.match(/inputs\s*:\s*\{([\s\S]*?)\n\s*\}/m)
495
+ if (match) {
496
+ try {
497
+ // Try to parse as JS object
498
+ const fakeObj = `({${match[1]}})`
499
+ const ast = acorn.parse(fakeObj, { ecmaVersion: 'latest' })
500
+ let obj = {}
501
+ walk.simple(ast, {
502
+ ObjectExpression(node) {
503
+ if (!obj || Object.keys(obj).length === 0) {
504
+ obj = context.#extractObjectLiteral(node)
505
+ }
506
+ }
507
+ })
508
+ if (obj && Object.keys(obj).length > 0) {
509
+ inputs = obj
510
+ }
511
+ } catch (e) {}
512
+ }
513
+ }
514
+ if (!description) {
515
+ // Try to extract description: '...' or description: "..."
516
+ const descMatch = content.match(
517
+ /description\s*:\s*(['"])([\s\S]*?)\1/
518
+ )
519
+ if (descMatch) {
520
+ description = descMatch[2]
521
+ }
522
+ }
523
+ helpers[name] = { path: fullPath, fnLine, inputs, description }
307
524
  }
308
525
  }
309
526
  }
@@ -312,6 +529,38 @@ class SailsParser {
312
529
  return helpers
313
530
  }
314
531
 
532
+ #extractObjectLiteral(node) {
533
+ if (node.type !== 'ObjectExpression') return undefined
534
+ const obj = {}
535
+ for (const prop of node.properties) {
536
+ if (prop.type === 'Property') {
537
+ const key =
538
+ prop.key.type === 'Identifier' ? prop.key.name : prop.key.value
539
+ let value
540
+ if (prop.value.type === 'ObjectExpression') {
541
+ value = this.#extractObjectLiteral(prop.value)
542
+ } else if (prop.value.type === 'ArrayExpression') {
543
+ value = prop.value.elements.map((el) =>
544
+ el.type === 'ObjectExpression'
545
+ ? this.#extractObjectLiteral(el)
546
+ : el.type === 'Literal'
547
+ ? el.value
548
+ : el.type === 'Identifier'
549
+ ? el.name
550
+ : undefined
551
+ )
552
+ } else if (prop.value.type === 'Literal') {
553
+ value = prop.value.value
554
+ } else if (prop.value.type === 'Identifier') {
555
+ value = prop.value.name
556
+ } else {
557
+ value = undefined
558
+ }
559
+ obj[key] = value
560
+ }
561
+ }
562
+ return obj
563
+ }
315
564
  #getDataTypes() {
316
565
  return [
317
566
  {
@@ -15,14 +15,15 @@ module.exports = function dataTypesCompletion(document, position, typeMap) {
15
15
  const offset = document.offsetAt(position)
16
16
  const before = text.substring(0, offset)
17
17
 
18
- const match = before.match(/type\s*:\s*['"]([a-z]*)$/i)
18
+ // Require `type: '` or `type: "` with optional partial type after it
19
+ const match = before.match(/type\s*:\s*(['"])([a-z]*)$/i)
19
20
  if (!match) return []
20
21
 
21
- const prefix = match[1]
22
+ const prefix = match[2] || ''
22
23
 
23
24
  const contextText = before.toLowerCase()
24
- const inAttributes = /attributes\s*:\s*{[^}]*$/.test(contextText)
25
- const inInputs = /inputs\s*:\s*{[^}]*$/.test(contextText)
25
+ const inAttributes = /attributes\s*:\s*{([\s\S]*)$/.test(contextText)
26
+ const inInputs = /inputs\s*:\s*{([\s\S]*)$/.test(contextText)
26
27
 
27
28
  if (!(inAttributes || inInputs)) return []
28
29
 
@@ -31,7 +32,7 @@ module.exports = function dataTypesCompletion(document, position, typeMap) {
31
32
  .map(({ type, description }) => ({
32
33
  label: type,
33
34
  kind: lsp.CompletionItemKind.TypeParameter,
34
- detail: 'Validation type',
35
+ detail: 'Data type',
35
36
  documentation: description,
36
37
  insertText: type
37
38
  }))
@@ -0,0 +1,91 @@
1
+ const lsp = require('vscode-languageserver/node')
2
+
3
+ // Find the helper path context from the line, e.g. sails.helpers.email.sendEmail.with({
4
+ function getHelperPath(line) {
5
+ const match = line.match(
6
+ /sails\.helpers((?:\.[a-zA-Z0-9_]+)+)\.with\s*\(\s*\{[^}]*$/
7
+ )
8
+ if (!match) return null
9
+ // e.g. '.email.sendEmail' => ['email', 'sendEmail']
10
+ return match[1].split('.').filter(Boolean)
11
+ }
12
+
13
+ function getInputCompletionItems(inputsObj) {
14
+ if (!inputsObj || typeof inputsObj !== 'object') return []
15
+ return Object.entries(inputsObj).map(([inputName, inputDef]) => {
16
+ let type = inputDef?.type
17
+ let required = inputDef?.required ? 'required' : 'optional'
18
+ let description = inputDef?.description || ''
19
+ let detail = type ? `${type} (${required})` : required
20
+ return {
21
+ label: inputName,
22
+ kind: lsp.CompletionItemKind.Field,
23
+ detail: detail,
24
+ documentation: description,
25
+ insertText: `${inputName}: `
26
+ }
27
+ })
28
+ }
29
+
30
+ module.exports = function helperInputsCompletion(document, position, typeMap) {
31
+ const text = document.getText()
32
+ const offset = document.offsetAt(position)
33
+ const before = text.substring(0, offset)
34
+ const lines = before.split('\n')
35
+ const line = lines[lines.length - 1]
36
+
37
+ // Only trigger if not after a colon (:) on this line
38
+ // e.g. don't trigger if "foo: '" or "foo: \"" or "foo: 1"
39
+ // But DO trigger after a comma (,) or at the start of a new property
40
+ // Find the text before the cursor on this line
41
+ const beforeCursor = line.slice(0, position.character)
42
+ // If the last non-whitespace character before the cursor is a colon, do not complete
43
+ // (but allow after comma, or at start of line/object)
44
+ const lastColon = beforeCursor.lastIndexOf(':')
45
+ const lastComma = beforeCursor.lastIndexOf(',')
46
+ // If the last colon is after the last comma, and after any opening brace, suppress completion
47
+ if (lastColon > lastComma && lastColon > beforeCursor.lastIndexOf('{'))
48
+ return []
49
+
50
+ const pathParts = getHelperPath(line)
51
+ if (!pathParts) return []
52
+
53
+ // Find already-used property names in the current object literal
54
+ // We'll look for all foo: ... pairs before the cursor in the current .with({ ... })
55
+ const objectStart = before.lastIndexOf('{')
56
+ const objectEnd = before.lastIndexOf('}')
57
+ let usedProps = new Set()
58
+ if (objectStart !== -1 && (objectEnd === -1 || objectStart > objectEnd)) {
59
+ // Get the text inside the current object literal up to the cursor
60
+ const objectText = before.slice(objectStart, offset)
61
+ // Match all property names before the cursor
62
+ const propRegex = /([a-zA-Z0-9_]+)\s*:/g
63
+ let m
64
+ while ((m = propRegex.exec(objectText)) !== null) {
65
+ usedProps.add(m[1])
66
+ }
67
+ }
68
+
69
+ // Convert camelCase to kebab-case for the last part
70
+ function camelToKebab(str) {
71
+ return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
72
+ }
73
+ // Try to find the helper in typeMap.helpers
74
+ let helperKey = pathParts.join('/')
75
+ let helper = typeMap.helpers[helperKey]
76
+ if (!helper) {
77
+ // Try kebab-case for last part
78
+ const last = pathParts[pathParts.length - 1]
79
+ pathParts[pathParts.length - 1] = camelToKebab(last)
80
+ helperKey = pathParts.join('/')
81
+ helper = typeMap.helpers[helperKey]
82
+ }
83
+ if (!helper || !helper.inputs || typeof helper.inputs !== 'object') return []
84
+
85
+ // Filter out already-used properties
86
+ const availableInputs = Object.fromEntries(
87
+ Object.entries(helper.inputs).filter(([key]) => !usedProps.has(key))
88
+ )
89
+
90
+ return getInputCompletionItems(availableInputs)
91
+ }
@@ -0,0 +1,85 @@
1
+ const { CompletionItemKind } = require('vscode-languageserver/node')
2
+
3
+ // Convert kebab-case to camelCase (e.g., 'send-email' -> 'sendEmail')
4
+ function kebabToCamel(str) {
5
+ return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
6
+ }
7
+
8
+ // Get the helper path context from the line, e.g. sails.helpers.email.
9
+ function getHelpersContext(line) {
10
+ const match = line.match(/sails\.helpers((?:\.[a-zA-Z0-9_]+)*)\.$/)
11
+ if (!match) return []
12
+ // e.g. '.email.foo.' => ['email', 'foo']
13
+ return match[1] ? match[1].split('.').filter(Boolean) : []
14
+ }
15
+
16
+ module.exports = function helpersCompletion(document, position, typeMap) {
17
+ const line = document.getText({
18
+ start: { line: position.line, character: 0 },
19
+ end: position
20
+ })
21
+ // Prevent helpers completion inside .with({ ... })
22
+ if (/\.with\s*\(\s*\{[^}]*$/.test(line)) {
23
+ return []
24
+ }
25
+ // Prevent helpers completion inside sails.helpers.foo({ ... })
26
+ if (/sails\.helpers(?:\.[a-zA-Z0-9_]+)+\s*\(\s*\{[^}]*$/.test(line)) {
27
+ return []
28
+ }
29
+ const helpers = typeMap.helpers || {}
30
+ const context = getHelpersContext(line.trim())
31
+ if (!line.trim().includes('sails.helpers.')) return []
32
+
33
+ // Build a tree of helpers from the flat keys
34
+ const tree = {}
35
+ for (const key of Object.keys(helpers)) {
36
+ const parts = key.split('/')
37
+ let node = tree
38
+ for (let i = 0; i < parts.length; i++) {
39
+ const part = parts[i]
40
+ if (!node[part])
41
+ node[part] =
42
+ i === parts.length - 1 ? { __isHelper: true, __key: key } : {}
43
+ node = node[part]
44
+ }
45
+ }
46
+
47
+ // Traverse the tree according to the context
48
+ let node = tree
49
+ for (const part of context) {
50
+ if (!node[part]) return []
51
+ node = node[part]
52
+ }
53
+
54
+ // If at a namespace, suggest children (namespaces or helpers)
55
+ return Object.entries(node)
56
+ .filter(([k]) => !k.startsWith('__'))
57
+ .map(([k, v]) => {
58
+ if (v.__isHelper) {
59
+ const helperInfo = helpers[v.__key] || {}
60
+ // Updated: check if inputs is a non-empty object
61
+ const hasInputs =
62
+ helperInfo.inputs &&
63
+ typeof helperInfo.inputs === 'object' &&
64
+ Object.keys(helperInfo.inputs).length > 0
65
+ return {
66
+ label: kebabToCamel(k),
67
+ kind: CompletionItemKind.Method,
68
+ detail: helperInfo.description || 'Helper function',
69
+ documentation: helperInfo.path || '',
70
+ insertText: hasInputs
71
+ ? `${kebabToCamel(k)}.with({$0})`
72
+ : `${kebabToCamel(k)}()`,
73
+ insertTextFormat: 2 // Snippet
74
+ }
75
+ } else {
76
+ // Namespace/folder
77
+ return {
78
+ label: k,
79
+ kind: CompletionItemKind.Module,
80
+ detail: 'Helper namespace',
81
+ insertText: k + '.'
82
+ }
83
+ }
84
+ })
85
+ }