@sailshq/language-server 0.1.0 → 0.2.1
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 +325 -76
- package/completions/data-types-completion.js +6 -5
- package/completions/helper-inputs-completion.js +91 -0
- package/completions/helpers-completion.js +85 -0
- package/completions/input-props-completion.js +19 -23
- package/completions/model-attribute-props-completion.js +36 -32
- package/completions/model-attributes-completion.js +103 -10
- package/completions/model-methods-completion.js +20 -10
- package/go-to-definitions/go-to-helper.js +34 -29
- package/index.js +12 -3
- package/package.json +3 -1
- package/validators/validate-document.js +23 -1
- package/validators/validate-helper-input-exist.js +42 -0
- package/validators/validate-model-attribute-exist.js +278 -109
- package/validators/validate-model-exist.js +64 -0
- package/validators/validate-required-helper-input.js +49 -0
- package/validators/validate-required-model-attribute.js +56 -0
- package/validators/validate-view-exist.js +86 -0
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
|
-
|
|
106
|
-
|
|
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: '
|
|
134
|
-
description: '
|
|
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: '
|
|
149
|
-
description: '
|
|
128
|
+
name: 'avg',
|
|
129
|
+
description: 'Calculate the average of a numeric attribute.'
|
|
150
130
|
},
|
|
151
131
|
{
|
|
152
|
-
name: '
|
|
153
|
-
description: '
|
|
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
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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[
|
|
22
|
+
const prefix = match[2] || ''
|
|
22
23
|
|
|
23
24
|
const contextText = before.toLowerCase()
|
|
24
|
-
const inAttributes = /attributes\s*:\s*{[
|
|
25
|
-
const inInputs = /inputs\s*:\s*{[
|
|
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: '
|
|
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
|
+
}
|