Skip to content

Commit a38f5d7

Browse files
committed
Add support for async type resolving
1 parent dc871f8 commit a38f5d7

12 files changed

Lines changed: 296 additions & 248 deletions

.eslintrc.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
},
1919
"ignorePatterns": ["dist/*", "node_modules/*"],
2020
"rules": {
21+
"indent": ["error", 2],
22+
"brace-style": ["error", "1tbs", { "allowSingleLine": false }],
2123
"@typescript-eslint/no-explicit-any": "off"
2224
}
2325
}

CONFIGURATION.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
| `Number` | `'number'` | Equivalent to PHP `int` or `float` |
1414
| `String` | `'string'` | Equivalent to PHP `string` |
1515
| `Null` | `'null'` | Equivalent to PHP `null` |
16+
| `Array` | `'array'` | Equivalent to PHP `array` |
1617
| `Any` | `'any'` | Equivalent to PHP `mixed` |
1718

1819

@@ -27,11 +28,12 @@
2728

2829
### ExpressionLanguageConfig
2930

30-
The configuration object that is passed to `expressionlanguage` function
31+
3132

3233
| Property | Type | Description |
3334
| ---------- | ---------- | ---------- |
3435
| `types` | `{ [key: string]: ELType; } or undefined` | Type definitions used in `identifiers` and `functions` |
36+
| `typeResolver` | `TypeResolver or undefined` | Optional async type resolver for dynamic/lazy type loading |
3537
| `identifiers` | `ELIdentifier[] or undefined` | Top-level variables |
3638
| `functions` | `ELFunction[] or undefined` | Top-level functions |
3739

@@ -96,8 +98,17 @@ Represents a function or a method of an object
9698

9799
## Types
98100

