@sailshq/language-server 0.3.2 → 0.4.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 CHANGED
@@ -60,30 +60,68 @@ class SailsParser {
60
60
  const actionsRoot = path.join(this.rootDir, 'api', 'controllers')
61
61
  const content = await this.#readFile(routesPath)
62
62
  const routes = {}
63
+ const actionsToParse = []
63
64
 
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]
65
+ try {
66
+ const ast = acorn.parse(content, {
67
+ ecmaVersion: 'latest',
68
+ sourceType: 'module'
69
+ })
69
70
 
70
- // Skip redirects and external URLs
71
- // Routes that start with '/' or contain '://' are redirects, not actions
72
- if (actionName.startsWith('/') || actionName.includes('://')) {
73
- continue
74
- }
71
+ walk.simple(ast, {
72
+ Property(node) {
73
+ const routePattern = node.key?.value || node.key?.name
74
+ if (!routePattern) return
75
75
 
76
- const filePath = path.join(actionsRoot, ...actionName.split('/')) + '.js'
76
+ let actionName = null
77
77
 
78
- const actionInfo = await this.#parseAction(filePath)
78
+ if (
79
+ node.value?.type === 'Literal' &&
80
+ typeof node.value.value === 'string'
81
+ ) {
82
+ actionName = node.value.value
83
+ } else if (
84
+ node.value?.type === 'ObjectExpression' &&
85
+ node.value.properties
86
+ ) {
87
+ for (const prop of node.value.properties) {
88
+ if (
89
+ prop.type === 'Property' &&
90
+ (prop.key?.name === 'action' || prop.key?.value === 'action') &&
91
+ prop.value?.type === 'Literal' &&
92
+ typeof prop.value.value === 'string'
93
+ ) {
94
+ actionName = prop.value.value
95
+ break
96
+ }
97
+ }
98
+ }
79
99
 
80
- routes[route] = {
81
- action: {
82
- name: actionName,
83
- path: filePath,
84
- ...actionInfo
100
+ if (
101
+ actionName &&
102
+ !actionName.startsWith('/') &&
103
+ !actionName.includes('://')
104
+ ) {
105
+ actionsToParse.push({ routePattern, actionName })
106
+ }
107
+ }
108
+ })
109
+
110
+ for (const { routePattern, actionName } of actionsToParse) {
111
+ const filePath =
112
+ path.join(actionsRoot, ...actionName.split('/')) + '.js'
113
+ const actionInfo = await this.#parseAction(filePath)
114
+
115
+ routes[routePattern] = {
116
+ action: {
117
+ name: actionName,
118
+ path: filePath,
119
+ ...actionInfo
120
+ }
85
121
  }
86
122
  }
123
+ } catch (error) {
124
+ console.error('Error parsing routes:', error)
87
125
  }
88
126
 
89
127
  return routes
@@ -21,30 +21,31 @@ module.exports = function modelAttributePropsCompletion(
21
21
  const insideAttributes = /attributes\s*:\s*{([\s\S]*)$/.exec(before)
22
22
  if (!insideAttributes) return []
23
23
 
24
- // Use a stack to track braces and find if we're inside a property block
25
- let braceStack = []
26
- let insideProperty = false
27
- for (let i = lines.length - 1; i >= 0; i--) {
24
+ // Count nesting depth from attributes: { to current position
25
+ // We want depth > 1 (inside a property) not depth === 1 (top level of attributes)
26
+ let depth = 0
27
+ let foundAttributesBlock = false
28
+
29
+ for (let i = 0; i < lines.length; i++) {
28
30
  const line = lines[i]
29
- for (let j = line.length - 1; j >= 0; j--) {
30
- if (line[j] === '}') braceStack.push('}')
31
- if (line[j] === '{') {
32
- if (braceStack.length > 0) {
33
- braceStack.pop()
34
- } else {
35
- const propMatch = lines[i]
36
- .slice(0, j + 1)
37
- .match(/([a-zA-Z0-9_]+)\s*:\s*{$/)
38
- if (propMatch) insideProperty = true
39
- break
40
- }
31
+
32
+ if (/attributes\s*:\s*{/.test(line)) {
33
+ foundAttributesBlock = true
34
+ depth = 1
35
+ continue
36
+ }
37
+
38
+ if (foundAttributesBlock) {
39
+ for (let j = 0; j < line.length; j++) {
40
+ if (line[j] === '{') depth++
41
+ if (line[j] === '}') depth--
41
42
  }
42
43
  }
43
- if (insideProperty) break
44
- if (/^\s*attributes\s*:\s*{/.test(line)) break
45
44
  }
46
45
 
47
- if (!insideProperty) return []
46
+ // Only provide completions if we're nested inside a property (depth > 1)
47
+ // depth === 1 means we're at the top level of attributes: {}
48
+ if (depth <= 1) return []
48
49
 
49
50
  return typeMap.modelAttributeProps.map(({ label, detail }) => ({
50
51
  label,
@@ -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,296 @@ 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
+ for (const prop of objNode.properties) {
278
+ if (!prop.key) continue
279
+ const keyName = prop.key.name || prop.key.value
280
+ if (queryOptionKeys.includes(keyName)) {
281
+ hasQueryOptions = true
282
+ } else if (WATERLINE_OPERATORS.includes(keyName)) {
283
+ hasOperators = true
284
+ } else if (!WATERLINE_MODIFIERS.includes(keyName)) {
285
+ // Check if it's a valid attribute
286
+ const model = getModelByName(astModelName)
287
+ if (
288
+ model &&
289
+ model.attributes &&
290
+ Object.prototype.hasOwnProperty.call(model.attributes, keyName)
291
+ ) {
292
+ hasAttributes = true
293
+ }
294
+ }
295
+ }
296
+
297
+ // If this object contains operators, it's an operator object - don't show completions
298
+ if (hasOperators) {
299
+ return false
300
+ }
301
+
302
+ // Determine context:
303
+ // - If we have query options (like where, select, limit), this is a query options object
304
+ // - If we're explicitly inside a where clause (isInsideWhere), show attributes
305
+ // - If we have attributes but no query options, it's a criteria object
306
+ const isCriteriaMode =
307
+ isInsideWhere || (hasAttributes && !hasQueryOptions)
308
+
309
+ // If we have query options and we're not inside where, this is NOT a criteria context
310
+ // Don't show attribute completions at the query options level
311
+ if (hasQueryOptions && !isInsideWhere) {
312
+ // But we still need to check if we're typing a new key after existing query options
313
+ // Check if cursor is in a position to type a new key
314
+ const isTypingNewKey =
315
+ objNode.properties.length > 0 &&
316
+ offset > objNode.properties[objNode.properties.length - 1].end &&
317
+ offset < objNode.end
318
+
319
+ if (isTypingNewKey) {
320
+ // Don't show attributes, this should show query option keys instead
321
+ return false
322
+ }
323
+ }
324
+
325
+ for (const prop of objNode.properties) {
326
+ if (!prop.key) continue
327
+
328
+ // Check if cursor is at the key position (typing attribute name)
329
+ if (offset >= prop.key.start && offset <= prop.key.end) {
330
+ const keyName = prop.key.name || prop.key.value
331
+ // Skip if it's a modifier or operator
332
+ if (
333
+ WATERLINE_MODIFIERS.includes(keyName) ||
334
+ WATERLINE_OPERATORS.includes(keyName)
335
+ ) {
336
+ return false
337
+ }
338
+ // Skip query option keys if we're in criteria mode
339
+ if (isCriteriaMode && queryOptionKeys.includes(keyName)) {
340
+ return false
341
+ }
342
+ astPrefix = text.substring(prop.key.start, offset)
343
+ return true
344
+ }
345
+
346
+ const keyName = prop.key.name || prop.key.value
347
+
348
+ // If it's 'where', check inside its object value
349
+ if (keyName === 'where') {
350
+ if (prop.value && prop.value.type === 'ObjectExpression') {
351
+ if (checkObjectForCursor(prop.value, true)) return true
352
+ }
353
+ }
354
+
355
+ // If it's a modifier (or/and/not), check inside the array
356
+ if (WATERLINE_MODIFIERS.includes(keyName)) {
357
+ if (
358
+ prop.value &&
359
+ prop.value.type === 'ArrayExpression' &&
360
+ prop.value.elements
361
+ ) {
362
+ for (const el of prop.value.elements) {
363
+ if (el && el.type === 'ObjectExpression') {
364
+ if (checkObjectForCursor(el, true)) return true
365
+ }
366
+ }
367
+ }
368
+ }
369
+
370
+ // If the value is an object with operators, don't recurse into it
371
+ if (
372
+ prop.value &&
373
+ prop.value.type === 'ObjectExpression' &&
374
+ prop.value.properties &&
375
+ prop.value.properties.length > 0
376
+ ) {
377
+ const firstKey =
378
+ prop.value.properties[0].key?.name ||
379
+ prop.value.properties[0].key?.value
380
+ if (WATERLINE_OPERATORS.includes(firstKey)) {
381
+ // This is an operator object like { '>': 100 } or { in: [...] }
382
+ // Don't provide completions inside operator values
383
+ continue
384
+ }
385
+ }
386
+
387
+ // If the value is an array and we're inside it, check if this is an operator value
388
+ // For example: { in: ['val1', 'val2'] } - don't complete inside the array
389
+ if (
390
+ prop.value &&
391
+ prop.value.type === 'ArrayExpression' &&
392
+ offset >= prop.value.start &&
393
+ offset <= prop.value.end
394
+ ) {
395
+ // Check if this property key is an operator
396
+ if (WATERLINE_OPERATORS.includes(keyName)) {
397
+ // We're inside an operator's array value - don't provide attribute completions
398
+ return false
399
+ }
400
+ }
401
+ }
402
+
403
+ // Check if cursor is after last property (typing new attribute)
404
+ if (objNode.properties.length > 0) {
405
+ const lastProp = objNode.properties[objNode.properties.length - 1]
406
+ if (offset > lastProp.end && offset < objNode.end) {
407
+ // Cursor is after last property, typing new attribute
408
+ const afterLast = text.substring(lastProp.end, offset)
409
+ const newKeyMatch = afterLast.match(/[,\s]*([a-zA-Z0-9_]*)$/)
410
+ if (newKeyMatch) {
411
+ astPrefix = newKeyMatch[1]
412
+ // Return true to indicate we found a valid context
413
+ // The isCriteriaMode flag will be used when building completions
414
+ return true
415
+ }
416
+ }
417
+ } else {
418
+ // Empty object, check if cursor is inside
419
+ const insideText = text.substring(objNode.start + 1, offset)
420
+ const newKeyMatch = insideText.match(/^\s*([a-zA-Z0-9_]*)$/)
421
+ if (newKeyMatch) {
422
+ astPrefix = newKeyMatch[1]
423
+ return true
424
+ }
425
+ }
426
+
427
+ return false
428
+ }
429
+
430
+ if (checkObjectForCursor(arg)) {
431
+ foundContext = true
432
+ }
433
+ }
434
+ })
435
+
436
+ if (foundContext && astModelName) {
437
+ modelName = astModelName
438
+ prefix = astPrefix
439
+ const foundKey = modelKeys.find(
440
+ (k) => k.toLowerCase() === modelName.toLowerCase()
441
+ )
442
+ const model = foundKey ? models[foundKey] : null
443
+ if (model) {
444
+ attributes = Object.keys(model.attributes || {})
445
+ return Array.from(
446
+ new Set(
447
+ attributes.filter((attr) =>
448
+ attr.toLowerCase().startsWith(prefix.toLowerCase())
449
+ )
450
+ )
451
+ ).map((attr) => {
452
+ const attrDef = model.attributes && model.attributes[attr]
453
+ let type = attrDef && attrDef.type ? attrDef.type : ''
454
+ let required = attrDef && attrDef.required ? 'required' : 'optional'
455
+ let detail = type ? `${type} (${required})` : required
456
+ return {
457
+ label: attr,
458
+ kind: lsp.CompletionItemKind.Field,
459
+ detail,
460
+ documentation: `${modelName}.${attr}`,
461
+ sortText: attr,
462
+ filterText: attr,
463
+ insertText: attr
464
+ }
465
+ })
466
+ }
467
+ }
468
+ } catch (err) {
469
+ // Fall through to regex-based detection
470
+ }
471
+
122
472
  // Determine the current Sails.js model context and attribute prefix for completions
123
473
  // by matching the code before the cursor against various Sails.js query patterns.
124
474
  // 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
- prefix = (prefixMatch && prefixMatch[1]) || ''
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
- const regex =
12
- /:\s*(?:{[^}]*?\baction\s*:\s*(?<quote>['"])(?<action>[^'"]+)\k<quote>[^}]*?}|(?<quoteAlt>['"])(?<actionAlt>[^'"]+)\k<quoteAlt>)/g
13
+ try {
14
+ const ast = acorn.parse(text, {
15
+ ecmaVersion: 'latest',
16
+ sourceType: 'module'
17
+ })
13
18
 
14
- let match
19
+ let result = null
15
20
 
16
- while ((match = regex.exec(text)) !== null) {
17
- const actionName = match.groups.action || match.groups.actionAlt
18
- const quote = match.groups.quote || match.groups.quoteAlt
19
- const fullMatchStart =
20
- match.index + match[0].indexOf(quote + actionName + quote)
21
- const fullMatchEnd = fullMatchStart + actionName.length + 2 // +2 for quotes
22
-
23
- if (offset >= fullMatchStart && offset <= fullMatchEnd) {
24
- const routeEntry = Object.values(typeMap.routes).find(
25
- (route) => route.action?.name === actionName
26
- )
27
- if (routeEntry?.action) {
28
- const { path: actionPath, fnLine } = routeEntry.action
29
- const uri = `file://${actionPath}`
30
- return lsp.LocationLink.create(
31
- uri,
32
- lsp.Range.create(fnLine - 1, 0, fnLine - 1, 0),
33
- lsp.Range.create(fnLine - 1, 0, fnLine - 1, 0),
34
- lsp.Range.create(
35
- document.positionAt(fullMatchStart),
36
- document.positionAt(fullMatchEnd)
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
  }