Skip to content

Commit ed8f90b

Browse files
authored
chore(compass-editor): add agg autocompleter for codemirror COMPASS-6575 (#4165)
* chore(compass-editor): add agg autocompleter for codemirror * chore(compass-editor): fix types * chore(compass-editor): fix resolveTokenAtCursor
1 parent 0c678a0 commit ed8f90b

File tree

7 files changed

+194
-4
lines changed

7 files changed

+194
-4
lines changed

configs/mocha-config-compass/register/jsdom-global-register.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,7 @@ if (!globalThis.IntersectionObserver) {
5555
// jsdom doesn't override classes that already exist in global scope
5656
// https://github.com/jsdom/jsdom/issues/3331
5757
globalThis.EventTarget = window.EventTarget;
58+
59+
Range.prototype.getClientRects = function () {
60+
return [];
61+
};

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,8 @@ export function createAceCompatAutocompleter(completers: {
9090

9191
const opts = { context, prefix, token };
9292

93-
if (token.type.name === 'String') {
94-
return completers.String?.(opts) ?? null;
93+
if (token.type.name === 'String' && completers.String) {
94+
return completers.String(opts) ?? null;
9595
}
9696

9797
return completers.IdentifierLike?.(opts) ?? null;
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { expect } from 'chai';
2+
import { STAGE_OPERATOR_NAMES } from '@mongodb-js/mongodb-constants';
3+
import { createAggregationAutocompleter } from './aggregation-autocompleter';
4+
import { setupCodemirrorCompleter } from '../../test/completer';
5+
6+
describe('query autocompleter', function () {
7+
const { getCompletions, applySnippet, cleanup } = setupCodemirrorCompleter(
8+
createAggregationAutocompleter
9+
);
10+
11+
after(cleanup);
12+
13+
context('when autocompleting outside of stage', function () {
14+
context('with empty pipeline', function () {
15+
it('should return stages', function () {
16+
const completions = getCompletions('[{ $');
17+
18+
expect(
19+
completions.map((completion) => completion.label).sort()
20+
).to.deep.eq([...STAGE_OPERATOR_NAMES].sort());
21+
});
22+
});
23+
24+
context('with other stages in the pipeline', function () {
25+
it('should return stages', function () {
26+
const completions = getCompletions('[{$match:{foo: 1}},{$');
27+
28+
expect(
29+
completions.map((completion) => completion.label).sort()
30+
).to.deep.eq([...STAGE_OPERATOR_NAMES].sort());
31+
});
32+
});
33+
34+
context('inside block', function () {
35+
it('should not suggest blocks in snippets', function () {
36+
const completions = getCompletions(`[{ /** comment */ $`);
37+
38+
completions.forEach((completion) => {
39+
const snippet = applySnippet(completion);
40+
expect(snippet).to.match(
41+
/^[^{]/,
42+
'expected snippet NOT to start with an opening bracket'
43+
);
44+
});
45+
});
46+
});
47+
48+
context('outside block', function () {
49+
it('should have blocks in snippets', function () {
50+
const completions = getCompletions(`[{ $match: {foo: 1} }, $`);
51+
52+
completions.forEach((completion) => {
53+
const snippet = applySnippet(completion);
54+
expect(snippet).to.match(
55+
/^{/,
56+
'expected snippet to start with an opening bracket'
57+
);
58+
});
59+
});
60+
});
61+
});
62+
63+
context('when autocompleting inside the stage', function () {
64+
it('should return stage completer results', function () {
65+
const completions = getCompletions('[{$bucket: { _id: "$', {
66+
fields: [{ name: 'foo' }, { name: 'bar' }],
67+
});
68+
69+
expect(completions.map((completion) => completion.label)).to.deep.eq([
70+
'$foo',
71+
'$bar',
72+
]);
73+
});
74+
});
75+
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import type { EditorState } from '@codemirror/state';
2+
import { STAGE_OPERATOR_NAMES } from '@mongodb-js/mongodb-constants';
3+
import type { CompleteOptions } from '../autocompleter';
4+
import { completer } from '../autocompleter';
5+
import type { Token } from './utils';
6+
import { createCompletionResultForIdPrefix } from './ace-compat-autocompleter';
7+
import { createAceCompatAutocompleter } from './ace-compat-autocompleter';
8+
import { createStageAutocompleter } from './stage-autocompleter';
9+
10+
const StageOperatorNames = new Set(STAGE_OPERATOR_NAMES as string[]);
11+
12+
function* parents(token: Token) {
13+
let parent: Token | null = token;
14+
while ((parent = parent.parent)) {
15+
yield parent;
16+
}
17+
}
18+
19+
function removeQuotes(str: string) {
20+
return str.replace(/(^('|")|('|")$)/g, '');
21+
}
22+
23+
// lezer tokens are immutable, we check position in syntax tree to make sure we
24+
// are looking at the same token
25+
function isTokenEqual(a: Token, b: Token) {
26+
return a.from === b.from && a.to === b.to;
27+
}
28+
29+
function getPropertyNameFromPropertyToken(
30+
editorState: EditorState,
31+
propertyToken: Token
32+
): string {
33+
if (!propertyToken.firstChild) {
34+
return '';
35+
}
36+
return removeQuotes(getTokenText(editorState, propertyToken.firstChild));
37+
}
38+
39+
function padLines(str: string, pad = ' ') {
40+
return str
41+
.split('\n')
42+
.map((line) => `${pad}${line}`)
43+
.join('\n');
44+
}
45+
46+
function getTokenText(editorState: EditorState, token: Token) {
47+
return editorState.sliceDoc(token.from, token.to);
48+
}
49+
50+
function getStageNameForToken(
51+
editorState: EditorState,
52+
token: Token
53+
): string | null {
54+
for (const parent of parents(token)) {
55+
if (parent.name === 'Property') {
56+
const propertyName = getPropertyNameFromPropertyToken(
57+
editorState,
58+
parent
59+
);
60+
if (
61+
parent.firstChild &&
62+
// We are inside a stage, but not right at the stage name token (we
63+
// don't want to autocomplete as stage while the stage is being typed)
64+
!isTokenEqual(parent.firstChild, token) &&
65+
StageOperatorNames.has(propertyName)
66+
) {
67+
return propertyName;
68+
}
69+
}
70+
}
71+
return null;
72+
}
73+
74+
export function createAggregationAutocompleter(
75+
options: Pick<CompleteOptions, 'fields' | 'serverVersion'> = {}
76+
) {
77+
const stageAutocompletions = completer('', { ...options, meta: ['stage'] });
78+
79+
return createAceCompatAutocompleter({
80+
IdentifierLike({ context, prefix, token }) {
81+
const stageOperator = getStageNameForToken(context.state, token);
82+
83+
if (stageOperator) {
84+
return createStageAutocompleter({ stageOperator, ...options })(context);
85+
}
86+
87+
const isInsideBlock =
88+
token.name === 'PropertyName' || token.parent?.name === 'Property';
89+
90+
return createCompletionResultForIdPrefix({
91+
prefix,
92+
completions: stageAutocompletions.map((completion) => {
93+
const opName = completion.value;
94+
return {
95+
...completion,
96+
...(completion.snippet && {
97+
snippet: !isInsideBlock
98+
? `{\n${padLines(`${opName}: ${completion.snippet}`)}\n}`
99+
: `${opName}: ${completion.snippet}`,
100+
}),
101+
};
102+
}),
103+
});
104+
},
105+
});
106+
}

packages/compass-editor/src/codemirror/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { syntaxTree } from '@codemirror/language';
55
export const completeWordsInString = ifIn(['String'], completeAnyWord);
66

77
export function resolveTokenAtCursor(context: CompletionContext) {
8-
return syntaxTree(context.state).resolveInner(context.pos - 1);
8+
return syntaxTree(context.state).resolveInner(context.pos, -1);
99
}
1010

1111
export type Token = ReturnType<typeof resolveTokenAtCursor>;

packages/compass-editor/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ export { createDocumentAutocompleter } from './codemirror/document-autocompleter
1818
export { createValidationAutocompleter } from './codemirror/validation-autocompleter';
1919
export { createQueryAutocompleter } from './codemirror/query-autocompleter';
2020
export { createStageAutocompleter } from './codemirror/stage-autocompleter';
21+
export { createAggregationAutocompleter } from './codemirror/aggregation-autocompleter';

packages/compass-editor/test/completer.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,5 +90,9 @@ export const setupCodemirrorCompleter = <
9090
editor.destroy();
9191
el.remove();
9292
};
93-
return { getCompletions, cleanup };
93+
const applySnippet = (completion: any) => {
94+
completion.apply(editor, null, 0, editor.state.doc.length);
95+
return editor.state.sliceDoc(0);
96+
};
97+
return { getCompletions, cleanup, applySnippet };
9498
};

0 commit comments

Comments
 (0)