Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,57 +1,15 @@
import type {
Completion,
CompletionContext,
CompletionResult,
CompletionSource,
} from '@codemirror/autocomplete';
import { snippetCompletion } from '@codemirror/autocomplete';
import type { CompletionResult as MongoDBCompletionResult } from '../autocompleter';
import { wrapField } from '../autocompleter';
import { resolveTokenAtCursor } from './utils';
import {
mapMongoDBCompletionToCodemirrorCompletion,
resolveTokenAtCursor,
} from './utils';
import type { Token } from './utils';

export function mapMongoDBCompletionToCodemirrorCompletion(
completion: MongoDBCompletionResult,
escape: 'always' | 'never' | 'invalid' = 'invalid'
): Completion {
const cmCompletion = {
label: completion.value,
apply:
escape === 'never'
? completion.value
: wrapField(completion.value, escape === 'always'),
detail: completion.meta?.startsWith('field') ? 'field' : completion.meta,
type: completion.meta?.startsWith('field') ? 'field' : 'method',
info() {
if (!completion.description) {
return null;
}

const infoNode = document.createElement('div');
infoNode.classList.add('completion-info');
infoNode.addEventListener('mousedown', (evt) => {
// If we are clicking a link inside the info block, we have to prevent
// default browser behavior that will remove the focus from the editor
// and cause the autocompleter to dissapear before browser handles the
// actual click. This is very similar to how codemirror handles clicks
// on the list items
// @see {@link https://github.com/codemirror/autocomplete/blob/82480a7d51d60ad933808e42f6189d841a5a6bc8/src/tooltip.ts#L96-L97}
if ((evt.target as HTMLElement).nodeName === 'A') {
evt.preventDefault();
}
});
infoNode.innerHTML = completion.description;
return infoNode;
},
};

if (completion.snippet) {
return snippetCompletion(completion.snippet, cmCompletion);
}

return cmCompletion;
}

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

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,17 +104,31 @@ describe('query history autocompleter', function () {
it('returns combined completions that match the prefix of the field', async function () {
const completions = await getCompletions('scor', {
savedQueries,
options: undefined,
options: {
fields: ['score', 'scoreValue', 'scoreCategory'],
},
queryProperty: 'filter',
onApply: mockOnApply,
theme: 'light',
});
const queryHistoryCompletions = getQueryHistoryAutocompletions(completions);

expect(queryHistoryCompletions).to.have.lengthOf(2);
expect(completions).to.have.length.greaterThan(
queryHistoryCompletions.length
);
expect(completions).to.have.length(5);
});

it('returns combined completions that match the prefix of the value', async function () {
const completions = await getCompletions('{ scoreCategory: legac', {
savedQueries,
options: undefined,
queryProperty: 'filter',
onApply: mockOnApply,
theme: 'light',
});
const queryHistoryCompletions = getQueryHistoryAutocompletions(completions);

expect(queryHistoryCompletions).to.have.lengthOf(0);
expect(completions).to.have.length(3);
});

it('returns completions that match with multiple fields', async function () {
Expand Down Expand Up @@ -151,9 +165,8 @@ describe('query history autocompleter', function () {
});

it('completes regular query autocompletion items', async function () {
// 'foo' matches > 45 methods and fields in the query autocompletion.
const completions = (
await getCompletions('foo', {
await getCompletions('$a', {
savedQueries,
options: undefined,
queryProperty: '',
Expand All @@ -162,7 +175,8 @@ describe('query history autocompleter', function () {
})
).filter(({ type }) => type !== 'favorite' && type !== 'query-history');

expect(completions).to.have.length.greaterThan(40);
// $all and $and.
expect(completions).to.have.lengthOf(2);
});

it('completes fields inside a string', async function () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,43 @@ import { expect } from 'chai';
import { createQueryAutocompleter } from './query-autocompleter';
import { setupCodemirrorCompleter } from '../../test/completer';

const pineFields = ['pineapple', 'pine tree', 'pinecone', 'apple'];

describe('query autocompleter', function () {
const { getCompletions, cleanup } = setupCodemirrorCompleter(
createQueryAutocompleter
);

after(cleanup);

it('returns all completions when current token is vaguely matches identifier', async function () {
expect(await getCompletions('foo')).to.have.lengthOf(49);
it('returns completions matching the prefix of a field', async function () {
expect(await getCompletions('foo')).to.have.lengthOf(0);

expect(
await getCompletions('{ pine', {
fields: pineFields,
})
).to.have.lengthOf(3);
});

it('does not complete field names when the prefix is a value', async function () {
expect(
await getCompletions('{ pineapple: pine', {
fields: pineFields,
})
).to.have.lengthOf(0);
});

it('returns completions matching the prefix of a query operator', async function () {
expect(
await getCompletions('{ $i', {
fields: pineFields,
})
).to.have.lengthOf(1);
});

it('returns completions matching the prefix of a bson value', async function () {
expect(await getCompletions('{ pineapple: legacy')).to.have.lengthOf(3);
});

it("doesn't return anything when not matching identifier", async function () {
Expand All @@ -25,22 +53,40 @@ describe('query autocompleter', function () {
).to.deep.eq(['bar', '1', 'buz', '2', 'foo']);
});

it('does not return completions when inside a comment', async function () {
expect(
await getCompletions('// pine', { fields: pineFields })
).to.have.lengthOf(0);
expect(
await getCompletions('/* pine */', { fields: pineFields })
).to.have.lengthOf(0);
});

it('completes bson operators in an array', async function () {
expect(
await getCompletions('{ field: [pin', { fields: pineFields })
).to.have.lengthOf(0);
expect(
await getCompletions('{ field: [Obj', { fields: pineFields })
).to.have.lengthOf(1);
});

it('escapes field names that are not valid identifiers', async function () {
expect(
(
await getCompletions('{ $m', {
await getCompletions('{ in', {
fields: [
'field name with spaces',
'dots.and+what@not',
'field name with spaces in it',
'dots.and+what@not.in.it',
'quotes"in"quotes',
],
} as any)
)
.filter((completion) => completion.detail?.startsWith('field'))
.map((completion) => completion.apply)
).to.deep.eq([
'"field name with spaces"',
'"dots.and+what@not"',
'"field name with spaces in it"',
'"dots.and+what@not.in.it"',
'"quotes\\"in\\"quotes"',
]);
});
Expand Down
65 changes: 49 additions & 16 deletions packages/compass-editor/src/codemirror/query-autocompleter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,62 @@ import type { CompletionSource } from '@codemirror/autocomplete';
import type { CompletionOptions } from '../autocompleter';
import { completer } from '../autocompleter';
import {
createAceCompatAutocompleter,
createCompletionResultForIdPrefix,
} from './ace-compat-autocompleter';
import { completeWordsInString } from './utils';
resolveTokenAtCursor,
completeWordsInString,
mapMongoDBCompletionToCodemirrorCompletion,
isPropertyValue,
} from './utils';

/**
* Autocompleter for the document object, only autocompletes field names in the
* appropriate format (either escaped or not) both for javascript and json modes
* Autocompleter for MongoDB queries, completes field names, query
* operators, and bson values based on the context.
*/
export const createQueryAutocompleter = (
options: Pick<CompletionOptions, 'fields' | 'serverVersion'> = {}
): CompletionSource => {
const completions = completer('', {
meta: ['query', 'bson', 'bson-legacy-uuid', 'field:identifier'],
const fieldCompletions = completer('', {
meta: ['query', 'field:identifier'],
...options,
});

return createAceCompatAutocompleter({
String({ context }) {
return completeWordsInString(context);
},
IdentifierLike({ prefix }) {
return createCompletionResultForIdPrefix({ prefix, completions });
},
const valueCompletions = completer('', {
meta: ['bson', 'bson-legacy-uuid'],
...options,
});

return (context) => {
const token = resolveTokenAtCursor(context);

// Don't autocomplete while in a comment.
if (['BlockComment', 'LineComment'].includes(token.type.name)) {
return null;
}
const prefix = context.state
.sliceDoc(token.from, context.pos)
.replace(/^("|')/, '');

if (!prefix) {
return null;
}

const isValueCompletion = isPropertyValue(token);

if (isValueCompletion && token.type.name === 'String') {
return completeWordsInString(context) ?? null;
}

const completions = isValueCompletion ? valueCompletions : fieldCompletions;

return {
from: token.from,
to: token.to,
options: completions
.filter((completion) =>
completion.value.toLowerCase().includes(prefix.toLowerCase())
)
.map((completion) =>
mapMongoDBCompletionToCodemirrorCompletion(completion)
),
filter: false,
};
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ describe('createStageAutocompleter', function () {
const completions = getCompletions('{ a', { fields });
expect(meta(await completions)).to.deep.eq([
'bson',
'bson-legacy-uuid',
'conv',
'expr:arith',
'expr:set',
Expand All @@ -79,17 +80,29 @@ describe('createStageAutocompleter', function () {
]);
});

it('returns query completions for $match stage', async function () {
const completions = getCompletions('{ a', {
it('returns query completions for $match stage field', async function () {
const completions = getCompletions('{ me', {
fields,
stageOperator: '$match',
});
expect(meta(await completions)).to.deep.eq([
'bson',
'bson-legacy-uuid',
'query',
'field',
]);
// na[me] and $com[me]nt
expect(meta(await completions)).to.deep.eq(['query', 'field']);
});

it('returns query completions for $match stage value', async function () {
const bsonCompletions = getCompletions('{ pineapple: me', {
fields,
stageOperator: '$match',
});
// Ti[me]stamp
expect(meta(await bsonCompletions)).to.deep.eq(['bson']);

const completions = getCompletions('{ pineapple: le', {
fields,
stageOperator: '$match',
});
// [le]gacy UUIDs match.
expect(meta(await completions)).to.deep.eq(['bson-legacy-uuid']);
});

['$project', '$group'].forEach((stageOperator) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const createStageAutocompleter = ({
'expr:*',
'conv',
'bson',
'bson-legacy-uuid',
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

drive-by. I totally missed this in COMPASS-9690 🙈

'field:identifier',
...(['$project', '$group'].includes(stageOperator ?? '')
? (['accumulator', 'accumulator:*'] as const)
Expand Down
Loading
Loading