Skip to content

Commit adce0ac

Browse files
authored
fix: Improve auto-complete behavior for aliases and maps (#1987)
## Summary This PR improves auto-complete in the following ways 1. Auto-complete suggestions will not appear after `AS`, since it is assumed that a user will not want to type an existing column or function name as a column alias 2. Accepting an auto-complete suggestion will replace characters after the cursor if they match the accepted suggestion. This is nice when, for example, I have typed `ResourceAttributes[]` and my cursor is before the `]` - accepting a suggestion will now replace the trailing `]` instead of leaving it be (in which case it would be duplicated after inserting the suggestion). ### Screenshots or video https://github.com/user-attachments/assets/9577393c-6bfa-410b-b5ba-2ba6b00bc26b ### How to test locally or on Vercel This can be tested in the preview environment. ### References - Linear Issue: Closes HDX-2612 - Related PRs:
1 parent a45b3cf commit adce0ac

File tree

3 files changed

+181
-6
lines changed

3 files changed

+181
-6
lines changed

.changeset/smooth-poems-film.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperdx/app": patch
3+
---
4+
5+
fix: Improve auto-complete behavior for aliases and maps
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { Completion, CompletionContext } from '@codemirror/autocomplete';
2+
import { EditorState } from '@codemirror/state';
3+
4+
import { createIdentifierCompletionSource } from '../utils';
5+
6+
const TEST_COMPLETIONS: Completion[] = [
7+
{ label: 'column1', type: 'variable' },
8+
{ label: 'column2', type: 'variable' },
9+
{ label: 'SELECT', type: 'keyword' },
10+
{ label: 'count', type: 'function', apply: 'count(' },
11+
];
12+
13+
/**
14+
* Simulates what CodeMirror shows the user: calls the completion source,
15+
* extracts the typed prefix (text from `result.from` to cursor), and
16+
* filters options by case-insensitive prefix match on the label.
17+
*
18+
* Returns the filtered labels, or null when the source suppresses completions.
19+
*/
20+
function getSuggestionLabels(
21+
doc: string,
22+
{
23+
pos,
24+
explicit = false,
25+
completions = TEST_COMPLETIONS,
26+
}: { pos?: number; explicit?: boolean; completions?: Completion[] } = {},
27+
): string[] | null {
28+
const source = createIdentifierCompletionSource(completions);
29+
const state = EditorState.create({ doc });
30+
const cursorPos = pos ?? doc.length;
31+
const context = new CompletionContext(state, cursorPos, explicit);
32+
const result = source(context);
33+
34+
if (result == null) return null;
35+
36+
const typedPrefix = doc.slice(result.from, cursorPos).toLowerCase();
37+
return result.options
38+
.filter(o => o.label.toLowerCase().startsWith(typedPrefix))
39+
.map(o => o.label);
40+
}
41+
42+
/**
43+
* Returns the range [from, to] that CodeMirror would replace when the user
44+
* accepts a suggestion. This lets us verify that the entire identifier
45+
* (including any trailing characters after the cursor) gets replaced.
46+
*/
47+
function getReplacementRange(
48+
doc: string,
49+
pos: number,
50+
): { from: number; to: number } | null {
51+
const source = createIdentifierCompletionSource(TEST_COMPLETIONS);
52+
const state = EditorState.create({ doc });
53+
const context = new CompletionContext(state, pos, false);
54+
const result = source(context);
55+
if (result == null) return null;
56+
return { from: result.from, to: result.to ?? pos };
57+
}
58+
59+
describe('Auto-Complete source', () => {
60+
it.each([
61+
{ doc: 'SELECT col', expected: ['column1', 'column2'] },
62+
{ doc: 'sel', expected: ['SELECT'] },
63+
{ doc: 'SELECT xyz', expected: [] },
64+
{ doc: 'SELECT count(*) AS total, col', expected: ['column1', 'column2'] },
65+
])('suggests matching completions for "$doc"', ({ doc, expected }) => {
66+
expect(getSuggestionLabels(doc)).toEqual(expected);
67+
});
68+
69+
it('returns all options when prefix is empty (Ctrl+Space)', () => {
70+
const labels = getSuggestionLabels('', { explicit: true });
71+
expect(labels).toEqual(['column1', 'column2', 'SELECT', 'count']);
72+
});
73+
74+
it('returns null when there is no prefix and no Ctrl+Space', () => {
75+
expect(getSuggestionLabels('')).toBeNull();
76+
});
77+
78+
it.each([
79+
{
80+
name: 'dots and brackets',
81+
doc: "ResourceAttributes['service.name']",
82+
completions: [
83+
{ label: "ResourceAttributes['service.name']", type: 'variable' },
84+
],
85+
expected: ["ResourceAttributes['service.name']"],
86+
},
87+
{
88+
name: '$ macros',
89+
doc: 'WHERE $__date',
90+
completions: [
91+
{ label: '$__dateFilter', type: 'variable' },
92+
{ label: 'column1', type: 'variable' },
93+
],
94+
expected: ['$__dateFilter'],
95+
},
96+
{
97+
name: 'curly braces and colons',
98+
doc: 'WHERE {name:',
99+
completions: [
100+
{ label: '{name:String}', type: 'variable' },
101+
{ label: 'column1', type: 'variable' },
102+
],
103+
expected: ['{name:String}'],
104+
},
105+
])('supports identifiers with $name', ({ doc, completions, expected }) => {
106+
expect(getSuggestionLabels(doc, { completions })).toEqual(expected);
107+
});
108+
109+
describe('AS keyword suppression', () => {
110+
it.each([
111+
{ name: 'AS (uppercase)', doc: 'SELECT count(*) AS ali' },
112+
{ name: 'as (lowercase)', doc: 'SELECT count(*) as ali' },
113+
{ name: 'AS with extra whitespace', doc: 'SELECT count(*) AS ali' },
114+
])('returns null after $name', ({ doc }) => {
115+
expect(getSuggestionLabels(doc)).toBeNull();
116+
});
117+
118+
it('does not suppress when AS is part of a larger word', () => {
119+
expect(getSuggestionLabels('SELECT CAST')).toEqual([]);
120+
});
121+
});
122+
123+
describe('mid-identifier completion', () => {
124+
it.each([
125+
{
126+
name: "cursor before trailing ']",
127+
doc: "ResourceAttributes['host.']",
128+
pos: 25, // after 'host.'
129+
expectedRange: { from: 0, to: 27 },
130+
},
131+
{
132+
name: 'cursor in middle of a word',
133+
doc: 'SELECT column1',
134+
pos: 10, // after 'col'
135+
expectedRange: { from: 7, to: 14 },
136+
},
137+
{
138+
name: 'cursor at end of identifier (no trailing chars)',
139+
doc: 'SELECT column1',
140+
pos: 14,
141+
expectedRange: { from: 7, to: 14 },
142+
},
143+
])(
144+
'replacement range covers full identifier when $name',
145+
({ doc, pos, expectedRange }) => {
146+
expect(getReplacementRange(doc, pos)).toEqual(expectedRange);
147+
},
148+
);
149+
});
150+
});

packages/app/src/components/SQLEditor/utils.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,41 @@ export type SQLCompletion = {
1919
type?: string;
2020
};
2121

22+
// Characters that form SQL identifiers in our editor: word chars, dots,
23+
// single quotes, brackets, $, {, }, and : — to support expressions like
24+
// `ResourceAttributes['service.name']`, `$__dateFilter`, `{name:Type}`.
25+
const IDENTIFIER_CHAR = "[\\w.'[\\]${}:]";
26+
const IDENTIFIER_BEFORE = new RegExp(`${IDENTIFIER_CHAR}+`);
27+
const IDENTIFIER_AFTER = new RegExp(`^${IDENTIFIER_CHAR}+`);
28+
const IDENTIFIER_VALID_FOR = new RegExp(`^${IDENTIFIER_CHAR}*$`);
29+
2230
/**
2331
* Creates a custom CodeMirror completion source for SQL identifiers (column names, table
2432
* names, functions, etc.) that inserts them verbatim, without quoting.
2533
*/
26-
function createIdentifierCompletionSource(completions: Completion[]) {
34+
export function createIdentifierCompletionSource(completions: Completion[]) {
2735
return (context: CompletionContext) => {
28-
// Match word characters, dots, single quotes, brackets, $, {, }, and :
29-
// to support identifiers like `ResourceAttributes['service.name']`,
30-
// macros like `$__dateFilter`, and query params like `{name:Type}`
31-
const prefix = context.matchBefore(/[\w.'[\]${}:]+/);
36+
const prefix = context.matchBefore(IDENTIFIER_BEFORE);
3237
if (!prefix && !context.explicit) return null;
38+
39+
// Suppress suggestions after AS keyword since the user is typing a custom alias
40+
const textBefore = context.state.doc
41+
.sliceString(0, prefix?.from ?? context.pos)
42+
.trimEnd();
43+
if (/\bAS$/i.test(textBefore)) return null;
44+
45+
// Look forward from cursor to include trailing identifier characters
46+
// (e.g. the `']` in `ResourceAttributes['host.']`) so accepting a
47+
// suggestion replaces the entire identifier, not just up to the cursor.
48+
const docText = context.state.doc.sliceString(context.pos);
49+
const suffix = docText.match(IDENTIFIER_AFTER);
50+
const to = suffix ? context.pos + suffix[0].length : context.pos;
51+
3352
return {
3453
from: prefix?.from ?? context.pos,
54+
to,
3555
options: completions,
36-
validFor: /^[\w.'[\]${}:]*$/,
56+
validFor: IDENTIFIER_VALID_FOR,
3757
};
3858
};
3959
}

0 commit comments

Comments
 (0)