@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.
Files changed (38) hide show
  1. package/SailsParser.js +652 -0
  2. package/completions/actions-completion.js +36 -0
  3. package/completions/data-types-completion.js +39 -0
  4. package/completions/helper-inputs-completion.js +91 -0
  5. package/completions/helpers-completion.js +85 -0
  6. package/completions/inertia-pages-completion.js +33 -0
  7. package/completions/input-props-completion.js +52 -0
  8. package/completions/model-attribute-props-completion.js +57 -0
  9. package/completions/model-attributes-completion.js +195 -0
  10. package/completions/model-methods-completion.js +71 -0
  11. package/completions/models-completion.js +52 -0
  12. package/completions/policies-completion.js +32 -0
  13. package/completions/views-completion.js +35 -0
  14. package/go-to-definitions/go-to-action.js +26 -49
  15. package/go-to-definitions/go-to-helper.js +37 -45
  16. package/go-to-definitions/go-to-model.js +39 -0
  17. package/go-to-definitions/go-to-page.js +38 -0
  18. package/go-to-definitions/go-to-policy.js +23 -72
  19. package/go-to-definitions/go-to-view.js +28 -55
  20. package/index.js +103 -19
  21. package/package.json +1 -1
  22. package/validators/validate-action-exist.js +28 -51
  23. package/validators/validate-data-type.js +34 -0
  24. package/validators/validate-document.js +42 -4
  25. package/validators/validate-helper-input-exist.js +42 -0
  26. package/validators/validate-model-attribute-exist.js +297 -0
  27. package/validators/validate-model-exist.js +64 -0
  28. package/validators/validate-page-exist.js +42 -0
  29. package/validators/validate-policy-exist.js +45 -0
  30. package/validators/validate-required-helper-input.js +49 -0
  31. package/validators/validate-required-model-attribute.js +56 -0
  32. package/validators/validate-view-exist.js +86 -0
  33. package/completions/sails-completions.js +0 -63
  34. package/go-to-definitions/go-to-inertia-page.js +0 -53
  35. package/helpers/find-fn-line.js +0 -21
  36. package/helpers/find-project-root.js +0 -18
  37. package/helpers/find-sails.js +0 -12
  38. 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
+ }