@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.
- package/SailsParser.js +403 -0
- package/completions/actions-completion.js +36 -0
- package/completions/data-types-completion.js +38 -0
- package/completions/inertia-pages-completion.js +33 -0
- package/completions/input-props-completion.js +56 -0
- package/completions/model-attribute-props-completion.js +53 -0
- package/completions/model-attributes-completion.js +102 -0
- package/completions/model-methods-completion.js +61 -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 +33 -33
- 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 +95 -20
- 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 +21 -5
- package/validators/validate-model-attribute-exist.js +128 -0
- package/validators/validate-page-exist.js +42 -0
- package/validators/validate-policy-exist.js +45 -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 -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
|
+
}
|