@sailshq/language-server 0.3.2 → 0.5.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 +125 -33
- package/completions/helper-inputs-completion.js +8 -3
- package/completions/model-attribute-props-completion.js +20 -19
- package/completions/model-attributes-completion.js +356 -0
- package/completions/model-methods-completion.js +24 -1
- package/go-to-definitions/go-to-action.js +82 -29
- package/go-to-definitions/go-to-helper-input.js +112 -0
- package/go-to-definitions/go-to-model-attribute.js +302 -0
- package/go-to-definitions/go-to-model.js +4 -3
- package/go-to-definitions/go-to-page.js +40 -26
- package/go-to-definitions/go-to-view.js +42 -24
- package/index.js +12 -4
- package/package.json +1 -1
- package/validators/validate-action-exist.js +54 -15
- package/validators/validate-data-type.js +88 -25
- package/validators/validate-helper-input-exist.js +98 -32
- package/validators/validate-model-attribute-exist.js +184 -52
- package/validators/validate-model-exist.js +14 -0
- package/validators/validate-required-helper-input.js +100 -39
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
const lsp = require('vscode-languageserver/node')
|
|
2
|
+
const acorn = require('acorn')
|
|
3
|
+
const walk = require('acorn-walk')
|
|
4
|
+
|
|
5
|
+
const WATERLINE_MODIFIERS = ['or', 'and', 'not']
|
|
6
|
+
const WATERLINE_OPERATORS = [
|
|
7
|
+
'<',
|
|
8
|
+
'<=',
|
|
9
|
+
'>',
|
|
10
|
+
'>=',
|
|
11
|
+
'!=',
|
|
12
|
+
'nin',
|
|
13
|
+
'in',
|
|
14
|
+
'contains',
|
|
15
|
+
'startsWith',
|
|
16
|
+
'endsWith',
|
|
17
|
+
'like',
|
|
18
|
+
'!'
|
|
19
|
+
]
|
|
2
20
|
|
|
3
21
|
module.exports = function modelAttributesCompletion(
|
|
4
22
|
document,
|
|
@@ -15,6 +33,31 @@ module.exports = function modelAttributesCompletion(
|
|
|
15
33
|
const offset = document.offsetAt(position)
|
|
16
34
|
const before = text.substring(0, offset)
|
|
17
35
|
|
|
36
|
+
// Don't provide completions after a chainable method call dot (e.g., User.find().catch|)
|
|
37
|
+
// This context should show chainable methods, not attributes
|
|
38
|
+
const afterChainableDot = /\.[a-zA-Z_]+\([^)]*\)\.\s*[a-zA-Z]*$/
|
|
39
|
+
if (afterChainableDot.test(before)) {
|
|
40
|
+
return []
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Don't provide completions inside operator value arrays like { in: ['val1', ''] }
|
|
44
|
+
// Check if we're inside an array that follows an operator
|
|
45
|
+
const insideOperatorArray = before.match(
|
|
46
|
+
/(in|nin|contains|startsWith|endsWith|like)\s*:\s*\[[^\]]*$/
|
|
47
|
+
)
|
|
48
|
+
if (insideOperatorArray) {
|
|
49
|
+
return []
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Don't provide completions inside operator objects like { contains: '...', | }
|
|
53
|
+
// Check if we're after a comma inside an object that has operator keys
|
|
54
|
+
const insideOperatorObject = before.match(
|
|
55
|
+
/\{[^}]*(contains|startsWith|endsWith|like|in|nin|<|<=|>|>=|!=|!)\s*:[^}]*,\s*[a-zA-Z0-9_]*$/
|
|
56
|
+
)
|
|
57
|
+
if (insideOperatorObject) {
|
|
58
|
+
return []
|
|
59
|
+
}
|
|
60
|
+
|
|
18
61
|
const criteriaMatch = before.match(
|
|
19
62
|
/(?:sails\.models\.([A-Za-z_$][\w$]*)|([A-Za-z_$][\w$]*))\s*\.\w+\s*\(\s*\{[^}]*([a-zA-Z0-9_]*)?$/
|
|
20
63
|
)
|
|
@@ -107,6 +150,23 @@ module.exports = function modelAttributesCompletion(
|
|
|
107
150
|
const models = typeMap.models || {}
|
|
108
151
|
const modelKeys = Object.keys(models)
|
|
109
152
|
|
|
153
|
+
// Helper to check if an identifier is a known Sails model
|
|
154
|
+
function isLikelyModel(name) {
|
|
155
|
+
if (!name) return false
|
|
156
|
+
const knownGlobals = [
|
|
157
|
+
'_',
|
|
158
|
+
'sails',
|
|
159
|
+
'require',
|
|
160
|
+
'module',
|
|
161
|
+
'exports',
|
|
162
|
+
'console',
|
|
163
|
+
'process'
|
|
164
|
+
]
|
|
165
|
+
if (knownGlobals.includes(name)) return false
|
|
166
|
+
const upper = name.charAt(0).toUpperCase() + name.slice(1)
|
|
167
|
+
return !!(typeMap.models && typeMap.models[upper])
|
|
168
|
+
}
|
|
169
|
+
|
|
110
170
|
// Better model name inference using last static model call
|
|
111
171
|
function inferModelName(before) {
|
|
112
172
|
const allMatches = [
|
|
@@ -119,6 +179,302 @@ module.exports = function modelAttributesCompletion(
|
|
|
119
179
|
return last[1] || last[2] || null
|
|
120
180
|
}
|
|
121
181
|
|
|
182
|
+
// AST-based detection: Check if cursor is inside a Waterline query object
|
|
183
|
+
// This handles nested contexts like or/and/not modifiers and operator objects
|
|
184
|
+
try {
|
|
185
|
+
const ast = acorn.parse(text, {
|
|
186
|
+
ecmaVersion: 'latest',
|
|
187
|
+
sourceType: 'module',
|
|
188
|
+
locations: true
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
let astModelName = null
|
|
192
|
+
let astPrefix = ''
|
|
193
|
+
let foundContext = false
|
|
194
|
+
|
|
195
|
+
walk.ancestor(ast, {
|
|
196
|
+
CallExpression(node, ancestors) {
|
|
197
|
+
if (!node.callee || node.callee.type !== 'MemberExpression') return
|
|
198
|
+
|
|
199
|
+
// Only trigger for query methods, not chainable methods
|
|
200
|
+
const method = node.callee.property?.name
|
|
201
|
+
const queryMethods = [
|
|
202
|
+
'find',
|
|
203
|
+
'findOne',
|
|
204
|
+
'create',
|
|
205
|
+
'createEach',
|
|
206
|
+
'update',
|
|
207
|
+
'destroy',
|
|
208
|
+
'count',
|
|
209
|
+
'sum',
|
|
210
|
+
'findOrCreate',
|
|
211
|
+
'where'
|
|
212
|
+
]
|
|
213
|
+
if (!queryMethods.includes(method)) return
|
|
214
|
+
|
|
215
|
+
// Extract model name from call chain
|
|
216
|
+
let obj = node.callee.object
|
|
217
|
+
while (obj) {
|
|
218
|
+
if (obj.type === 'Identifier') {
|
|
219
|
+
if (isLikelyModel(obj.name)) {
|
|
220
|
+
astModelName = obj.name
|
|
221
|
+
}
|
|
222
|
+
break
|
|
223
|
+
} else if (
|
|
224
|
+
obj.type === 'CallExpression' &&
|
|
225
|
+
obj.callee &&
|
|
226
|
+
obj.callee.type === 'MemberExpression'
|
|
227
|
+
) {
|
|
228
|
+
obj = obj.callee.object
|
|
229
|
+
} else {
|
|
230
|
+
break
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (!astModelName) return
|
|
235
|
+
|
|
236
|
+
// Helper to get model by name
|
|
237
|
+
function getModelByName(name) {
|
|
238
|
+
if (!name) return undefined
|
|
239
|
+
const upper = name.charAt(0).toUpperCase() + name.slice(1)
|
|
240
|
+
return typeMap.models[upper]
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Check if cursor is inside an argument object
|
|
244
|
+
const arg = node.arguments[0]
|
|
245
|
+
if (!arg || arg.type !== 'ObjectExpression') return
|
|
246
|
+
if (offset < arg.start || offset > arg.end) return
|
|
247
|
+
|
|
248
|
+
// Query option keys that should NOT be treated as model attributes
|
|
249
|
+
const queryOptionKeys = [
|
|
250
|
+
'where',
|
|
251
|
+
'select',
|
|
252
|
+
'omit',
|
|
253
|
+
'sort',
|
|
254
|
+
'limit',
|
|
255
|
+
'skip',
|
|
256
|
+
'page',
|
|
257
|
+
'populate',
|
|
258
|
+
'groupBy',
|
|
259
|
+
'having',
|
|
260
|
+
'sum',
|
|
261
|
+
'average',
|
|
262
|
+
'min',
|
|
263
|
+
'max',
|
|
264
|
+
'distinct',
|
|
265
|
+
'meta'
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
// Recursively check if cursor is in a valid attribute position
|
|
269
|
+
function checkObjectForCursor(objNode, isInsideWhere = false) {
|
|
270
|
+
if (!objNode || objNode.type !== 'ObjectExpression') return false
|
|
271
|
+
if (offset < objNode.start || offset > objNode.end) return false
|
|
272
|
+
|
|
273
|
+
// Determine if this object contains any model attributes (vs only query options)
|
|
274
|
+
let hasAttributes = false
|
|
275
|
+
let hasQueryOptions = false
|
|
276
|
+
let hasOperators = false
|
|
277
|
+
let hasModifiers = false
|
|
278
|
+
for (const prop of objNode.properties) {
|
|
279
|
+
if (!prop.key) continue
|
|
280
|
+
const keyName = prop.key.name || prop.key.value
|
|
281
|
+
if (queryOptionKeys.includes(keyName)) {
|
|
282
|
+
hasQueryOptions = true
|
|
283
|
+
} else if (WATERLINE_OPERATORS.includes(keyName)) {
|
|
284
|
+
hasOperators = true
|
|
285
|
+
} else if (WATERLINE_MODIFIERS.includes(keyName)) {
|
|
286
|
+
hasModifiers = true
|
|
287
|
+
} else {
|
|
288
|
+
// Check if it's a valid attribute
|
|
289
|
+
const model = getModelByName(astModelName)
|
|
290
|
+
if (
|
|
291
|
+
model &&
|
|
292
|
+
model.attributes &&
|
|
293
|
+
Object.prototype.hasOwnProperty.call(model.attributes, keyName)
|
|
294
|
+
) {
|
|
295
|
+
hasAttributes = true
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// If this object contains operators, it's an operator object - don't show completions
|
|
301
|
+
if (hasOperators) {
|
|
302
|
+
return false
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Determine context:
|
|
306
|
+
// - If we have query options (like where, select, limit), this is a query options object
|
|
307
|
+
// - If we're explicitly inside a where clause (isInsideWhere), show attributes
|
|
308
|
+
// - If we have attributes but no query options, it's a criteria object
|
|
309
|
+
// - Modifiers at top level don't make this a criteria object (only inside them)
|
|
310
|
+
const isCriteriaMode =
|
|
311
|
+
isInsideWhere || (hasAttributes && !hasQueryOptions)
|
|
312
|
+
|
|
313
|
+
// If we have query options or modifiers and we're not inside where, this is NOT a criteria context
|
|
314
|
+
// Don't show attribute completions at the query options level
|
|
315
|
+
if ((hasQueryOptions || hasModifiers) && !isInsideWhere) {
|
|
316
|
+
// But we still need to check if we're typing a new key after existing query options
|
|
317
|
+
// Check if cursor is in a position to type a new key
|
|
318
|
+
const isTypingNewKey =
|
|
319
|
+
objNode.properties.length > 0 &&
|
|
320
|
+
offset > objNode.properties[objNode.properties.length - 1].end &&
|
|
321
|
+
offset < objNode.end
|
|
322
|
+
|
|
323
|
+
if (isTypingNewKey) {
|
|
324
|
+
// Don't show attributes, this should show query option keys instead
|
|
325
|
+
return false
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
for (const prop of objNode.properties) {
|
|
330
|
+
if (!prop.key) continue
|
|
331
|
+
|
|
332
|
+
// Check if cursor is at the key position (typing attribute name)
|
|
333
|
+
if (offset >= prop.key.start && offset <= prop.key.end) {
|
|
334
|
+
const keyName = prop.key.name || prop.key.value
|
|
335
|
+
// Skip if it's a modifier or operator
|
|
336
|
+
if (
|
|
337
|
+
WATERLINE_MODIFIERS.includes(keyName) ||
|
|
338
|
+
WATERLINE_OPERATORS.includes(keyName)
|
|
339
|
+
) {
|
|
340
|
+
return false
|
|
341
|
+
}
|
|
342
|
+
// Skip query option keys if we're in criteria mode
|
|
343
|
+
if (isCriteriaMode && queryOptionKeys.includes(keyName)) {
|
|
344
|
+
return false
|
|
345
|
+
}
|
|
346
|
+
astPrefix = text.substring(prop.key.start, offset)
|
|
347
|
+
return true
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const keyName = prop.key.name || prop.key.value
|
|
351
|
+
|
|
352
|
+
// If it's 'where', check inside its object value
|
|
353
|
+
if (keyName === 'where') {
|
|
354
|
+
if (prop.value && prop.value.type === 'ObjectExpression') {
|
|
355
|
+
if (checkObjectForCursor(prop.value, true)) return true
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// If it's a modifier (or/and/not), check inside the array or object at any level
|
|
360
|
+
if (WATERLINE_MODIFIERS.includes(keyName)) {
|
|
361
|
+
if (
|
|
362
|
+
prop.value &&
|
|
363
|
+
prop.value.type === 'ArrayExpression' &&
|
|
364
|
+
prop.value.elements
|
|
365
|
+
) {
|
|
366
|
+
for (const el of prop.value.elements) {
|
|
367
|
+
if (el && el.type === 'ObjectExpression') {
|
|
368
|
+
if (checkObjectForCursor(el, true)) return true
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
} else if (prop.value && prop.value.type === 'ObjectExpression') {
|
|
372
|
+
if (checkObjectForCursor(prop.value, true)) return true
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// If the value is an object with operators, don't recurse into it
|
|
377
|
+
if (
|
|
378
|
+
prop.value &&
|
|
379
|
+
prop.value.type === 'ObjectExpression' &&
|
|
380
|
+
prop.value.properties &&
|
|
381
|
+
prop.value.properties.length > 0
|
|
382
|
+
) {
|
|
383
|
+
const firstKey =
|
|
384
|
+
prop.value.properties[0].key?.name ||
|
|
385
|
+
prop.value.properties[0].key?.value
|
|
386
|
+
if (WATERLINE_OPERATORS.includes(firstKey)) {
|
|
387
|
+
// This is an operator object like { '>': 100 } or { in: [...] }
|
|
388
|
+
// Don't provide completions inside operator values
|
|
389
|
+
continue
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// If the value is an array and we're inside it, check if this is an operator value
|
|
394
|
+
// For example: { in: ['val1', 'val2'] } - don't complete inside the array
|
|
395
|
+
if (
|
|
396
|
+
prop.value &&
|
|
397
|
+
prop.value.type === 'ArrayExpression' &&
|
|
398
|
+
offset >= prop.value.start &&
|
|
399
|
+
offset <= prop.value.end
|
|
400
|
+
) {
|
|
401
|
+
// Check if this property key is an operator
|
|
402
|
+
if (WATERLINE_OPERATORS.includes(keyName)) {
|
|
403
|
+
// We're inside an operator's array value - don't provide attribute completions
|
|
404
|
+
return false
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Check if cursor is after last property (typing new attribute)
|
|
410
|
+
if (objNode.properties.length > 0) {
|
|
411
|
+
const lastProp = objNode.properties[objNode.properties.length - 1]
|
|
412
|
+
if (offset > lastProp.end && offset < objNode.end) {
|
|
413
|
+
// Cursor is after last property, typing new attribute
|
|
414
|
+
const afterLast = text.substring(lastProp.end, offset)
|
|
415
|
+
const newKeyMatch = afterLast.match(/[,\s]*([a-zA-Z0-9_]*)$/)
|
|
416
|
+
if (newKeyMatch) {
|
|
417
|
+
astPrefix = newKeyMatch[1]
|
|
418
|
+
// Return true to indicate we found a valid context
|
|
419
|
+
// The isCriteriaMode flag will be used when building completions
|
|
420
|
+
return true
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
} else {
|
|
424
|
+
// Empty object, check if cursor is inside
|
|
425
|
+
const insideText = text.substring(objNode.start + 1, offset)
|
|
426
|
+
const newKeyMatch = insideText.match(/^\s*([a-zA-Z0-9_]*)$/)
|
|
427
|
+
if (newKeyMatch) {
|
|
428
|
+
astPrefix = newKeyMatch[1]
|
|
429
|
+
return true
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return false
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (checkObjectForCursor(arg)) {
|
|
437
|
+
foundContext = true
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
if (foundContext && astModelName) {
|
|
443
|
+
modelName = astModelName
|
|
444
|
+
prefix = astPrefix
|
|
445
|
+
const foundKey = modelKeys.find(
|
|
446
|
+
(k) => k.toLowerCase() === modelName.toLowerCase()
|
|
447
|
+
)
|
|
448
|
+
const model = foundKey ? models[foundKey] : null
|
|
449
|
+
if (model) {
|
|
450
|
+
attributes = Object.keys(model.attributes || {})
|
|
451
|
+
return Array.from(
|
|
452
|
+
new Set(
|
|
453
|
+
attributes.filter((attr) =>
|
|
454
|
+
attr.toLowerCase().startsWith(prefix.toLowerCase())
|
|
455
|
+
)
|
|
456
|
+
)
|
|
457
|
+
).map((attr) => {
|
|
458
|
+
const attrDef = model.attributes && model.attributes[attr]
|
|
459
|
+
let type = attrDef && attrDef.type ? attrDef.type : ''
|
|
460
|
+
let required = attrDef && attrDef.required ? 'required' : 'optional'
|
|
461
|
+
let detail = type ? `${type} (${required})` : required
|
|
462
|
+
return {
|
|
463
|
+
label: attr,
|
|
464
|
+
kind: lsp.CompletionItemKind.Field,
|
|
465
|
+
detail,
|
|
466
|
+
documentation: `${modelName}.${attr}`,
|
|
467
|
+
sortText: attr,
|
|
468
|
+
filterText: attr,
|
|
469
|
+
insertText: attr
|
|
470
|
+
}
|
|
471
|
+
})
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
} catch (err) {
|
|
475
|
+
// Fall through to regex-based detection
|
|
476
|
+
}
|
|
477
|
+
|
|
122
478
|
// Determine the current Sails.js model context and attribute prefix for completions
|
|
123
479
|
// by matching the code before the cursor against various Sails.js query patterns.
|
|
124
480
|
// This enables context-aware attribute completions for all supported query forms.
|
|
@@ -11,6 +11,23 @@ module.exports = function modelMethodsCompletion(document, position, typeMap) {
|
|
|
11
11
|
const offset = document.offsetAt(position)
|
|
12
12
|
const before = text.substring(0, offset)
|
|
13
13
|
|
|
14
|
+
// Don't provide method completions when cursor is inside an object or array literal
|
|
15
|
+
// Count opening and closing braces/brackets to determine if we're inside one
|
|
16
|
+
const lastOpenBrace = before.lastIndexOf('{')
|
|
17
|
+
const lastCloseBrace = before.lastIndexOf('}')
|
|
18
|
+
const lastOpenBracket = before.lastIndexOf('[')
|
|
19
|
+
const lastCloseBracket = before.lastIndexOf(']')
|
|
20
|
+
|
|
21
|
+
if (lastOpenBrace !== -1 && lastOpenBrace > lastCloseBrace) {
|
|
22
|
+
// We're inside an object literal, don't show method completions
|
|
23
|
+
return []
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (lastOpenBracket !== -1 && lastOpenBracket > lastCloseBracket) {
|
|
27
|
+
// We're inside an array literal, don't show method completions
|
|
28
|
+
return []
|
|
29
|
+
}
|
|
30
|
+
|
|
14
31
|
// Match static calls like User.method or sails.models.user.method
|
|
15
32
|
const staticCallMatch = before.match(
|
|
16
33
|
/(?:sails\.models\.([A-Za-z_$][\w$]*)|([A-Za-z_$][\w$]*))\.\s*([a-zA-Z]*)?$/
|
|
@@ -33,7 +50,13 @@ module.exports = function modelMethodsCompletion(document, position, typeMap) {
|
|
|
33
50
|
// Get the prefix after the last dot (if user is typing e.g. .select)
|
|
34
51
|
const afterLastChain = before.slice(lastMatch.index + lastMatch[0].length)
|
|
35
52
|
const prefixMatch = afterLastChain.match(/\.\s*([a-zA-Z]*)?$/)
|
|
36
|
-
|
|
53
|
+
|
|
54
|
+
// Only provide chainable method completions if there's a dot after the call
|
|
55
|
+
if (!prefixMatch) {
|
|
56
|
+
return []
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
prefix = prefixMatch[1] || ''
|
|
37
60
|
const foundKey = Object.keys(models).find(
|
|
38
61
|
(k) => k.toLowerCase() === modelName.toLowerCase()
|
|
39
62
|
)
|
|
@@ -1,43 +1,96 @@
|
|
|
1
1
|
const lsp = require('vscode-languageserver/node')
|
|
2
2
|
const path = require('path')
|
|
3
|
+
const acorn = require('acorn')
|
|
4
|
+
const walk = require('acorn-walk')
|
|
3
5
|
|
|
4
6
|
module.exports = async function goToAction(document, position, typeMap) {
|
|
5
7
|
const fileName = path.basename(document.uri)
|
|
6
|
-
if (fileName !== 'routes.js') return
|
|
8
|
+
if (fileName !== 'routes.js') return null
|
|
7
9
|
|
|
8
10
|
const text = document.getText()
|
|
9
11
|
const offset = document.offsetAt(position)
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
try {
|
|
14
|
+
const ast = acorn.parse(text, {
|
|
15
|
+
ecmaVersion: 'latest',
|
|
16
|
+
sourceType: 'module'
|
|
17
|
+
})
|
|
13
18
|
|
|
14
|
-
|
|
19
|
+
let result = null
|
|
15
20
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
21
|
+
walk.simple(ast, {
|
|
22
|
+
Property(node) {
|
|
23
|
+
if (
|
|
24
|
+
node.value &&
|
|
25
|
+
node.value.type === 'Literal' &&
|
|
26
|
+
typeof node.value.value === 'string'
|
|
27
|
+
) {
|
|
28
|
+
const actionName = node.value.value
|
|
29
|
+
if (
|
|
30
|
+
!actionName.startsWith('/') &&
|
|
31
|
+
!actionName.startsWith('http://') &&
|
|
32
|
+
!actionName.startsWith('https://')
|
|
33
|
+
) {
|
|
34
|
+
if (offset >= node.value.start && offset <= node.value.end) {
|
|
35
|
+
const routeEntry = Object.values(typeMap.routes).find(
|
|
36
|
+
(route) => route.action?.name === actionName
|
|
37
|
+
)
|
|
38
|
+
if (routeEntry?.action) {
|
|
39
|
+
const { path: actionPath, fnLine } = routeEntry.action
|
|
40
|
+
const uri = `file://${actionPath}`
|
|
41
|
+
result = lsp.LocationLink.create(
|
|
42
|
+
uri,
|
|
43
|
+
lsp.Range.create(fnLine - 1, 0, fnLine - 1, 0),
|
|
44
|
+
lsp.Range.create(fnLine - 1, 0, fnLine - 1, 0),
|
|
45
|
+
lsp.Range.create(
|
|
46
|
+
document.positionAt(node.value.start),
|
|
47
|
+
document.positionAt(node.value.end)
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} else if (
|
|
54
|
+
node.value &&
|
|
55
|
+
node.value.type === 'ObjectExpression' &&
|
|
56
|
+
node.value.properties
|
|
57
|
+
) {
|
|
58
|
+
for (const prop of node.value.properties) {
|
|
59
|
+
if (
|
|
60
|
+
prop.type === 'Property' &&
|
|
61
|
+
prop.key &&
|
|
62
|
+
(prop.key.name === 'action' || prop.key.value === 'action') &&
|
|
63
|
+
prop.value &&
|
|
64
|
+
prop.value.type === 'Literal' &&
|
|
65
|
+
typeof prop.value.value === 'string'
|
|
66
|
+
) {
|
|
67
|
+
const actionName = prop.value.value
|
|
68
|
+
if (offset >= prop.value.start && offset <= prop.value.end) {
|
|
69
|
+
const routeEntry = Object.values(typeMap.routes).find(
|
|
70
|
+
(route) => route.action?.name === actionName
|
|
71
|
+
)
|
|
72
|
+
if (routeEntry?.action) {
|
|
73
|
+
const { path: actionPath, fnLine } = routeEntry.action
|
|
74
|
+
const uri = `file://${actionPath}`
|
|
75
|
+
result = lsp.LocationLink.create(
|
|
76
|
+
uri,
|
|
77
|
+
lsp.Range.create(fnLine - 1, 0, fnLine - 1, 0),
|
|
78
|
+
lsp.Range.create(fnLine - 1, 0, fnLine - 1, 0),
|
|
79
|
+
lsp.Range.create(
|
|
80
|
+
document.positionAt(prop.value.start),
|
|
81
|
+
document.positionAt(prop.value.end)
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
39
89
|
}
|
|
40
|
-
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
return result
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return null
|
|
41
95
|
}
|
|
42
|
-
return []
|
|
43
96
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
const lsp = require('vscode-languageserver/node')
|
|
2
|
+
const acorn = require('acorn')
|
|
3
|
+
const walk = require('acorn-walk')
|
|
4
|
+
|
|
5
|
+
module.exports = async function goToHelperInput(document, position, typeMap) {
|
|
6
|
+
const text = document.getText()
|
|
7
|
+
const offset = document.offsetAt(position)
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
const ast = acorn.parse(text, {
|
|
11
|
+
ecmaVersion: 'latest',
|
|
12
|
+
sourceType: 'module'
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
let result = null
|
|
16
|
+
|
|
17
|
+
walk.simple(ast, {
|
|
18
|
+
CallExpression(node) {
|
|
19
|
+
if (
|
|
20
|
+
node.callee &&
|
|
21
|
+
node.callee.type === 'MemberExpression' &&
|
|
22
|
+
node.callee.property.name === 'with' &&
|
|
23
|
+
node.callee.object &&
|
|
24
|
+
node.callee.object.type === 'MemberExpression'
|
|
25
|
+
) {
|
|
26
|
+
const helperPath = extractHelperPath(node.callee.object)
|
|
27
|
+
if (!helperPath) return
|
|
28
|
+
|
|
29
|
+
const helperInfo = typeMap.helpers && typeMap.helpers[helperPath]
|
|
30
|
+
if (!helperInfo || !helperInfo.inputs) return
|
|
31
|
+
|
|
32
|
+
const objArg = node.arguments[0]
|
|
33
|
+
if (!objArg || objArg.type !== 'ObjectExpression') return
|
|
34
|
+
|
|
35
|
+
for (const prop of objArg.properties) {
|
|
36
|
+
if (prop.type !== 'Property' || !prop.key) continue
|
|
37
|
+
|
|
38
|
+
const inputName =
|
|
39
|
+
prop.key.type === 'Identifier'
|
|
40
|
+
? prop.key.name
|
|
41
|
+
: prop.key.type === 'Literal'
|
|
42
|
+
? prop.key.value
|
|
43
|
+
: null
|
|
44
|
+
|
|
45
|
+
if (!inputName) continue
|
|
46
|
+
|
|
47
|
+
const keyStart = prop.key.start
|
|
48
|
+
const keyEnd = prop.key.end
|
|
49
|
+
|
|
50
|
+
if (offset >= keyStart && offset <= keyEnd) {
|
|
51
|
+
const inputInfo = helperInfo.inputs[inputName]
|
|
52
|
+
if (inputInfo?.line) {
|
|
53
|
+
const uri = `file://${helperInfo.path}`
|
|
54
|
+
result = lsp.LocationLink.create(
|
|
55
|
+
uri,
|
|
56
|
+
lsp.Range.create(
|
|
57
|
+
inputInfo.line - 1,
|
|
58
|
+
0,
|
|
59
|
+
inputInfo.line - 1,
|
|
60
|
+
0
|
|
61
|
+
),
|
|
62
|
+
lsp.Range.create(
|
|
63
|
+
inputInfo.line - 1,
|
|
64
|
+
0,
|
|
65
|
+
inputInfo.line - 1,
|
|
66
|
+
0
|
|
67
|
+
),
|
|
68
|
+
lsp.Range.create(
|
|
69
|
+
document.positionAt(keyStart),
|
|
70
|
+
document.positionAt(keyEnd)
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
return result
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function extractHelperPath(node) {
|
|
88
|
+
const segments = []
|
|
89
|
+
let current = node
|
|
90
|
+
|
|
91
|
+
while (current && current.type === 'MemberExpression') {
|
|
92
|
+
if (current.property && current.property.type === 'Identifier') {
|
|
93
|
+
const propName = current.property.name
|
|
94
|
+
if (propName === 'helpers') {
|
|
95
|
+
if (
|
|
96
|
+
current.object &&
|
|
97
|
+
current.object.type === 'Identifier' &&
|
|
98
|
+
current.object.name === 'sails'
|
|
99
|
+
) {
|
|
100
|
+
const toKebab = (s) =>
|
|
101
|
+
s.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
|
|
102
|
+
return segments.map(toKebab).join('/')
|
|
103
|
+
}
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
segments.unshift(propName)
|
|
107
|
+
}
|
|
108
|
+
current = current.object
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return null
|
|
112
|
+
}
|