Skip to content

Commit 0c678a0

Browse files
authored
chore(compass-editor): validation autocompleter with json COMPASS-6572 (#4157)
1 parent 5479946 commit 0c678a0

File tree

9 files changed

+368
-25
lines changed

9 files changed

+368
-25
lines changed

packages/compass-editor/src/codemirror/ace-compat-autocompleter.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import type {
44
CompletionResult,
55
CompletionSource,
66
} from '@codemirror/autocomplete';
7-
import { completeAnyWord, ifIn } from '@codemirror/autocomplete';
87
import { snippetCompletion } from '@codemirror/autocomplete';
9-
import { syntaxTree } from '@codemirror/language';
108
import type { CompletionResult as MongoDBCompletionResult } from '../autocompleter';
119
import { wrapField } from '../autocompleter';
10+
import { resolveTokenAtCursor } from './utils';
11+
import type { Token } from './utils';
1212

1313
export function mapMongoDBCompletionToCodemirrorCompletion(
1414
completion: MongoDBCompletionResult,
@@ -33,17 +33,11 @@ export function mapMongoDBCompletionToCodemirrorCompletion(
3333

3434
type Prefix = { from: number; to: number; text: string };
3535

36-
export const completeWordsInString = ifIn(['String'], completeAnyWord);
37-
3836
/**
3937
* Ace autocompleter "valid" identifier regex
4038
*/
4139
export const ID_REGEX = /[a-zA-Z_0-9$\-\u00A2-\u2000\u2070-\uFFFF]+/;
4240

43-
export function resolveTokenAtCursor(context: CompletionContext) {
44-
return syntaxTree(context.state).resolveInner(context.pos, -1);
45-
}
46-
4741
export function createCompletionResultForIdPrefix({
4842
prefix,
4943
completions,
@@ -65,7 +59,7 @@ export function createCompletionResultForIdPrefix({
6559

6660
type AceCompatCompletionSource = (options: {
6761
prefix: Prefix;
68-
token: ReturnType<typeof resolveTokenAtCursor>;
62+
token: Token;
6963
context: CompletionContext;
7064
}) => ReturnType<CompletionSource>;
7165

packages/compass-editor/src/codemirror/document-autocompleter.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,8 @@
1-
import type {
2-
CompletionContext,
3-
CompletionSource,
4-
} from '@codemirror/autocomplete';
5-
import { syntaxTree } from '@codemirror/language';
6-
import { completeAnyWord, ifIn } from '@codemirror/autocomplete';
1+
import type { CompletionSource } from '@codemirror/autocomplete';
72
import { completer, wrapField } from '../autocompleter';
83
import { languageName } from '../json-editor';
9-
10-
const completeWordsInString = ifIn(['String'], completeAnyWord);
11-
12-
function resolveTokenAtCursor(context: CompletionContext) {
13-
return syntaxTree(context.state).resolveInner(context.pos, -1);
14-
}
15-
16-
type Token = ReturnType<typeof resolveTokenAtCursor>;
4+
import { resolveTokenAtCursor, completeWordsInString } from './utils';
5+
import type { Token } from './utils';
176

187
function isJSONPropertyName(token: Token): boolean {
198
return (

packages/compass-editor/src/codemirror/query-autocompleter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import type { CompletionSource } from '@codemirror/autocomplete';
22
import type { CompleteOptions } from '../autocompleter';
33
import { completer } from '../autocompleter';
44
import {
5-
completeWordsInString,
65
createAceCompatAutocompleter,
76
createCompletionResultForIdPrefix,
87
} from './ace-compat-autocompleter';
8+
import { completeWordsInString } from './utils';
99

1010
/**
1111
* Autocompleter for the document object, only autocompletes field names in the

packages/compass-editor/src/codemirror/stage-autocompleter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import type { CompletionSource } from '@codemirror/autocomplete';
22
import type { CompleteOptions } from '../autocompleter';
33
import { completer } from '../autocompleter';
44
import {
5-
completeWordsInString,
65
createAceCompatAutocompleter,
76
createCompletionResultForIdPrefix,
87
} from './ace-compat-autocompleter';
8+
import { completeWordsInString } from './utils';
99
import { createQueryAutocompleter } from './query-autocompleter';
1010

1111
/**
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { expect } from 'chai';
2+
import { languages } from '../json-editor';
3+
import { EditorView } from '@codemirror/view';
4+
import { getAncestryOfToken, resolveTokenAtCursor } from './utils';
5+
import { CompletionContext } from '@codemirror/autocomplete';
6+
7+
const parseDocument = (_doc = '') => {
8+
const pos = _doc.indexOf('${}');
9+
const doc = _doc.replace('${}', '');
10+
const editor = new EditorView({
11+
doc,
12+
extensions: [languages.javascript()],
13+
});
14+
const context = new CompletionContext(editor.state, pos, false);
15+
const token = resolveTokenAtCursor(context);
16+
return { token, document: context.state.sliceDoc(0) };
17+
};
18+
19+
describe('codemirror utils', function () {
20+
describe('getAncestryOfToken', function () {
21+
const useCases = {
22+
'simple object': {
23+
doc: '{ a: ${} }',
24+
expected: ['a'],
25+
},
26+
'nested object': {
27+
doc: '{ "a": { "b": ${} } }',
28+
expected: ['a', 'b'],
29+
},
30+
'nested with array and object': {
31+
doc: '{ a: { b: [1, 2, { c: ${} }] } }',
32+
expected: ['a', 'b', '[2]', 'c'],
33+
},
34+
'property in no quotes': {
35+
doc: '{ nam${} }',
36+
expected: [],
37+
},
38+
'property in single quotes': {
39+
doc: "{ $jsonSchema: { 'nam'${} } }",
40+
expected: ['$jsonSchema'],
41+
},
42+
'property in double quotes': {
43+
doc: "{ $jsonSchema: { properties: { 'nam'${} } } }",
44+
expected: ['$jsonSchema', 'properties'],
45+
},
46+
'numeric value as a property name': {
47+
doc: '{ name: { address: { 123${} } } }',
48+
expected: ['name', 'address'],
49+
},
50+
51+
'boolean value as a property name': {
52+
doc: '{ name: { address: { true${} } } }',
53+
expected: ['name', 'address'],
54+
},
55+
56+
'value in an object': {
57+
doc: '{ name: nam${} }',
58+
expected: ['name'],
59+
},
60+
'string value in an object': {
61+
doc: '{ name: "nam"${} }',
62+
expected: ['name'],
63+
},
64+
};
65+
for (const [name, { doc, expected }] of Object.entries(useCases)) {
66+
it(`get ancestry - ${name}`, function () {
67+
const { token, document } = parseDocument(doc);
68+
const ancestry = getAncestryOfToken(token, document);
69+
expect(ancestry).to.deep.equal(expected);
70+
});
71+
}
72+
});
73+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { completeAnyWord, ifIn } from '@codemirror/autocomplete';
2+
import type { CompletionContext } from '@codemirror/autocomplete';
3+
import { syntaxTree } from '@codemirror/language';
4+
5+
export const completeWordsInString = ifIn(['String'], completeAnyWord);
6+
7+
export function resolveTokenAtCursor(context: CompletionContext) {
8+
return syntaxTree(context.state).resolveInner(context.pos - 1);
9+
}
10+
11+
export type Token = ReturnType<typeof resolveTokenAtCursor>;
12+
13+
/**
14+
* Returns the list of possible ancestors of the token.
15+
*/
16+
const getAncestorList = (
17+
node: Token | undefined | null,
18+
doc: string
19+
): string[] => {
20+
const ancestors: string[] = [];
21+
22+
if (!node?.parent) {
23+
return ancestors;
24+
}
25+
26+
// In order to find the index of an node in an array, we have to check for the parent.
27+
// And then use siblings to find the index. We can't use the node.parent as it does not
28+
// contain all the childern and reference to the current node.
29+
if (['ArrayExpression', 'Array'].includes(node.parent.name)) {
30+
let prevSibling = node.prevSibling;
31+
let index = 0;
32+
while (prevSibling) {
33+
if (![':', ',', '['].includes(prevSibling.name)) {
34+
index++;
35+
}
36+
prevSibling = prevSibling.prevSibling;
37+
}
38+
ancestors.push(`[${index}]`);
39+
}
40+
if (node.name === 'Property' && node.firstChild) {
41+
const { from, to } = node.firstChild;
42+
const ancestor = doc.slice(from, to).replace(/"/g, '').replace(/'/g, '');
43+
ancestors.push(ancestor);
44+
}
45+
return ancestors.concat(getAncestorList(node.parent, doc));
46+
};
47+
48+
/**
49+
* Walks the syntax tree from the token and returns a list
50+
* of all the ancestors of the token. The array indexes are
51+
* represented as `[0-9]`.
52+
*/
53+
export function getAncestryOfToken(token: Token, document: string): string[] {
54+
// If we are at the property name, we ignore it to correctly
55+
// find the parent.
56+
const isAutocompletingPropertyName =
57+
['ObjectExpression', 'PropertyDefinition'].includes(token.name) ||
58+
(token.parent?.parent?.name === 'ObjectExpression' && !token.prevSibling);
59+
// We reverse the list as we want to start from the root.
60+
const list = getAncestorList(
61+
isAutocompletingPropertyName ? token.parent?.parent : token,
62+
document
63+
).reverse();
64+
return list;
65+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { expect } from 'chai';
2+
import { createValidationAutocompleter } from './validation-autocompleter';
3+
import { setupCodemirrorCompleter } from '../../test/completer';
4+
5+
describe('validation autocompleter', function () {
6+
const { getCompletions, cleanup } = setupCodemirrorCompleter(
7+
createValidationAutocompleter
8+
);
9+
10+
after(cleanup);
11+
12+
it('returns $jsonSchema by default', function () {
13+
const completions = getCompletions('');
14+
expect(completions.map((x) => x.label)).to.deep.equal(['$jsonSchema']);
15+
});
16+
17+
it('returns query operators when completing at root', function () {
18+
const completions = getCompletions('{ $');
19+
expect(completions).to.have.lengthOf(33);
20+
});
21+
22+
it('returns field names when autocompleting required', function () {
23+
const completions = getCompletions('{ $jsonSchema: { required: ["i', {
24+
fields: ['_id', 'name', 'age'],
25+
});
26+
expect(completions.map((x) => x.label)).to.deep.equal([
27+
'_id',
28+
'name',
29+
'age',
30+
]);
31+
});
32+
33+
it('returns bson type when autocompleting bsonType as a string', function () {
34+
const completions = getCompletions('{ $jsonSchema: { bsonType: "a');
35+
expect(completions).to.have.lengthOf(19);
36+
});
37+
38+
it('returns bson type when autocompleting bsonType as a array', function () {
39+
const completions = getCompletions('{ $jsonSchema: { bsonType: ["a');
40+
expect(completions).to.have.lengthOf(19);
41+
});
42+
43+
it('returns field names when autocompleting properties', function () {
44+
const completions = getCompletions('{ $jsonSchema: { properties: { "a', {
45+
fields: ['_id', 'name', 'age'],
46+
});
47+
expect(completions.map((x) => x.label)).to.deep.equal([
48+
'_id',
49+
'name',
50+
'age',
51+
]);
52+
});
53+
});

0 commit comments

Comments
 (0)