@sailshq/language-server 0.0.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/SailsParser.js +652 -0
  2. package/completions/actions-completion.js +36 -0
  3. package/completions/data-types-completion.js +39 -0
  4. package/completions/helper-inputs-completion.js +91 -0
  5. package/completions/helpers-completion.js +85 -0
  6. package/completions/inertia-pages-completion.js +33 -0
  7. package/completions/input-props-completion.js +52 -0
  8. package/completions/model-attribute-props-completion.js +57 -0
  9. package/completions/model-attributes-completion.js +195 -0
  10. package/completions/model-methods-completion.js +71 -0
  11. package/completions/models-completion.js +52 -0
  12. package/completions/policies-completion.js +32 -0
  13. package/completions/views-completion.js +35 -0
  14. package/go-to-definitions/go-to-action.js +26 -49
  15. package/go-to-definitions/go-to-helper.js +37 -45
  16. package/go-to-definitions/go-to-model.js +39 -0
  17. package/go-to-definitions/go-to-page.js +38 -0
  18. package/go-to-definitions/go-to-policy.js +23 -72
  19. package/go-to-definitions/go-to-view.js +28 -55
  20. package/index.js +103 -19
  21. package/package.json +1 -1
  22. package/validators/validate-action-exist.js +28 -51
  23. package/validators/validate-data-type.js +34 -0
  24. package/validators/validate-document.js +42 -4
  25. package/validators/validate-helper-input-exist.js +42 -0
  26. package/validators/validate-model-attribute-exist.js +297 -0
  27. package/validators/validate-model-exist.js +64 -0
  28. package/validators/validate-page-exist.js +42 -0
  29. package/validators/validate-policy-exist.js +45 -0
  30. package/validators/validate-required-helper-input.js +49 -0
  31. package/validators/validate-required-model-attribute.js +56 -0
  32. package/validators/validate-view-exist.js +86 -0
  33. package/completions/sails-completions.js +0 -63
  34. package/go-to-definitions/go-to-inertia-page.js +0 -53
  35. package/helpers/find-fn-line.js +0 -21
  36. package/helpers/find-project-root.js +0 -18
  37. package/helpers/find-sails.js +0 -12
  38. package/helpers/load-sails.js +0 -39
