@sailshq/language-server 0.0.5 → 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 +652 -0
- package/completions/actions-completion.js +36 -0
- package/completions/data-types-completion.js +39 -0
- package/completions/helper-inputs-completion.js +91 -0
- package/completions/helpers-completion.js +85 -0
- package/completions/inertia-pages-completion.js +33 -0
- package/completions/input-props-completion.js +52 -0
- package/completions/model-attribute-props-completion.js +57 -0
- package/completions/model-attributes-completion.js +195 -0
- package/completions/model-methods-completion.js +71 -0
- package/completions/models-completion.js +52 -0
- package/completions/policies-completion.js +32 -0
- package/completions/views-completion.js +35 -0
- package/go-to-definitions/go-to-action.js +26 -49
- package/go-to-definitions/go-to-helper.js +37 -45
- package/go-to-definitions/go-to-model.js +39 -0
- package/go-to-definitions/go-to-page.js +38 -0
- package/go-to-definitions/go-to-policy.js +23 -72
- package/go-to-definitions/go-to-view.js +28 -55
- package/index.js +103 -19
- package/package.json +1 -1
- package/validators/validate-action-exist.js +28 -51
- package/validators/validate-data-type.js +34 -0
- package/validators/validate-document.js +42 -4
- package/validators/validate-helper-input-exist.js +42 -0
- package/validators/validate-model-attribute-exist.js +297 -0
- package/validators/validate-model-exist.js +64 -0
- package/validators/validate-page-exist.js +42 -0
- package/validators/validate-policy-exist.js +45 -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/completions/sails-completions.js +0 -63
- package/go-to-definitions/go-to-inertia-page.js +0 -53
- package/helpers/find-fn-line.js +0 -21
- package/helpers/find-project-root.js +0 -18
- package/helpers/find-sails.js +0 -12
- package/helpers/load-sails.js +0 -39
package/SailsParser.js
ADDED
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
const fs = require('fs').promises
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const acorn = require('acorn')
|
|
4
|
+
const walk = require('acorn-walk')
|
|
5
|
+
|
|
6
|
+
class SailsParser {
|
|
7
|
+
constructor(rootDir) {
|
|
8
|
+
this.rootDir = rootDir
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
setRootDir(rootDir) {
|
|
12
|
+
this.rootDir = rootDir
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async #readFile(filePath) {
|
|
16
|
+
try {
|
|
17
|
+
return await fs.readFile(filePath, 'utf8')
|
|
18
|
+
} catch (error) {
|
|
19
|
+
console.error(`Error reading file: ${filePath}`, error)
|
|
20
|
+
return ''
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async #parseAction(filePath) {
|
|
25
|
+
const content = await this.#readFile(filePath)
|
|
26
|
+
const info = { inputs: {}, exits: {}, fnLine: 0 }
|
|
27
|
+
|
|
28
|
+
// Extract inputs
|
|
29
|
+
const inputsMatch = content.match(/inputs\s*:\s*\{([\s\S]*?)\}/)
|
|
30
|
+
if (inputsMatch) {
|
|
31
|
+
for (const inputMatch of inputsMatch[1].matchAll(
|
|
32
|
+
/(\w+)\s*:\s*\{[^}]*type\s*:\s*['"](\w+)['"]/g
|
|
33
|
+
)) {
|
|
34
|
+
info.inputs[inputMatch[1]] = inputMatch[2]
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Extract exits
|
|
39
|
+
const exitsMatch = content.match(/exits\s*:\s*\{([\s\S]*?)\}/)
|
|
40
|
+
if (exitsMatch) {
|
|
41
|
+
for (const exitMatch of exitsMatch[1].matchAll(/(\w+)\s*:/g)) {
|
|
42
|
+
info.exits[exitMatch[1]] = true
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Find the line number of the `fn` function
|
|
47
|
+
const lines = content.split('\n')
|
|
48
|
+
for (let i = 0; i < lines.length; i++) {
|
|
49
|
+
if (/fn\s*:\s*(async\s*)?function/.test(lines[i])) {
|
|
50
|
+
info.fnLine = i + 1
|
|
51
|
+
break
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return info
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async #parseRoutesWithActions() {
|
|
59
|
+
const routesPath = path.join(this.rootDir, 'config', 'routes.js')
|
|
60
|
+
const actionsRoot = path.join(this.rootDir, 'api', 'controllers')
|
|
61
|
+
const content = await this.#readFile(routesPath)
|
|
62
|
+
const routes = {}
|
|
63
|
+
|
|
64
|
+
const regex = /['"]([^'"]+)['"]\s*:\s*['"]([^'"]+)['"]/g
|
|
65
|
+
let match
|
|
66
|
+
while ((match = regex.exec(content))) {
|
|
67
|
+
const route = match[1]
|
|
68
|
+
const actionName = match[2]
|
|
69
|
+
const filePath = path.join(actionsRoot, ...actionName.split('/')) + '.js'
|
|
70
|
+
|
|
71
|
+
const actionInfo = await this.#parseAction(filePath)
|
|
72
|
+
|
|
73
|
+
routes[route] = {
|
|
74
|
+
action: {
|
|
75
|
+
name: actionName,
|
|
76
|
+
path: filePath,
|
|
77
|
+
...actionInfo
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return routes
|
|
83
|
+
}
|
|
84
|
+
async #parseModels() {
|
|
85
|
+
const dir = path.join(this.rootDir, 'api', 'models')
|
|
86
|
+
const models = {}
|
|
87
|
+
|
|
88
|
+
const STATIC_METHODS = [
|
|
89
|
+
{ name: 'find', description: 'Retrieve all records matching criteria.' },
|
|
90
|
+
{
|
|
91
|
+
name: 'findOne',
|
|
92
|
+
description: 'Retrieve a single record matching criteria.'
|
|
93
|
+
},
|
|
94
|
+
{ name: 'create', description: 'Create a new record.' },
|
|
95
|
+
{
|
|
96
|
+
name: 'createEach',
|
|
97
|
+
description: 'Create multiple new records in a batch.'
|
|
98
|
+
},
|
|
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.' },
|
|
103
|
+
{
|
|
104
|
+
name: 'replaceCollection',
|
|
105
|
+
description: 'Replace all items in a collection association.'
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: 'addToCollection',
|
|
109
|
+
description: 'Add items to a collection association.'
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: 'removeFromCollection',
|
|
113
|
+
description: 'Remove items from a collection association.'
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: 'findOrCreate',
|
|
117
|
+
description: 'Find a record or create it if it does not exist.'
|
|
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.' },
|
|
123
|
+
{
|
|
124
|
+
name: 'validate',
|
|
125
|
+
description: 'Validate a record against its schema.'
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: 'avg',
|
|
129
|
+
description: 'Calculate the average of a numeric attribute.'
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: 'getDatastore',
|
|
133
|
+
description: 'Get the datastore used by this model.'
|
|
134
|
+
}
|
|
135
|
+
]
|
|
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
|
|
158
|
+
if (!(await this.#directoryExists(dir))) return models
|
|
159
|
+
|
|
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
|
+
|
|
231
|
+
for (const file of files) {
|
|
232
|
+
if (!file.endsWith('.js')) continue
|
|
233
|
+
|
|
234
|
+
const name = file.slice(0, -3)
|
|
235
|
+
const modelPath = path.join(dir, file)
|
|
236
|
+
let attributes = {}
|
|
237
|
+
const context = this
|
|
238
|
+
|
|
239
|
+
try {
|
|
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
|
+
}
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
} catch (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
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return models
|
|
311
|
+
}
|
|
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
|
+
}
|
|
321
|
+
async #parseViews() {
|
|
322
|
+
const dir = path.join(this.rootDir, 'views')
|
|
323
|
+
const views = {}
|
|
324
|
+
|
|
325
|
+
if (await this.#directoryExists(dir)) {
|
|
326
|
+
const collect = async (base, rel = '') => {
|
|
327
|
+
const entries = await fs.readdir(base, { withFileTypes: true })
|
|
328
|
+
for (const entry of entries) {
|
|
329
|
+
const relPath = path.join(rel, entry.name)
|
|
330
|
+
const fullPath = path.join(base, entry.name)
|
|
331
|
+
if (entry.isDirectory()) {
|
|
332
|
+
await collect(fullPath, relPath)
|
|
333
|
+
} else if (entry.isFile() && entry.name.endsWith('.ejs')) {
|
|
334
|
+
const viewKey = relPath.replace(/\.ejs$/, '').replace(/\\/g, '/')
|
|
335
|
+
views[viewKey] = {
|
|
336
|
+
path: fullPath
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
await collect(dir)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return views
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async #parsePages() {
|
|
348
|
+
const dir = path.join(this.rootDir, 'assets', 'js', 'pages')
|
|
349
|
+
const pages = {}
|
|
350
|
+
|
|
351
|
+
if (await this.#directoryExists(dir)) {
|
|
352
|
+
const collect = async (base, rel = '') => {
|
|
353
|
+
const entries = await fs.readdir(base, { withFileTypes: true })
|
|
354
|
+
for (const entry of entries) {
|
|
355
|
+
const relPath = path.join(rel, entry.name)
|
|
356
|
+
const fullPath = path.join(base, entry.name)
|
|
357
|
+
if (entry.isDirectory()) {
|
|
358
|
+
await collect(fullPath, relPath)
|
|
359
|
+
} else if (
|
|
360
|
+
entry.isFile() &&
|
|
361
|
+
/\.(vue|js|ts|jsx|tsx|svelte|html)$/.test(entry.name)
|
|
362
|
+
) {
|
|
363
|
+
const pageKey = relPath
|
|
364
|
+
.replace(/\.(vue|js|ts|jsx|tsx|svelte|html)$/, '')
|
|
365
|
+
.replace(/\\/g, '/')
|
|
366
|
+
pages[pageKey] = { path: fullPath }
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
await collect(dir)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return pages
|
|
374
|
+
}
|
|
375
|
+
async #parsePolicies() {
|
|
376
|
+
const dir = path.join(this.rootDir, 'api', 'policies')
|
|
377
|
+
const policies = {}
|
|
378
|
+
|
|
379
|
+
if (await this.#directoryExists(dir)) {
|
|
380
|
+
const files = await fs.readdir(dir)
|
|
381
|
+
for (const file of files) {
|
|
382
|
+
if (file.endsWith('.js')) {
|
|
383
|
+
const name = file.replace(/\.js$/, '')
|
|
384
|
+
const fullPath = path.join(dir, file)
|
|
385
|
+
policies[name] = { path: fullPath }
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return policies
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async #parseHelpers() {
|
|
394
|
+
const dir = path.join(this.rootDir, 'api', 'helpers')
|
|
395
|
+
const helpers = {}
|
|
396
|
+
if (await this.#directoryExists(dir)) {
|
|
397
|
+
const collect = async (base, rel = '') => {
|
|
398
|
+
const entries = await fs.readdir(base, { withFileTypes: true })
|
|
399
|
+
for (const entry of entries) {
|
|
400
|
+
const relPath = path.join(rel, entry.name)
|
|
401
|
+
const fullPath = path.join(base, entry.name)
|
|
402
|
+
if (entry.isDirectory()) {
|
|
403
|
+
await collect(fullPath, relPath)
|
|
404
|
+
} else if (entry.isFile() && entry.name.endsWith('.js')) {
|
|
405
|
+
const name = relPath.replace(/\.js$/, '').replace(/\\/g, '/')
|
|
406
|
+
const content = await this.#readFile(fullPath)
|
|
407
|
+
// Find the line number of the `fn` function
|
|
408
|
+
let fnLine = 0
|
|
409
|
+
const lines = content.split('\n')
|
|
410
|
+
for (let i = 0; i < lines.length; i++) {
|
|
411
|
+
if (/fn\s*:\s*(async\s*)?function/.test(lines[i])) {
|
|
412
|
+
fnLine = i + 1
|
|
413
|
+
break
|
|
414
|
+
}
|
|
415
|
+
}
|
|
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 }
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
await collect(dir)
|
|
528
|
+
}
|
|
529
|
+
return helpers
|
|
530
|
+
}
|
|
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
|
+
}
|
|
564
|
+
#getDataTypes() {
|
|
565
|
+
return [
|
|
566
|
+
{
|
|
567
|
+
type: 'string',
|
|
568
|
+
description: 'Any string.'
|
|
569
|
+
},
|
|
570
|
+
{ type: 'number', description: 'Any number.' },
|
|
571
|
+
{ type: 'boolean', description: 'True or false.' },
|
|
572
|
+
{
|
|
573
|
+
type: 'json',
|
|
574
|
+
description:
|
|
575
|
+
'Any JSON-serializable value, including numbers, booleans, strings, arrays, dictionaries (plain JavaScript objects), and null.'
|
|
576
|
+
},
|
|
577
|
+
{ type: 'ref', description: 'Any JavaScript value except undefined' }
|
|
578
|
+
]
|
|
579
|
+
}
|
|
580
|
+
#getSharedAttributeProperties() {
|
|
581
|
+
return [
|
|
582
|
+
{ label: 'type', detail: 'Data type of the attribute/input' },
|
|
583
|
+
{ label: 'required', detail: 'If true, this field is mandatory' },
|
|
584
|
+
{ label: 'defaultsTo', detail: 'Default value if not provided' },
|
|
585
|
+
{ label: 'allowNull', detail: 'Allow null values' },
|
|
586
|
+
{ label: 'description', detail: 'Description for documentation' },
|
|
587
|
+
{
|
|
588
|
+
label: 'extendedDescription',
|
|
589
|
+
detail: 'Longer, more detailed description for documentation'
|
|
590
|
+
},
|
|
591
|
+
{ label: 'example', detail: 'Example value' },
|
|
592
|
+
{ label: 'isIn', detail: 'Enum of allowed values' }
|
|
593
|
+
]
|
|
594
|
+
}
|
|
595
|
+
#getModelProperties() {
|
|
596
|
+
return [
|
|
597
|
+
{ label: 'columnName', detail: 'Custom database column name' },
|
|
598
|
+
{ label: 'unique', detail: 'Must be unique across records' },
|
|
599
|
+
{ label: 'autoIncrement', detail: 'Auto-increment this field' },
|
|
600
|
+
{ label: 'primaryKey', detail: 'Marks as primary key' },
|
|
601
|
+
{ label: 'model', detail: 'Reference to another model' },
|
|
602
|
+
{ label: 'collection', detail: 'Association with other records' },
|
|
603
|
+
{ label: 'via', detail: 'Used for collection associations' },
|
|
604
|
+
{ label: 'dominant', detail: 'Used in many-to-many relationships' }
|
|
605
|
+
]
|
|
606
|
+
}
|
|
607
|
+
#getModelAttributeProperties() {
|
|
608
|
+
return [
|
|
609
|
+
...this.#getSharedAttributeProperties(),
|
|
610
|
+
...this.#getModelProperties()
|
|
611
|
+
]
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
#getInputProperties() {
|
|
615
|
+
return this.#getSharedAttributeProperties()
|
|
616
|
+
}
|
|
617
|
+
async buildTypeMap() {
|
|
618
|
+
const [routes, models, views, pages, policies, helpers] = await Promise.all(
|
|
619
|
+
[
|
|
620
|
+
this.#parseRoutesWithActions(),
|
|
621
|
+
this.#parseModels(),
|
|
622
|
+
this.#parseViews(),
|
|
623
|
+
this.#parsePages(),
|
|
624
|
+
this.#parsePolicies(),
|
|
625
|
+
this.#parseHelpers()
|
|
626
|
+
]
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
return {
|
|
630
|
+
routes,
|
|
631
|
+
models,
|
|
632
|
+
views,
|
|
633
|
+
pages,
|
|
634
|
+
policies,
|
|
635
|
+
helpers,
|
|
636
|
+
dataTypes: this.#getDataTypes(),
|
|
637
|
+
modelAttributeProps: this.#getModelAttributeProperties(),
|
|
638
|
+
inputProps: this.#getInputProperties()
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async #directoryExists(dirPath) {
|
|
643
|
+
try {
|
|
644
|
+
const stat = await fs.stat(dirPath)
|
|
645
|
+
return stat.isDirectory()
|
|
646
|
+
} catch {
|
|
647
|
+
return false
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
module.exports = SailsParser
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const lsp = require('vscode-languageserver/node')
|
|
2
|
+
|
|
3
|
+
module.exports = function actionsCompletion(document, position, typeMap) {
|
|
4
|
+
if (!document.uri.endsWith('routes.js')) return [] // Return an empty array instead of null
|
|
5
|
+
|
|
6
|
+
const text = document.getText()
|
|
7
|
+
const offset = document.offsetAt(position)
|
|
8
|
+
const before = text.substring(0, offset)
|
|
9
|
+
|
|
10
|
+
// Match both "action: 'user/login'" and "'GET /foo': 'user/login'"
|
|
11
|
+
const match = before.match(
|
|
12
|
+
/(?:action\s*:\s*|['"][^'"]+['"]\s*:\s*)['"]([^'"]*)$/
|
|
13
|
+
)
|
|
14
|
+
if (!match) return [] // Return an empty array instead of null
|
|
15
|
+
|
|
16
|
+
const prefix = match[1]
|
|
17
|
+
|
|
18
|
+
const completions = Object.values(typeMap.routes || {})
|
|
19
|
+
.map((route) => {
|
|
20
|
+
const actionName = route.action?.name
|
|
21
|
+
if (!actionName || !actionName.startsWith(prefix)) return null
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
label: actionName,
|
|
25
|
+
kind: lsp.CompletionItemKind.Function,
|
|
26
|
+
detail: 'Controller Action',
|
|
27
|
+
documentation: `Defined in ${route.src?.replace(/^.*\/api\/controllers\//, '') || 'unknown file'}`,
|
|
28
|
+
sortText: actionName,
|
|
29
|
+
filterText: actionName,
|
|
30
|
+
insertText: actionName
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
|
|
35
|
+
return completions
|
|
36
|
+
}
|