@sailshq/language-server 0.0.4 → 0.1.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 (31) hide show
  1. package/SailsParser.js +403 -0
  2. package/completions/actions-completion.js +36 -0
  3. package/completions/data-types-completion.js +38 -0
  4. package/completions/inertia-pages-completion.js +33 -0
  5. package/completions/input-props-completion.js +56 -0
  6. package/completions/model-attribute-props-completion.js +53 -0
  7. package/completions/model-attributes-completion.js +102 -0
  8. package/completions/model-methods-completion.js +61 -0
  9. package/completions/models-completion.js +52 -0
  10. package/completions/policies-completion.js +32 -0
  11. package/completions/views-completion.js +35 -0
  12. package/go-to-definitions/go-to-action.js +26 -49
  13. package/go-to-definitions/go-to-helper.js +33 -33
  14. package/go-to-definitions/go-to-model.js +39 -0
  15. package/go-to-definitions/go-to-page.js +38 -0
  16. package/go-to-definitions/go-to-policy.js +23 -72
  17. package/go-to-definitions/go-to-view.js +28 -55
  18. package/index.js +95 -20
  19. package/package.json +1 -1
  20. package/validators/validate-action-exist.js +28 -51
  21. package/validators/validate-data-type.js +34 -0
  22. package/validators/validate-document.js +21 -5
  23. package/validators/validate-model-attribute-exist.js +128 -0
  24. package/validators/validate-page-exist.js +42 -0
  25. package/validators/validate-policy-exist.js +45 -0
  26. package/completions/sails-completions.js +0 -63
  27. package/go-to-definitions/go-to-inertia-page.js +0 -53
  28. package/helpers/find-fn-line.js +0 -21
  29. package/helpers/find-project-root.js +0 -18
  30. package/helpers/find-sails.js +0 -12
  31. package/helpers/load-sails.js +0 -38
