@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.
@@ -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
- 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
  }
@@ -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
+ }