@@ -0,0 +1,39 @@
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
+ // Require `type: '` or `type: "` with optional partial type after it
19
+ const match = before.match(/type\s*:\s*(['"])([a-z]*)$/i)
20
+ if (!match) return []
21
+
22
+ const prefix = match[2] || ''
23
+
24
+ const contextText = before.toLowerCase()
25
+ const inAttributes = /attributes\s*:\s*{([\s\S]*)$/.test(contextText)
26
+ const inInputs = /inputs\s*:\s*{([\s\S]*)$/.test(contextText)
27
+
28
+ if (!(inAttributes || inInputs)) return []
29
+
30
+ return typeMap.dataTypes
31
+ .filter(({ type }) => type.startsWith(prefix))
32
+ .map(({ type, description }) => ({
33
+ label: type,
34
+ kind: lsp.CompletionItemKind.TypeParameter,
35
+ detail: 'Data type',
36
+ documentation: description,
37
+ insertText: type
38
+ }))
39
+ }
@@ -0,0 +1,91 @@
1
+ const lsp = require('vscode-languageserver/node')
2
+
3
+ // Find the helper path context from the line, e.g. sails.helpers.email.sendEmail.with({
4
+ function getHelperPath(line) {
5
+ const match = line.match(
6
+ /sails\.helpers((?:\.[a-zA-Z0-9_]+)+)\.with\s*\(\s*\{[^}]*$/
7
+ )
8
+ if (!match) return null
9
+ // e.g. '.email.sendEmail' => ['email', 'sendEmail']
10
+ return match[1].split('.').filter(Boolean)
11
+ }
12
+
13
+ function getInputCompletionItems(inputsObj) {
14
+ if (!inputsObj || typeof inputsObj !== 'object') return []
15
+ return Object.entries(inputsObj).map(([inputName, inputDef]) => {
16
+ let type = inputDef?.type
17
+ let required = inputDef?.required ? 'required' : 'optional'
18
+ let description = inputDef?.description || ''
19
+ let detail = type ? `${type} (${required})` : required
20
+ return {
21
+ label: inputName,
22
+ kind: lsp.CompletionItemKind.Field,
23
+ detail: detail,
24
+ documentation: description,
25
+ insertText: `${inputName}: `
26
+ }
27
+ })
28
+ }
29
+
30
+ module.exports = function helperInputsCompletion(document, position, typeMap) {
31
+ const text = document.getText()
32
+ const offset = document.offsetAt(position)
33
+ const before = text.substring(0, offset)
34
+ const lines = before.split('\n')
35
+ const line = lines[lines.length - 1]
36
+
37
+ // Only trigger if not after a colon (:) on this line
38
+ // e.g. don't trigger if "foo: '" or "foo: \"" or "foo: 1"
39
+ // But DO trigger after a comma (,) or at the start of a new property
40
+ // Find the text before the cursor on this line
41
+ const beforeCursor = line.slice(0, position.character)
42
+ // If the last non-whitespace character before the cursor is a colon, do not complete
43
+ // (but allow after comma, or at start of line/object)
44
+ const lastColon = beforeCursor.lastIndexOf(':')
45
+ const lastComma = beforeCursor.lastIndexOf(',')
46
+ // If the last colon is after the last comma, and after any opening brace, suppress completion
47
+ if (lastColon > lastComma && lastColon > beforeCursor.lastIndexOf('{'))
48
+ return []
49
+
50
+ const pathParts = getHelperPath(line)
51
+ if (!pathParts) return []
52
+
53
+ // Find already-used property names in the current object literal
54
+ // We'll look for all foo: ... pairs before the cursor in the current .with({ ... })
55
+ const objectStart = before.lastIndexOf('{')
56
+ const objectEnd = before.lastIndexOf('}')
57
+ let usedProps = new Set()
58
+ if (objectStart !== -1 && (objectEnd === -1 || objectStart > objectEnd)) {
59
+ // Get the text inside the current object literal up to the cursor
60
+ const objectText = before.slice(objectStart, offset)
61
+ // Match all property names before the cursor
62
+ const propRegex = /([a-zA-Z0-9_]+)\s*:/g
63
+ let m
64
+ while ((m = propRegex.exec(objectText)) !== null) {
65
+ usedProps.add(m[1])
66
+ }
67
+ }
68
+
69
+ // Convert camelCase to kebab-case for the last part
70
+ function camelToKebab(str) {
71
+ return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
72
+ }
73
+ // Try to find the helper in typeMap.helpers
74
+ let helperKey = pathParts.join('/')
75
+ let helper = typeMap.helpers[helperKey]
76
+ if (!helper) {
77
+ // Try kebab-case for last part
78
+ const last = pathParts[pathParts.length - 1]
79
+ pathParts[pathParts.length - 1] = camelToKebab(last)
80
+ helperKey = pathParts.join('/')
81
+ helper = typeMap.helpers[helperKey]
82
+ }
83
+ if (!helper || !helper.inputs || typeof helper.inputs !== 'object') return []
84
+
85
+ // Filter out already-used properties
86
+ const availableInputs = Object.fromEntries(
87
+ Object.entries(helper.inputs).filter(([key]) => !usedProps.has(key))
88
+ )
89
+
90
+ return getInputCompletionItems(availableInputs)
91
+ }
@@ -0,0 +1,85 @@
1
+ const { CompletionItemKind } = require('vscode-languageserver/node')
2
+
3
+ // Convert kebab-case to camelCase (e.g., 'send-email' -> 'sendEmail')
4
+ function kebabToCamel(str) {
5
+ return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
6
+ }
7
+
8
+ // Get the helper path context from the line, e.g. sails.helpers.email.
9
+ function getHelpersContext(line) {
10
+ const match = line.match(/sails\.helpers((?:\.[a-zA-Z0-9_]+)*)\.$/)
11
+ if (!match) return []
12
+ // e.g. '.email.foo.' => ['email', 'foo']
13
+ return match[1] ? match[1].split('.').filter(Boolean) : []
14
+ }
15
+
16
+ module.exports = function helpersCompletion(document, position, typeMap) {
17
+ const line = document.getText({
18
+ start: { line: position.line, character: 0 },
19
+ end: position
20
+ })
21
+ // Prevent helpers completion inside .with({ ... })
22
+ if (/\.with\s*\(\s*\{[^}]*$/.test(line)) {
23
+ return []
24
+ }
25
+ // Prevent helpers completion inside sails.helpers.foo({ ... })
26
+ if (/sails\.helpers(?:\.[a-zA-Z0-9_]+)+\s*\(\s*\{[^}]*$/.test(line)) {
27
+ return []
28
+ }
29
+ const helpers = typeMap.helpers || {}
30
+ const context = getHelpersContext(line.trim())
31
+ if (!line.trim().includes('sails.helpers.')) return []
32
+
33
+ // Build a tree of helpers from the flat keys
34
+ const tree = {}
35
+ for (const key of Object.keys(helpers)) {
36
+ const parts = key.split('/')
37
+ let node = tree
38
+ for (let i = 0; i < parts.length; i++) {
39
+ const part = parts[i]
40
+ if (!node[part])
41
+ node[part] =
42
+ i === parts.length - 1 ? { __isHelper: true, __key: key } : {}
43
+ node = node[part]
44
+ }
45
+ }
46
+
47
+ // Traverse the tree according to the context
48
+ let node = tree
49
+ for (const part of context) {
50
+ if (!node[part]) return []
51
+ node = node[part]
52
+ }
53
+
54
+ // If at a namespace, suggest children (namespaces or helpers)
55
+ return Object.entries(node)
56
+ .filter(([k]) => !k.startsWith('__'))
57
+ .map(([k, v]) => {
58
+ if (v.__isHelper) {
59
+ const helperInfo = helpers[v.__key] || {}
60
+ // Updated: check if inputs is a non-empty object
61
+ const hasInputs =
62
+ helperInfo.inputs &&
63
+ typeof helperInfo.inputs === 'object' &&
64
+ Object.keys(helperInfo.inputs).length > 0
65
+ return {
66
+ label: kebabToCamel(k),
67
+ kind: CompletionItemKind.Method,
68
+ detail: helperInfo.description || 'Helper function',
69
+ documentation: helperInfo.path || '',
70
+ insertText: hasInputs
71
+ ? `${kebabToCamel(k)}.with({$0})`
72
+ : `${kebabToCamel(k)}()`,
73
+ insertTextFormat: 2 // Snippet
74
+ }
75
+ } else {
76
+ // Namespace/folder
77
+ return {
78
+ label: k,
79
+ kind: CompletionItemKind.Module,
80
+ detail: 'Helper namespace',
81
+ insertText: k + '.'
82
+ }
83
+ }
84
+ })
85
+ }
@@ -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,52 @@
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
+ const lines = before.split('\n')
17
+ const lastLine = lines[lines.length - 1]
18
+
19
+ // Only trigger if we're on a new line with optional whitespace (no code)
20
+ if (!/^\s*$/.test(lastLine)) return []
21
+
22
+ // Check we're inside the inputs: { ... } section
23
+ const insideInputs = /inputs\s*:\s*{[\s\S]*$/.test(before)
24
+ if (!insideInputs) return []
25
+
26
+ // Walk backward to see if we're inside an input block
27
+ const reversed = lines.slice().reverse()
28
+ let insideInputBlock = false
29
+
30
+ for (const line of reversed) {
31
+ const trimmed = line.trim()
32
+ if (/^[a-zA-Z0-9_]+\s*:\s*{\s*$/.test(trimmed)) {
33
+ insideInputBlock = true
34
+ break
35
+ }
36
+ if (/^\}/.test(trimmed)) break
37
+ }
38
+
39
+ if (!insideInputBlock) return []
40
+
41
+ return typeMap.inputProps.map(({ label, detail }) => ({
42
+ label,
43
+ kind:
44
+ label === 'custom'
45
+ ? lsp.CompletionItemKind.Method
46
+ : lsp.CompletionItemKind.Field,
47
+ detail,
48
+ documentation: detail,
49
+ insertText: `${label}: `,
50
+ insertTextFormat: lsp.InsertTextFormat.PlainText
51
+ }))
52
+ }
@@ -0,0 +1,57 @@
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
+ if (!filePath.includes('/api/models/')) return []
10
+
11
+ const text = document.getText()
12
+ const offset = document.offsetAt(position)
13
+ const before = text.substring(0, offset)
14
+ const lines = before.split('\n')
15
+ const lastLine = lines[lines.length - 1]
16
+
17
+ // Only trigger on an empty or whitespace-only line
18
+ if (!/^\s*$/.test(lastLine)) return []
19
+
20
+ // Confirm we're inside the attributes section
21
+ const insideAttributes = /attributes\s*:\s*{([\s\S]*)$/.exec(before)
22
+ if (!insideAttributes) return []
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--) {
28
+ 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
+ }
41
+ }
42
+ }
43
+ if (insideProperty) break
44
+ if (/^\s*attributes\s*:\s*{/.test(line)) break
45
+ }
46
+
47
+ if (!insideProperty) return []
48
+
49
+ return typeMap.modelAttributeProps.map(({ label, detail }) => ({
50
+ label,
51
+ kind: lsp.CompletionItemKind.Field,
52
+ detail,
53
+ documentation: detail,
54
+ insertText: `${label}: `,
55
+ insertTextFormat: lsp.InsertTextFormat.PlainText
56
+ }))
57
+ }
@@ -0,0 +1,195 @@
1
+ const lsp = require('vscode-languageserver/node')
2
+
3
+ module.exports = function modelAttributesCompletion(
4
+ document,
5
+ position,
6
+ typeMap
7
+ ) {
8
+ const JS_FILE_TYPES = ['helpers', 'controllers', 'scripts', 'models']
9
+ const isRelevantFile = JS_FILE_TYPES.some((type) =>
10
+ document.uri.includes(`${type}/`)
11
+ )
12
+ if (!isRelevantFile) return []
13
+
14
+ const text = document.getText()
15
+ const offset = document.offsetAt(position)
16
+ const before = text.substring(0, offset)
17
+
18
+ const criteriaMatch = before.match(
19
+ /(?:sails\.models\.([A-Za-z_$][\w$]*)|([A-Za-z_$][\w$]*))\s*\.\w+\s*\(\s*\{[^}]*([a-zA-Z0-9_]*)?$/
20
+ )
21
+ const selectStringMatch = before.match(
22
+ /(?:select|omit|sort)\s*:\s*['"]([a-zA-Z0-9_]*)?$/
23
+ )
24
+ const selectArrayMatch = before.match(
25
+ /(?:select|omit|sort)\s*:\s*\[\s*['"]([a-zA-Z0-9_]*)?$/
26
+ )
27
+ const populateStringMatch = before.match(
28
+ /populate\s*:\s*['"]([a-zA-Z0-9_]*)?$/
29
+ )
30
+ const sortStringMatch = before.match(/sort\s*:\s*['"]([a-zA-Z0-9_]*)?$/)
31
+ const sortArrayStringMatch = before.match(
32
+ /sort\s*:\s*\[\s*[^\{\]]*['"]([a-zA-Z0-9_]*)?$/
33
+ )
34
+ const sortArrayObjectMatch = before.match(
35
+ /sort\s*:\s*\[\s*\{\s*([a-zA-Z0-9_]*)?$/
36
+ )
37
+
38
+ // Detect if we are inside a .select(['']), .omit(['']), .sort(['']), etc. chainable method call context
39
+ // This matches e.g. .select(['foo', '']) or .omit(["bar", '']) or .select('foo')
40
+ const chainableMethodCallRegex =
41
+ /\.(select|omit|sort)\s*\(\s*([\[\{]?[^\)]*)$/
42
+ const isInChainableMethodCall = chainableMethodCallRegex.test(before)
43
+
44
+ // Detect if we are inside a .select([]), .omit([]), .sort([]), etc. as a method call (e.g. User.find().select([]))
45
+ // This matches e.g. .select(['foo', '']) or .omit(["bar", '']) or .select('foo')
46
+ const chainableDirectCallMatch = before.match(
47
+ /\.(select|omit|sort|populate|where)\s*\(\s*\[?\s*['"]?([a-zA-Z0-9_]*)?$/
48
+ )
49
+
50
+ // Also allow completions in .where({ ... }) chainable method call context
51
+ const whereMethodCallRegex = /\.where\s*\(\s*\{[^\)]*$/
52
+ const isInWhereMethodCall = whereMethodCallRegex.test(before)
53
+
54
+ // Add: detect .where({ ... }) context for modelName/prefix inference
55
+ const whereMethodCallMatch = before.match(
56
+ /([A-Za-z_$][\w$]*)\s*\.where\s*\(\s*\{[^}]*([a-zA-Z0-9_]*)?$/
57
+ )
58
+
59
+ // Only suppress completions after a colon (:) in object literals for static methods,
60
+ // but always allow completions in .select(['']), .omit(['']), .sort(['']), .where({}), etc.
61
+ const inChainableString =
62
+ selectStringMatch ||
63
+ selectArrayMatch ||
64
+ sortStringMatch ||
65
+ sortArrayStringMatch ||
66
+ sortArrayObjectMatch ||
67
+ populateStringMatch ||
68
+ isInChainableMethodCall ||
69
+ isInWhereMethodCall ||
70
+ !!chainableDirectCallMatch
71
+
72
+ // Suppress completions after a colon only if NOT in a chainable string/array context
73
+ if (!inChainableString) {
74
+ const lines = before.split('\n')
75
+ const line = lines[lines.length - 1]
76
+ const beforeCursor = line.slice(0, position.character)
77
+ // If the last non-whitespace character before the cursor is a colon, suppress completion
78
+ // (but allow after comma, or at start of line/object)
79
+ const lastColon = beforeCursor.lastIndexOf(':')
80
+ const lastComma = beforeCursor.lastIndexOf(',')
81
+ if (lastColon > lastComma && lastColon > beforeCursor.lastIndexOf('{')) {
82
+ // Check if we are inside a string (e.g. after a colon and inside quotes)
83
+ // If so, suppress completion
84
+ const quoteBefore = beforeCursor.lastIndexOf("'")
85
+ const dquoteBefore = beforeCursor.lastIndexOf('"')
86
+ if (
87
+ (quoteBefore > lastColon && quoteBefore > lastComma) ||
88
+ (dquoteBefore > lastColon && dquoteBefore > lastComma)
89
+ ) {
90
+ return []
91
+ }
92
+ // Otherwise, suppress completion after colon
93
+ return []
94
+ }
95
+ }
96
+
97
+ let modelName,
98
+ prefix,
99
+ attributes,
100
+ isPopulate = false
101
+
102
+ const models = typeMap.models || {}
103
+ const modelKeys = Object.keys(models)
104
+
105
+ // Better model name inference using last static model call
106
+ function inferModelName(before) {
107
+ const allMatches = [
108
+ ...before.matchAll(
109
+ /(?:sails\.models\.([A-Za-z_$][\w$]*)|([A-Z][A-Za-z0-9_]*))\s*\./g
110
+ )
111
+ ]
112
+ if (allMatches.length === 0) return null
113
+ const last = allMatches[allMatches.length - 1]
114
+ return last[1] || last[2] || null
115
+ }
116
+
117
+ if (criteriaMatch) {
118
+ modelName = criteriaMatch[1] || criteriaMatch[2]
119
+ prefix = criteriaMatch[3] || ''
120
+ } else if (selectStringMatch || selectArrayMatch) {
121
+ modelName = inferModelName(before)
122
+ prefix = (selectStringMatch || selectArrayMatch)[1] || ''
123
+ } else if (populateStringMatch) {
124
+ isPopulate = true
125
+ modelName = inferModelName(before)
126
+ prefix = populateStringMatch[1] || ''
127
+ } else if (sortStringMatch || sortArrayStringMatch || sortArrayObjectMatch) {
128
+ modelName = inferModelName(before)
129
+ prefix =
130
+ (sortStringMatch || sortArrayStringMatch || sortArrayObjectMatch)[1] || ''
131
+ } else if (whereMethodCallMatch) {
132
+ modelName = whereMethodCallMatch[1]
133
+ prefix = whereMethodCallMatch[2] || ''
134
+ } else if (chainableDirectCallMatch) {
135
+ modelName = inferModelName(before)
136
+ prefix = chainableDirectCallMatch[2] || ''
137
+ } else {
138
+ return []
139
+ }
140
+
141
+ if (!modelName) return []
142
+ const foundKey = modelKeys.find(
143
+ (k) => k.toLowerCase() === modelName.toLowerCase()
144
+ )
145
+ const model = foundKey ? models[foundKey] : null
146
+ if (!model) return []
147
+
148
+ // Find already-used property names in the current object literal (if inside one)
149
+ let usedProps = new Set()
150
+ // Use the text before the cursor to find the nearest opening brace
151
+ const beforeCursorFull = text.slice(0, offset)
152
+ const lastOpen = beforeCursorFull.lastIndexOf('{')
153
+ const lastClose = beforeCursorFull.lastIndexOf('}')
154
+ if (lastOpen !== -1 && (lastClose === -1 || lastOpen > lastClose)) {
155
+ // Only consider properties before the cursor
156
+ const objectText = beforeCursorFull.slice(lastOpen, offset)
157
+ // Match both foo: ... and object shorthand foo,
158
+ const propRegex = /([a-zA-Z0-9_]+)\s*:/g
159
+ const shorthandRegex = /([a-zA-Z0-9_]+)\s*,/g
160
+ let m
161
+ while ((m = propRegex.exec(objectText)) !== null) {
162
+ usedProps.add(m[1])
163
+ }
164
+ while ((m = shorthandRegex.exec(objectText)) !== null) {
165
+ usedProps.add(m[1])
166
+ }
167
+ }
168
+
169
+ if (isPopulate) {
170
+ attributes = Object.entries(model.attributes || {})
171
+ .filter(([, def]) => def && (def.model || def.collection))
172
+ .map(([attr]) => attr)
173
+ } else {
174
+ attributes = Object.keys(model.attributes || {})
175
+ }
176
+
177
+ return attributes
178
+ .filter((attr) => attr.startsWith(prefix))
179
+ .filter((attr) => !usedProps.has(attr))
180
+ .map((attr) => {
181
+ const attrDef = model.attributes && model.attributes[attr]
182
+ let type = attrDef && attrDef.type ? attrDef.type : ''
183
+ let required = attrDef && attrDef.required ? 'required' : 'optional'
184
+ let detail = type ? `${type} (${required})` : required
185
+ return {
186
+ label: attr,
187
+ kind: lsp.CompletionItemKind.Field,
188
+ detail,
189
+ documentation: `${modelName}.${attr}`,
190
+ sortText: attr,
191
+ filterText: attr,
192
+ insertText: attr
193
+ }
194
+ })
195
+ }
@@ -0,0 +1,71 @@
1
+ const lsp = require('vscode-languageserver/node')
2
+
3
+ module.exports = function modelMethodsCompletion(document, position, typeMap) {
4
+ const JS_FILE_TYPES = ['helpers', 'controllers', 'scripts', 'models']
5
+ const isRelevantFile = JS_FILE_TYPES.some((type) =>
6
+ document.uri.includes(`${type}/`)
7
+ )
8
+ if (!isRelevantFile) return []
9
+
10
+ const text = document.getText()
11
+ const offset = document.offsetAt(position)
12
+ const before = text.substring(0, offset)
13
+
14
+ // Match static calls like User.method or sails.models.user.method
15
+ const staticCallMatch = before.match(
16
+ /(?:sails\.models\.([A-Za-z_$][\w$]*)|([A-Za-z_$][\w$]*))\.\s*([a-zA-Z]*)?$/
17
+ )
18
+ // Match chainable calls like User.find().<chainable> or User.find({...}).<chainable>
19
+ const chainableCallMatch = before.match(
20
+ /([A-Za-z_$][\w$]*)\.[a-zA-Z_]+\([^)]*\)\.\s*([a-zA-Z]*)?$/
21
+ )
22
+
23
+ let modelName, prefix, methods
24
+
25
+ // Make model lookup case-insensitive
26
+ const models = typeMap.models || {}
27
+ const modelKeys = Object.keys(models)
28
+
29
+ if (chainableCallMatch) {
30
+ modelName = chainableCallMatch[1]
31
+ prefix = chainableCallMatch[2] || ''
32
+ if (!modelName) return []
33
+ const foundKey = modelKeys.find(
34
+ (k) => k.toLowerCase() === modelName.toLowerCase()
35
+ )
36
+ methods = foundKey ? models[foundKey].chainableMethods || [] : []
37
+ } else if (staticCallMatch) {
38
+ modelName = staticCallMatch[1] || staticCallMatch[2]
39
+ prefix = staticCallMatch[3] || ''
40
+ if (!modelName) return []
41
+ const foundKey = modelKeys.find(
42
+ (k) => k.toLowerCase() === modelName.toLowerCase()
43
+ )
44
+ methods = foundKey ? models[foundKey].methods || [] : []
45
+ } else {
46
+ return []
47
+ }
48
+
49
+ return methods
50
+ .filter((method) => method.name.startsWith(prefix))
51
+ .map((method) => {
52
+ let insertText = method.name + '($0)'
53
+ // For chainable .select or .omit, insert ([''])
54
+ if (
55
+ chainableCallMatch &&
56
+ (method.name === 'select' || method.name === 'omit')
57
+ ) {
58
+ insertText = method.name + '([$0])'
59
+ }
60
+ return {
61
+ label: method.name,
62
+ kind: lsp.CompletionItemKind.Method,
63
+ detail: method.description,
64
+ documentation: `${modelName}.${method.name}()`,
65
+ sortText: method.name,
66
+ filterText: method.name,
67
+ insertText,
68
+ insertTextFormat: lsp.InsertTextFormat.Snippet
69
+ }
70
+ })
71
+ }
@@ -0,0 +1,52 @@
1
+ const lsp = require('vscode-languageserver/node')
2
+
3
+ module.exports = function modelsCompletion(document, position, typeMap) {
4
+ const text = document.getText()
5
+ const offset = document.offsetAt(position)
6
+ const before = text.substring(0, offset)
7
+
8
+ // sails.models.<model>
9
+ const dotMatch = before.match(/sails\.models\.([a-zA-Z0-9_]*)$/)
10
+ if (dotMatch) {
11
+ const prefix = dotMatch[1] || ''
12
+
13
+ return Object.entries(typeMap.models || {})
14
+ .map(([modelName, model]) => {
15
+ const key = modelName.toLowerCase()
16
+ if (!key.startsWith(prefix.toLowerCase())) return null
17
+
18
+ return {
19
+ label: key,
20
+ kind: lsp.CompletionItemKind.Class,
21
+ detail: 'Sails Model (sails.models)',
22
+ documentation: model.src?.replace(/^.*\/api\/models\//, ''),
23
+ insertText: key
24
+ }
25
+ })
26
+ .filter(Boolean)
27
+ }
28
+
29
+ // await User.<method>
30
+ // Only match if there's an uppercase prefix and it follows "await"
31
+ const awaitMatch = before.match(/(?:await\s+)([A-Z][a-zA-Z0-9_]*)$/)
32
+ if (awaitMatch) {
33
+ const prefix = awaitMatch[1]
34
+ if (!prefix) return [] // Don't show anything if prefix is empty
35
+
36
+ return Object.entries(typeMap.models || {})
37
+ .map(([modelName, model]) => {
38
+ if (!modelName.startsWith(prefix)) return null
39
+
40
+ return {
41
+ label: modelName,
42
+ kind: lsp.CompletionItemKind.Class,
43
+ detail: 'Sails Model',
44
+ documentation: model.path?.replace(/^.*\/api\/models\//, ''),
45
+ insertText: modelName
46
+ }
47
+ })
48
+ .filter(Boolean)
49
+ }
50
+
51
+ return []
52
+ }