package/SailsParser.js ADDED
@@ -0,0 +1,403 @@
1
+ const fs = require('fs').promises
2
+ const path = require('path')
3
+
4
+ class SailsParser {
5
+ constructor(rootDir) {
6
+ this.rootDir = rootDir
7
+ }
8
+
9
+ setRootDir(rootDir) {
10
+ this.rootDir = rootDir
11
+ }
12
+
13
+ async #readFile(filePath) {
14
+ try {
15
+ return await fs.readFile(filePath, 'utf8')
16
+ } catch (error) {
17
+ console.error(`Error reading file: ${filePath}`, error)
18
+ return ''
19
+ }
20
+ }
21
+
22
+ async #parseAction(filePath) {
23
+ const content = await this.#readFile(filePath)
24
+ const info = { inputs: {}, exits: {}, fnLine: 0 }
25
+
26
+ // Extract inputs
27
+ const inputsMatch = content.match(/inputs\s*:\s*\{([\s\S]*?)\}/)
28
+ if (inputsMatch) {
29
+ for (const inputMatch of inputsMatch[1].matchAll(
30
+ /(\w+)\s*:\s*\{[^}]*type\s*:\s*['"](\w+)['"]/g
31
+ )) {
32
+ info.inputs[inputMatch[1]] = inputMatch[2]
33
+ }
34
+ }
35
+
36
+ // Extract exits
37
+ const exitsMatch = content.match(/exits\s*:\s*\{([\s\S]*?)\}/)
38
+ if (exitsMatch) {
39
+ for (const exitMatch of exitsMatch[1].matchAll(/(\w+)\s*:/g)) {
40
+ info.exits[exitMatch[1]] = true
41
+ }
42
+ }
43
+
44
+ // Find the line number of the `fn` function
45
+ const lines = content.split('\n')
46
+ for (let i = 0; i < lines.length; i++) {
47
+ if (/fn\s*:\s*(async\s*)?function/.test(lines[i])) {
48
+ info.fnLine = i + 1
49
+ break
50
+ }
51
+ }
52
+
53
+ return info
54
+ }
55
+
56
+ async #parseRoutesWithActions() {
57
+ const routesPath = path.join(this.rootDir, 'config', 'routes.js')
58
+ const actionsRoot = path.join(this.rootDir, 'api', 'controllers')
59
+ const content = await this.#readFile(routesPath)
60
+ const routes = {}
61
+
62
+ const regex = /['"]([^'"]+)['"]\s*:\s*['"]([^'"]+)['"]/g
63
+ let match
64
+ while ((match = regex.exec(content))) {
65
+ const route = match[1]
66
+ const actionName = match[2]
67
+ const filePath = path.join(actionsRoot, ...actionName.split('/')) + '.js'
68
+
69
+ const actionInfo = await this.#parseAction(filePath)
70
+
71
+ routes[route] = {
72
+ action: {
73
+ name: actionName,
74
+ path: filePath,
75
+ ...actionInfo
76
+ }
77
+ }
78
+ }
79
+
80
+ return routes
81
+ }
82
+ async #parseModels() {
83
+ const dir = path.join(this.rootDir, 'api', 'models')
84
+ const models = {}
85
+
86
+ // Define Waterline static and chainable methods
87
+ const STATIC_METHODS = [
88
+ {
89
+ name: 'find',
90
+ description: 'Retrieve all records matching criteria.'
91
+ },
92
+ {
93
+ name: 'findOne',
94
+ description: 'Retrieve a single record matching criteria.'
95
+ },
96
+ {
97
+ name: 'create',
98
+ description: 'Create a new record.'
99
+ },
100
+ {
101
+ name: 'createEach',
102
+ description: 'Create multiple new records in a batch.'
103
+ },
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
+ },
116
+ {
117
+ name: 'replaceCollection',
118
+ description: 'Replace all items in a collection association.'
119
+ },
120
+ {
121
+ name: 'addToCollection',
122
+ description: 'Add items to a collection association.'
123
+ },
124
+ {
125
+ name: 'removeFromCollection',
126
+ description: 'Remove items from a collection association.'
127
+ },
128
+ {
129
+ name: 'findOrCreate',
130
+ description: 'Find a record or create it if it does not exist.'
131
+ },
132
+ {
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.'
146
+ },
147
+ {
148
+ name: 'skip',
149
+ description: 'Skip a number of records (for pagination).'
150
+ },
151
+ {
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.'
174
+ }
175
+ ]
176
+
177
+ if (!(await this.#directoryExists(dir))) return models
178
+
179
+ const files = await fs.readdir(dir)
180
+ for (const file of files) {
181
+ if (!file.endsWith('.js')) continue
182
+
183
+ const name = file.slice(0, -3)
184
+ const modelPath = path.join(dir, file)
185
+
186
+ 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 }
201
+ }
202
+ }
203
+ models[name] = info
204
+ } catch (err) {
205
+ console.error(`Error requiring model: ${file}`, err)
206
+ }
207
+ }
208
+ return models
209
+ }
210
+
211
+ async #parseViews() {
212
+ const dir = path.join(this.rootDir, 'views')
213
+ const views = {}
214
+
215
+ if (await this.#directoryExists(dir)) {
216
+ const collect = async (base, rel = '') => {
217
+ const entries = await fs.readdir(base, { withFileTypes: true })
218
+ for (const entry of entries) {
219
+ const relPath = path.join(rel, entry.name)
220
+ const fullPath = path.join(base, entry.name)
221
+ if (entry.isDirectory()) {
222
+ await collect(fullPath, relPath)
223
+ } else if (entry.isFile() && entry.name.endsWith('.ejs')) {
224
+ const viewKey = relPath.replace(/\.ejs$/, '').replace(/\\/g, '/')
225
+ views[viewKey] = {
226
+ path: fullPath
227
+ }
228
+ }
229
+ }
230
+ }
231
+ await collect(dir)
232
+ }
233
+
234
+ return views
235
+ }
236
+
237
+ async #parsePages() {
238
+ const dir = path.join(this.rootDir, 'assets', 'js', 'pages')
239
+ const pages = {}
240
+
241
+ if (await this.#directoryExists(dir)) {
242
+ const collect = async (base, rel = '') => {
243
+ const entries = await fs.readdir(base, { withFileTypes: true })
244
+ for (const entry of entries) {
245
+ const relPath = path.join(rel, entry.name)
246
+ const fullPath = path.join(base, entry.name)
247
+ if (entry.isDirectory()) {
248
+ await collect(fullPath, relPath)
249
+ } else if (
250
+ entry.isFile() &&
251
+ /\.(vue|js|ts|jsx|tsx|svelte|html)$/.test(entry.name)
252
+ ) {
253
+ const pageKey = relPath
254
+ .replace(/\.(vue|js|ts|jsx|tsx|svelte|html)$/, '')
255
+ .replace(/\\/g, '/')
256
+ pages[pageKey] = { path: fullPath }
257
+ }
258
+ }
259
+ }
260
+ await collect(dir)
261
+ }
262
+
263
+ return pages
264
+ }
265
+ async #parsePolicies() {
266
+ const dir = path.join(this.rootDir, 'api', 'policies')
267
+ const policies = {}
268
+
269
+ if (await this.#directoryExists(dir)) {
270
+ const files = await fs.readdir(dir)
271
+ for (const file of files) {
272
+ if (file.endsWith('.js')) {
273
+ const name = file.replace(/\.js$/, '')
274
+ const fullPath = path.join(dir, file)
275
+ policies[name] = { path: fullPath }
276
+ }
277
+ }
278
+ }
279
+
280
+ return policies
281
+ }
282
+
283
+ async #parseHelpers() {
284
+ const dir = path.join(this.rootDir, 'api', 'helpers')
285
+ const helpers = {}
286
+ if (await this.#directoryExists(dir)) {
287
+ const collect = async (base, rel = '') => {
288
+ const entries = await fs.readdir(base, { withFileTypes: true })
289
+ for (const entry of entries) {
290
+ const relPath = path.join(rel, entry.name)
291
+ const fullPath = path.join(base, entry.name)
292
+ if (entry.isDirectory()) {
293
+ await collect(fullPath, relPath)
294
+ } else if (entry.isFile() && entry.name.endsWith('.js')) {
295
+ const name = relPath.replace(/\.js$/, '').replace(/\\/g, '/')
296
+ const content = await this.#readFile(fullPath)
297
+ // Find the line number of the `fn` function
298
+ let fnLine = 0
299
+ const lines = content.split('\n')
300
+ for (let i = 0; i < lines.length; i++) {
301
+ if (/fn\s*:\s*(async\s*)?function/.test(lines[i])) {
302
+ fnLine = i + 1
303
+ break
304
+ }
305
+ }
306
+ helpers[name] = { path: fullPath, fnLine }
307
+ }
308
+ }
309
+ }
310
+ await collect(dir)
311
+ }
312
+ return helpers
313
+ }
314
+
315
+ #getDataTypes() {
316
+ return [
317
+ {
318
+ type: 'string',
319
+ description: 'Any string.'
320
+ },
321
+ { type: 'number', description: 'Any number.' },
322
+ { type: 'boolean', description: 'True or false.' },
323
+ {
324
+ type: 'json',
325
+ description:
326
+ 'Any JSON-serializable value, including numbers, booleans, strings, arrays, dictionaries (plain JavaScript objects), and null.'
327
+ },
328
+ { type: 'ref', description: 'Any JavaScript value except undefined' }
329
+ ]
330
+ }
331
+ #getSharedAttributeProperties() {
332
+ return [
333
+ { label: 'type', detail: 'Data type of the attribute/input' },
334
+ { label: 'required', detail: 'If true, this field is mandatory' },
335
+ { label: 'defaultsTo', detail: 'Default value if not provided' },
336
+ { label: 'allowNull', detail: 'Allow null values' },
337
+ { label: 'description', detail: 'Description for documentation' },
338
+ {
339
+ label: 'extendedDescription',
340
+ detail: 'Longer, more detailed description for documentation'
341
+ },
342
+ { label: 'example', detail: 'Example value' },
343
+ { label: 'isIn', detail: 'Enum of allowed values' }
344
+ ]
345
+ }
346
+ #getModelProperties() {
347
+ return [
348
+ { label: 'columnName', detail: 'Custom database column name' },
349
+ { label: 'unique', detail: 'Must be unique across records' },
350
+ { label: 'autoIncrement', detail: 'Auto-increment this field' },
351
+ { label: 'primaryKey', detail: 'Marks as primary key' },
352
+ { label: 'model', detail: 'Reference to another model' },
353
+ { label: 'collection', detail: 'Association with other records' },
354
+ { label: 'via', detail: 'Used for collection associations' },
355
+ { label: 'dominant', detail: 'Used in many-to-many relationships' }
356
+ ]
357
+ }
358
+ #getModelAttributeProperties() {
359
+ return [
360
+ ...this.#getSharedAttributeProperties(),
361
+ ...this.#getModelProperties()
362
+ ]
363
+ }
364
+
365
+ #getInputProperties() {
366
+ return this.#getSharedAttributeProperties()
367
+ }
368
+ async buildTypeMap() {
369
+ const [routes, models, views, pages, policies, helpers] = await Promise.all(
370
+ [
371
+ this.#parseRoutesWithActions(),
372
+ this.#parseModels(),
373
+ this.#parseViews(),
374
+ this.#parsePages(),
375
+ this.#parsePolicies(),
376
+ this.#parseHelpers()
377
+ ]
378
+ )
379
+
380
+ return {
381
+ routes,
382
+ models,
383
+ views,
384
+ pages,
385
+ policies,
386
+ helpers,
387
+ dataTypes: this.#getDataTypes(),
388
+ modelAttributeProps: this.#getModelAttributeProperties(),
389
+ inputProps: this.#getInputProperties()
390
+ }
391
+ }
392
+
393
+ async #directoryExists(dirPath) {
394
+ try {
395
+ const stat = await fs.stat(dirPath)
396
+ return stat.isDirectory()
397
+ } catch {
398
+ return false
399
+ }
400
+ }
401
+ }
402
+
403
+ 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
+ }
@@ -0,0 +1,38 @@
1
+ const lsp = require('vscode-languageserver/node')
2
+
3
+ module.exports = function dataTypesCompletion(document, position, typeMap) {
4
+ const filePath = document.uri
5
+
6
+ const isTargetFile =
7
+ filePath.includes('/api/models/') ||
8
+ filePath.includes('/api/helpers/') ||
9
+ filePath.includes('/api/controllers/') ||
10
+ filePath.includes('/scripts/')
11
+
12
+ if (!isTargetFile) return []
13
+
14
+ const text = document.getText()
15
+ const offset = document.offsetAt(position)
16
+ const before = text.substring(0, offset)
17
+
18
+ const match = before.match(/type\s*:\s*['"]([a-z]*)$/i)
19
+ if (!match) return []
20
+
21
+ const prefix = match[1]
22
+
23
+ const contextText = before.toLowerCase()
24
+ const inAttributes = /attributes\s*:\s*{[^}]*$/.test(contextText)
25
+ const inInputs = /inputs\s*:\s*{[^}]*$/.test(contextText)
26
+
27
+ if (!(inAttributes || inInputs)) return []
28
+
29
+ return typeMap.dataTypes
30
+ .filter(({ type }) => type.startsWith(prefix))
31
+ .map(({ type, description }) => ({
32
+ label: type,
33
+ kind: lsp.CompletionItemKind.TypeParameter,
34
+ detail: 'Validation type',
35
+ documentation: description,
36
+ insertText: type
37
+ }))
38
+ }
@@ -0,0 +1,33 @@
1
+ const lsp = require('vscode-languageserver/node')
2
+
3
+ module.exports = function inertiaPagesCompletion(document, position, typeMap) {
4
+ if (!document.uri.includes('/api/controllers/')) return []
5
+
6
+ const text = document.getText()
7
+ const offset = document.offsetAt(position)
8
+ const before = text.substring(0, offset)
9
+
10
+ // Match { page: '<cursor here>' } (either single or double quotes)
11
+ const match = before.match(/page\s*:\s*['"]([^'"]*)$/)
12
+ if (!match) return []
13
+
14
+ const prefix = match[1]
15
+
16
+ const completions = Object.entries(typeMap.pages || {})
17
+ .map(([pageKey, pageData]) => {
18
+ if (!pageKey.startsWith(prefix)) return null
19
+
20
+ return {
21
+ label: pageKey,
22
+ kind: lsp.CompletionItemKind.Module,
23
+ detail: 'Inertia Page',
24
+ documentation: pageData.path,
25
+ sortText: pageKey,
26
+ filterText: pageKey,
27
+ insertText: pageKey
28
+ }
29
+ })
30
+ .filter(Boolean)
31
+
32
+ return completions
33
+ }
@@ -0,0 +1,56 @@
1
+ const lsp = require('vscode-languageserver/node')
2
+
3
+ module.exports = function inputPropsCompletion(document, position, typeMap) {
4
+ const filePath = document.uri
5
+
6
+ const isTargetFile =
7
+ filePath.includes('/api/helpers/') ||
8
+ filePath.includes('/api/controllers/') ||
9
+ filePath.includes('/scripts/')
10
+ if (!isTargetFile) return []
11
+
12
+ const text = document.getText()
13
+ const offset = document.offsetAt(position)
14
+ const before = text.substring(0, offset)
15
+
16
+ // Check we're inside the inputs: { ... } section
17
+ const insideInputs = /inputs\s*:\s*{[\s\S]*$/.test(before)
18
+ if (!insideInputs) return []
19
+
20
+ // Walk backward to see if we're inside an input property definition
21
+ const lines = before.split('\n')
22
+ const reversed = lines.slice().reverse()
23
+ let insideInputBlock = false
24
+
25
+ for (const line of reversed) {
26
+ const trimmed = line.trim()
27
+ if (/^[a-zA-Z0-9_]+\s*:\s*{\s*$/.test(trimmed)) {
28
+ insideInputBlock = true
29
+ break
30
+ }
31
+ if (/^\}/.test(trimmed)) {
32
+ break // exited a block
33
+ }
34
+ }
35
+
36
+ if (!insideInputBlock) return []
37
+
38
+ // Extract current typing prefix
39
+ const lastLine = lines[lines.length - 1]
40
+ const prefixMatch = lastLine.match(/([a-zA-Z0-9_]*)$/)
41
+ const prefix = prefixMatch ? prefixMatch[1] : ''
42
+
43
+ return typeMap.inputProps
44
+ .filter(({ label }) => label.startsWith(prefix))
45
+ .map(({ label, detail }) => ({
46
+ label,
47
+ kind:
48
+ label === 'custom'
49
+ ? lsp.CompletionItemKind.Method
50
+ : lsp.CompletionItemKind.Field,
51
+ detail,
52
+ documentation: detail,
53
+ insertText: `${label}: `,
54
+ insertTextFormat: lsp.InsertTextFormat.PlainText
55
+ }))
56
+ }
@@ -0,0 +1,53 @@
1
+ const lsp = require('vscode-languageserver/node')
2
+
3
+ module.exports = function modelAttributePropsCompletion(
4
+ document,
5
+ position,
6
+ typeMap
7
+ ) {
8
+ const filePath = document.uri
9
+
10
+ const isTargetFile = filePath.includes('/api/models/')
11
+ if (!isTargetFile) return []
12
+ const text = document.getText()
13
+ const offset = document.offsetAt(position)
14
+ const before = text.substring(0, offset)
15
+
16
+ // Confirm we're inside the attributes section
17
+ const insideAttributes = /attributes\s*:\s*{[\s\S]*$/.test(before)
18
+ if (!insideAttributes) return []
19
+
20
+ // Try to match "someProperty: {" above the current line
21
+ const lines = before.split('\n')
22
+ const reversed = lines.slice().reverse()
23
+ let insidePropertyBlock = false
24
+
25
+ for (const line of reversed) {
26
+ const trimmed = line.trim()
27
+ if (/^[a-zA-Z0-9_]+\s*:\s*{\s*$/.test(trimmed)) {
28
+ insidePropertyBlock = true
29
+ break
30
+ }
31
+ if (/^\}/.test(trimmed)) {
32
+ break // exited a block without entering a new one
33
+ }
34
+ }
35
+
36
+ if (!insidePropertyBlock) return []
37
+
38
+ // Optional: match current prefix
39
+ const lastLine = lines[lines.length - 1]
40
+ const prefixMatch = lastLine.match(/([a-zA-Z0-9_]*)$/)
41
+ const prefix = prefixMatch ? prefixMatch[1] : ''
42
+
43
+ return typeMap.modelAttributeProps
44
+ .filter(({ label }) => label.startsWith(prefix))
45
+ .map(({ label, detail }) => ({
46
+ label,
47
+ kind: lsp.CompletionItemKind.Field,
48
+ detail,
49
+ documentation: detail,
50
+ insertText: `${label}: `,
51
+ insertTextFormat: lsp.InsertTextFormat.PlainText
52
+ }))
53
+ }