101+
- [TypeResolver](#typeresolver)
99102
- [ELTypeName](#eltypename)
100103

104+
### TypeResolver
105+
106+
The configuration object that is passed to `expressionlanguage` function
107+
108+
| Type | Type |
109+
| ---------- | ---------- |
110+
| `TypeResolver` | `(type: string) => Promise<ELType or undefined> or ELType or undefined` |
111+
101112
### ELTypeName
102113

103114
One of predefined types (`ELScalar`) or a custom type from `ExpressionLanguageConfig.types`

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"name": "@valtzu/codemirror-lang-el",
33
"description": "Symfony Expression Language language support for CodeMirror",
44
"scripts": {
5+
"generate-docs": "npx tsdoc --src=src/types.ts --dest=CONFIGURATION.md --noemoji --types",
56
"pretest": "npm run-script prepare",
67
"test": "cm-runtests",
78
"prepare": "cm-buildhelper src/index.ts",

src/complete.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -75,23 +75,26 @@ function completeIdentifier(state: EditorState, config: ExpressionLanguageConfig
7575
};
7676
}
7777

78-
function completeMember(state: EditorState, config: ExpressionLanguageConfig, tree: SyntaxNode, from: number, to: number): CompletionResult | null {
78+
79+
async function completeMember(state: EditorState, config: ExpressionLanguageConfig, tree: SyntaxNode, from: number, to: number): Promise<CompletionResult | null> {
7980
if (!(tree.parent?.type.is(PropertyAccess) || tree.parent?.type.is(MethodAccess)) || !tree.parent?.firstChild) {
8081
return null;
8182
}
8283

83-
const types = resolveTypes(state, tree.parent.firstChild.node, config);
84+
const types = await resolveTypes(state, tree.parent.firstChild.node, config);
8485
if (!types?.size) {
8586
return null;
8687
}
8788

88-
const options = [];
89+
const options: Completion[] = [];
8990
for (const type of types) {
90-
const typeDeclaration = config.types?.[type];
91-
options.push(
92-
...(typeDeclaration?.identifiers?.map(autocompleteIdentifier) || []),
93-
...(typeDeclaration?.functions?.map(autocompleteFunction) || []),
94-
);
91+
const typeDeclaration = await config.typeResolver(type);
92+
if (typeDeclaration?.identifiers) {
93+
options.push(...typeDeclaration.identifiers.map(autocompleteIdentifier));
94+
}
95+
if (typeDeclaration?.functions) {
96+
options.push(...typeDeclaration.functions.map(autocompleteFunction));
97+
}
9598
}
9699

97100
return {
@@ -102,7 +105,8 @@ function completeMember(state: EditorState, config: ExpressionLanguageConfig, tr
102105
};
103106
}
104107

105-
export function expressionLanguageCompletion(context: CompletionContext): CompletionResult | null {
108+
109+
export async function expressionLanguageCompletion(context: CompletionContext): Promise<CompletionResult | null> {
106110
const { state, pos, explicit } = context;
107111
const tree = syntaxTree(state);
108112
const lastChar = state.sliceDoc(pos - 1, pos);
@@ -117,7 +121,7 @@ export function expressionLanguageCompletion(context: CompletionContext): Comple
117121
}
118122

119123
if ((prevNode.parent?.type.is(PropertyAccess) || prevNode.parent?.type.is(MethodAccess)) && [PropertyAccess, MethodAccess, ArrayAccess, Variable, Call, Application].includes(prevNode.parent.firstChild?.type.id)) {
120-
return completeMember(state, config, prevNode, isIdentifier(prevNode) || isMember(prevNode) ? prevNode.from : pos, pos);
124+
return await completeMember(state, config, prevNode, isIdentifier(prevNode) || isMember(prevNode) ? prevNode.from : pos, pos);
121125
}
122126

123127
if (

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export const ELLanguage = LRLanguage.define({
5454
});
5555

5656
export function expressionlanguage(config: ExpressionLanguageConfig = {}, extensions: Extension[] = []) {
57+
config.typeResolver ??= (type) => config.types?.[type]
5758
return new LanguageSupport(ELLanguage, [
5859
ELLanguage.data.of({
5960
autocomplete: expressionLanguageCompletion,

src/linter.ts

Lines changed: 96 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -12,130 +12,121 @@ export const expressionLanguageLinterSource = (state: EditorState) => {
1212
const config = getExpressionLanguageConfig(state);
1313
const diagnostics: Diagnostic[] = [];
1414

15-
syntaxTree(state).cursor().iterate(node => {
15+
const processNode = async (node: any) => {
1616
const { from, to, type: { id } } = node;
17-
1817
let identifier: string | undefined;
1918
switch (id) {
20-
case 0: {
21-
if (state.doc.length === 0 || from === 0) {
22-
// Don't show error on empty doc (even though it is an error)
23-
return;
24-
}
25-
26-
identifier = state.sliceDoc(from, to);
27-
if (identifier.length === 0) {
28-
diagnostics.push({ from, to: node.node.parent?.parent?.to ?? to, severity: 'error', message: `Expression expected` });
29-
} else {
30-
const type = /^[a-zA-Z_]+[a-zA-Z_0-9]*$/.test(identifier) ? 'identifier' : 'operator';
31-
diagnostics.push({ from, to, severity: 'error', message: `Unexpected ${type} <code>${identifier}</code>` });
32-
}
33-
19+
case 0: {
20+
if (state.doc.length === 0 || from === 0) {
21+
return;
22+
}
23+
identifier = state.sliceDoc(from, to);
24+
if (identifier.length === 0) {
25+
diagnostics.push({ from, to: node.node.parent?.parent?.to ?? to, severity: 'error', message: `Expression expected` });
26+
} else {
27+
const type = /^[a-zA-Z_]+[a-zA-Z_0-9]*$/.test(identifier) ? 'identifier' : 'operator';
28+
diagnostics.push({ from, to, severity: 'error', message: `Unexpected ${type} <code>${identifier}</code>` });
29+
}
30+
return;
31+
}
32+
case Arguments: {
33+
const fn = await resolveFunctionDefinition(node.node.prevSibling, state, config);
34+
const args = fn?.args;
35+
if (!args) {
3436
return;
3537
}
36-
case Arguments: {
37-
const fn = resolveFunctionDefinition(node.node.prevSibling, state, config);
38-
const args = fn?.args;
39-
if (!args) {
40-
return;
38+
const argCountMin = args.reduce((count, arg) => count + Number(!arg.optional), 0);
39+
const argCountMax = args.length;
40+
const argumentCountHintFn = () => `<code>${fn?.name}</code> takes ${argCountMin == argCountMax ? `exactly ${argCountMax}` : `${argCountMin}${argCountMax}`} argument${argCountMax == 1 ? '' : 's'}`;
41+
let i = 0;
42+
for (let n = node.node.firstChild; n != null; n = n.nextSibling) {
43+
if (n.type.is(BlockComment)) {
44+
continue;
4145
}
42-
const argCountMin = args.reduce((count, arg) => count + Number(!arg.optional), 0);
43-
const argCountMax = args.length;
44-
const argumentCountHintFn = () => `<code>${fn.name}</code> takes ${argCountMin == argCountMax ? `exactly ${argCountMax}` : `${argCountMin}${argCountMax}`} argument${argCountMax == 1 ? '' : 's'}`;
45-
let i = 0;
46-
47-
for (let n = node.node.firstChild; n != null; n = n.nextSibling) {
48-
if (n.type.is(BlockComment)) {
49-
continue;
50-
}
51-
52-
if (i > argCountMax - 1) {
53-
diagnostics.push({ from: n.from, to: n.to, severity: 'warning', message: `Unexpected argument – ${argumentCountHintFn()}` });
54-
continue;
55-
}
56-
57-
const typesUsed = Array.from(resolveTypes(state, n, config));
58-
const typesExpected = args[i].type;
59-
60-
if (typesExpected && !typesExpected.includes(ELScalar.Any) && !typesUsed.some(x => typesExpected.includes(x))) {
61-
diagnostics.push({
62-
from: n.from,
63-
to: n.to,
64-
severity: 'error',
65-
message: `<code>${typesExpected.join('|')}</code> expected, got <code>${typesUsed.join('|')}</code>`
66-
});
67-
}
68-
i++;
46+
if (i > argCountMax - 1) {
47+
diagnostics.push({ from: n.from, to: n.to, severity: 'warning', message: `Unexpected argument – ${argumentCountHintFn()}` });
48+
continue;
6949
}
70-
71-
if (i < argCountMin) {
72-
diagnostics.push({ from: node.from, to: node.to, severity: 'error', message: `Too few arguments – ${argumentCountHintFn()}` });
50+
const typesUsed = Array.from(await resolveTypes(state, n, config));
51+
const typesExpected = args[i].type;
52+
if (typesExpected && !typesExpected.includes(ELScalar.Any) && !typesUsed.some(x => typesExpected.includes(x))) {
53+
diagnostics.push({
54+
from: n.from,
55+
to: n.to,
56+
severity: 'error',
57+
message: `<code>${typesExpected.join('|')}</code> expected, got <code>${typesUsed.join('|')}</code>`
58+
});
7359
}
74-
75-
break;
60+
i++;
7661
}
77-
case Property:
78-
case Method: {
79-
const leftArgument = node.node.parent?.firstChild?.node;
80-
const types = Array.from(resolveTypes(state, leftArgument, config));
81-
identifier = state.sliceDoc(from, to);
82-
83-
if (!types.find(type => resolveIdentifier(id, identifier, config.types?.[type]))) {
84-
diagnostics.push({ from, to, severity: 'error', message: `${node.name} <code>${identifier}</code> not found in <code>${types.join('|')}</code>` });
85-
}
86-
87-
break;
62+
if (i < argCountMin) {
63+
diagnostics.push({ from: node.from, to: node.to, severity: 'error', message: `Too few arguments – ${argumentCountHintFn()}` });
8864
}
89-
case Variable:
90-
case Function: {
91-
identifier = state.sliceDoc(from, node.node.firstChild ? node.node.firstChild.from - 1 : to);
92-
if (!resolveIdentifier(id, identifier, config)) {
93-
diagnostics.push({ from, to, severity: 'error', message: `${node.node.name} <code>${identifier}</code> not found` });
94-
}
95-
96-
break;
65+
break;
66+
}
67+
case Property:
68+
case Method: {
69+
const leftArgument = node.node.parent?.firstChild?.node;
70+
const types = Array.from(await resolveTypes(state, leftArgument, config));
71+
identifier = state.sliceDoc(from, to);
72+
if (!types.find(type => resolveIdentifier(id, identifier, config.types?.[type]))) {
73+
diagnostics.push({ from, to, severity: 'error', message: `${node.name} <code>${identifier}</code> not found in <code>${types.join('|')}</code>` });
74+
}
75+
break;
76+
}
77+
case Variable:
78+
case Function: {
79+
identifier = state.sliceDoc(from, node.node.firstChild ? node.node.firstChild.from - 1 : to);
80+
if (!resolveIdentifier(id, identifier, config)) {
81+
diagnostics.push({ from, to, severity: 'error', message: `${node.node.name} <code>${identifier}</code> not found` });
9782
}
98-
case BinaryExpression: {
99-
const operatorNode = node.node.getChild(OperatorKeyword);
100-
if (operatorNode) {
101-
const operator = state.sliceDoc(operatorNode.from, operatorNode.to);
102-
const leftArgument = node.node.firstChild;
103-
const rightArgument = node.node.lastChild;
104-
if (operator === 'in') {
105-
const types = resolveTypes(state, rightArgument, config);
106-
if (!types.has(ELScalar.Array)) {
107-
diagnostics.push({ from: rightArgument.from, to: rightArgument.to, severity: 'error', message: `<code>${ELScalar.Array}</code> expected, got <code>${[...types].join('|')}</code>` });
108-
}
109-
} else if (["contains", "starts with", "ends with", "matches"].includes(operator)) {
110-
// Both sides must be string
111-
const leftTypes = resolveTypes(state, leftArgument, config);
112-
const rightTypes = resolveTypes(state, rightArgument, config);
113-
if (!leftTypes.has(ELScalar.String)) {
114-
diagnostics.push({ from: leftArgument.from, to: leftArgument.to, severity: 'error', message: `<code>string</code> expected, got <code>${[...leftTypes].join('|')}</code>` });
115-
}
116-
if (!rightTypes.has(ELScalar.String)) {
117-
diagnostics.push({ from: rightArgument.from, to: rightArgument.to, severity: 'error', message: `<code>string</code> expected, got <code>${[...rightTypes].join('|')}</code>` });
118-
}
83+
break;
84+
}
85+
case BinaryExpression: {
86+
const operatorNode = node.node.getChild(OperatorKeyword);
87+
if (operatorNode) {
88+
const operator = state.sliceDoc(operatorNode.from, operatorNode.to);
89+
const leftArgument = node.node.firstChild;
90+
const rightArgument = node.node.lastChild;
91+
if (operator === 'in') {
92+
const types = await resolveTypes(state, rightArgument, config);
93+
if (!types.has(ELScalar.Array)) {
94+
diagnostics.push({ from: rightArgument.from, to: rightArgument.to, severity: 'error', message: `<code>${ELScalar.Array}</code> expected, got <code>${[...types].join('|')}</code>` });
95+
}
96+
} else if (["contains", "starts with", "ends with", "matches"].includes(operator)) {
97+
// Both sides must be string
98+
const leftTypes = await resolveTypes(state, leftArgument, config);
99+
const rightTypes = await resolveTypes(state, rightArgument, config);
100+
if (!leftTypes.has(ELScalar.String)) {
101+
diagnostics.push({ from: leftArgument.from, to: leftArgument.to, severity: 'error', message: `<code>string</code> expected, got <code>${[...leftTypes].join('|')}</code>` });
102+
}
103+
if (!rightTypes.has(ELScalar.String)) {
104+
diagnostics.push({ from: rightArgument.from, to: rightArgument.to, severity: 'error', message: `<code>string</code> expected, got <code>${[...rightTypes].join('|')}</code>` });
119105
}
120106
}
121-
break;
122107
}
108+
break;
109+
}
123110
}
124-
125111
if (identifier && node.node.parent?.type.isError) {
126112
diagnostics.push({ from, to, severity: 'error', message: `Unexpected identifier <code>${identifier}</code>` });
127113
}
128-
});
114+
};
129115

130-
diagnostics.forEach(d => {
131-
d.renderMessage = () => {
132-
const span = document.createElement('span');
133-
span.innerHTML = d.message;
134-
return span;
135-
};
136-
});
137-
138-
return diagnostics;
116+
return (async () => {
117+
const cursor = syntaxTree(state).cursor();
118+
while (cursor.next()) {
119+
await processNode(cursor);
120+
}
121+
diagnostics.forEach(d => {
122+
d.renderMessage = () => {
123+
const span = document.createElement('span');
124+
span.innerHTML = d.message;
125+
return span;
126+
};
127+
});
128+
return diagnostics;
129+
})();
139130
};
140131

141132
export const expressionLanguageLinter = linter(view => expressionLanguageLinterSource(view.state));

0 commit comments

Comments
 (0)