@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.
- package/SailsParser.js +652 -0
- package/completions/actions-completion.js +36 -0
- package/completions/data-types-completion.js +39 -0
- package/completions/helper-inputs-completion.js +91 -0
- package/completions/helpers-completion.js +85 -0
- package/completions/inertia-pages-completion.js +33 -0
- package/completions/input-props-completion.js +52 -0
- package/completions/model-attribute-props-completion.js +57 -0
- package/completions/model-attributes-completion.js +195 -0
- package/completions/model-methods-completion.js +71 -0
- package/completions/models-completion.js +52 -0
- package/completions/policies-completion.js +32 -0
- package/completions/views-completion.js +35 -0
- package/go-to-definitions/go-to-action.js +26 -49
- package/go-to-definitions/go-to-helper.js +37 -45
- package/go-to-definitions/go-to-model.js +39 -0
- package/go-to-definitions/go-to-page.js +38 -0
- package/go-to-definitions/go-to-policy.js +23 -72
- package/go-to-definitions/go-to-view.js +28 -55
- package/index.js +103 -19
- package/package.json +1 -1
- package/validators/validate-action-exist.js +28 -51
- package/validators/validate-data-type.js +34 -0
- package/validators/validate-document.js +42 -4
- package/validators/validate-helper-input-exist.js +42 -0
- package/validators/validate-model-attribute-exist.js +297 -0
- package/validators/validate-model-exist.js +64 -0
- package/validators/validate-page-exist.js +42 -0
- package/validators/validate-policy-exist.js +45 -0
- package/validators/validate-required-helper-input.js +49 -0
- package/validators/validate-required-model-attribute.js +56 -0
- package/validators/validate-view-exist.js +86 -0
- package/completions/sails-completions.js +0 -63
- package/go-to-definitions/go-to-inertia-page.js +0 -53
- package/helpers/find-fn-line.js +0 -21
- package/helpers/find-project-root.js +0 -18
- package/helpers/find-sails.js +0 -12
- package/helpers/load-sails.js +0 -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
|
+
}
|