gristlabs_grist-core/app/client/components/AceEditorCompletions.ts
Dmitry S 3513c1e8d2 (core) In formula autocompletions, fix suggestions after an opening parenthesis.
Summary:
Previously, a change was made to include the opening parenthesis into the
"identifier regex", so that backend received the function call name for cases
like 'T.lookupOne(B' (to autocomplete columns of table T that start with "B").
Unfortunately, that interferes with common cases like 'SUM($B', since instead
of sending '$B', it is now including the function name.

To properly fix the common case, we need to exclude the function name from the
call, but for lookups we need to include it. ACE's methods aren't flexible
enough to override this logic cleanly, so some monkey-patching was needed.

Test Plan: Added a test case

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3874
2023-04-26 15:26:44 -04:00

319 lines
14 KiB
TypeScript

import {ISuggestionWithValue} from 'app/common/ActiveDocAPI';
import * as ace from 'brace';
export interface ICompletionOptions {
getSuggestions(prefix: string): Promise<ISuggestionWithValue[]>;
}
const completionOptions = new WeakMap<ace.Editor, ICompletionOptions>();
export function setupAceEditorCompletions(editor: ace.Editor, options: ICompletionOptions) {
initCustomCompleter();
completionOptions.set(editor, options);
// Create Autocomplete object at this point so we can turn autoSelect off.
// There doesn't seem to be any way to get ace to respect autoSelect otherwise.
// It is important for autoSelect to be off so that hitting enter doesn't automatically
// use a suggestion, a change of behavior that doesn't seem particularly desirable and
// which also breaks several existing tests.
const {Autocomplete} = ace.acequire('ace/autocomplete'); // lives in brace/ext/language_tools
const completer = new Autocomplete();
completer.autoSelect = false;
(editor as any).completer = completer;
// Used in the patches below. Returns true if the client should fetch fresh completions from the server,
// as it may have new suggestions that aren't currently shown.
completer._gristShouldRefreshCompletions = function(this: any, start: any) {
// These two lines are based on updateCompletions() in the ace autocomplete source code.
const end = this.editor.getCursorPosition();
const prefix: string = this.editor.session.getTextRange({start, end}).toLowerCase();
return (
prefix.endsWith(".") || // to get fresh attributes of references
prefix.endsWith(".lookupone(") || // to get initial argument suggestions
prefix.endsWith(".lookuprecords(")
);
}.bind(completer);
// Patch updateCompletions and insertMatch so that fresh completions are fetched when appropriate.
const originalUpdate = completer.updateCompletions.bind(completer);
completer.updateCompletions = function(this: any, keepPopupPosition: boolean) {
// This next line is copied from updateCompletions() in the ace autocomplete source code.
if (keepPopupPosition && this.base && this.completions) {
// When we need fresh completions, prevent this same block from running
// in the original updateCompletions() function. Otherwise it will just keep any remaining completions that match,
// or not show any completions at all.
if (this._gristShouldRefreshCompletions(this.base)) {
this.completions = null;
}
}
return originalUpdate(keepPopupPosition);
}.bind(completer);
// Similar patch to the above.
const originalInsertMatch = completer.insertMatch.bind(completer);
completer.insertMatch = function(this: any) {
const base = this.base; // this.base may become null after the next line, save it now.
const result = originalInsertMatch.apply(...arguments);
if (this._gristShouldRefreshCompletions(base)) {
this.showPopup(this.editor);
}
return result;
}.bind(completer);
aceCompleterAddHelpLinks(completer);
// Explicitly destroy the auto-completer on disposal, since it doesn't not remove the element
// it adds to body even when it detaches itself. Ace's AutoCompleter doesn't expose any
// interface for this, so this takes some hacking. (One reason for this is that Ace seems to
// expect that a single AutoCompleter would be used for all editor instances.)
editor.on('destroy', () => {
if (completer.editor) {
completer.detach();
}
if (completer.popup) {
completer.popup.destroy(); // This is not enough, but seems relevant to call.
completer.popup.container.remove(); // Removes the element from DOM.
}
});
}
let _initialized = false;
function initCustomCompleter() {
if (_initialized) { return; }
_initialized = true;
// The default regex just matches identifiers. We expand it to include periods (to capture
// attributes) and "$", for Grist column names. In addition, we autocomplete lookup formulas
// with the function name, to give suggestions for lookup keyword arguments.
const prefixMatchRegex = /\w+\.(?:lookupRecords|lookupOne)\([\w.$\u00A2-\uFFFF]*$|[\w.$\u00A2-\uFFFF]+$/;
// Monkey-patch getCompletionPrefix. This is based on the source code in
// node_modules/brace/ext/language_tools.js, simplified to do the one thing we want here (since
// the original method's generality doesn't help us here).
const util = ace.acequire('ace/autocomplete/util'); // lives in brace/ext/language_tools
util.getCompletionPrefix = function getCompletionPrefix(this: any, editor: ace.Editor) {
const pos = editor.getCursorPosition();
const line = editor.session.getLine(pos.row);
const match = line.slice(0, pos.column).match(prefixMatchRegex);
return match ? match[0] : "";
};
// Add some autocompletion with partial access to document
const aceLanguageTools = ace.acequire('ace/ext/language_tools');
aceLanguageTools.setCompleters([]);
aceLanguageTools.addCompleter({
// For autocompletion we ship text to the sandbox and run standard completion there.
async getCompletions(
editor: ace.Editor, session: ace.IEditSession, pos: ace.Position, prefix: string, callback: any
) {
const options = completionOptions.get(editor);
if (!options || prefix.length === 0) { callback(null, []); return; }
const suggestions = await options.getSuggestions(prefix);
// ACE autocompletions are very poorly documented. This is somewhat helpful:
// https://prog.world/implementing-code-completion-in-ace-editor/
const completions: AceSuggestion[] = suggestions.map(suggestionWithValue => {
const [suggestion, example] = suggestionWithValue;
if (Array.isArray(suggestion)) {
const [funcname, argSpec] = suggestion;
return {
value: funcname + '(',
caption: funcname + argSpec,
score: 1,
example,
funcname,
};
} else {
return {
value: suggestion,
caption: suggestion,
score: 1,
example,
funcname: '',
};
}
});
// For suggestions with example values, calculate the 'shared padding', i.e.
// the minimum width in characters that all suggestions should fill
// (before adding 'base padding') so that the examples are aligned.
const captionLengths = completions.filter(c => c.example).map(c => c.caption.length);
const sharedPadding = Math.min(
Math.min(...captionLengths) + MAX_RELATIVE_SHARED_PADDING,
Math.max(...captionLengths),
MAX_ABSOLUTE_SHARED_PADDING,
);
// Add the padding spaces and example values to the captions.
for (const c of completions) {
if (!c.example) { continue; }
const numSpaces = Math.max(0, sharedPadding - c.caption.length) + BASE_PADDING;
c.caption = c.caption + ' '.repeat(numSpaces) + c.example;
}
callback(null, completions);
},
});
}
// Regardless of other suggestions, always add this many spaces between the caption and the example.
const BASE_PADDING = 8;
// In addition to the base padding, there's shared padding, which is the minimum number of spaces
// that all suggestions should fill so that the examples are aligned.
// However, one extremely long suggestion shouldn't result in huge padding for all suggestions.
// To mitigate this, there are two limits on the shared padding.
// The first limit is relative to the shortest caption in the suggestions.
// So if all the suggestions are similarly long, there will still be some shared padding.
const MAX_RELATIVE_SHARED_PADDING = 15;
// The second limit is absolute, so that even if all suggestions are long, we don't run out of popup space.
const MAX_ABSOLUTE_SHARED_PADDING = 40;
// Suggestion objects that are passed to ace.
interface AceSuggestion {
value: string; // the actual value inserted by the autocomplete
caption: string; // the value displayed in the popup
score: number;
// Custom attributes used only by us
example: string | null; // example value of the suggestion to show on the right
funcname: string; // name of a function to link to in documentation
}
/**
* When autocompleting a known function (with funcname received from the server call), turn the
* function name into a link to Grist documentation.
*
* This is only applied for items returned from getCompletions() that include a our custom
* `funcname` attribute.
*
* ACE autocomplete is poorly documented, and poorly customizable, so this is accomplished by
* monkey-patching it. Further, the only text styling is done via styled tokens, but we can style
* them to look like links, and handle clicks to open the destination URL.
*
* This implementation relies a lot on the details of the implementation in
* node_modules/brace/ext/language_tools.js. Updates to brace module may easily break it.
*/
function aceCompleterAddHelpLinks(completer: any) {
// Replace the $init function in order to intercept the creation of the autocomplete popup.
const init = completer.$init;
completer.$init = function() {
const popup = init.apply(this, arguments);
customizeAceCompleterPopup(this, popup);
return popup;
};
}
function customizeAceCompleterPopup(completer: any, popup: any) {
// Replace the $tokenizeRow function to produce customized tokens to style the link part.
const origTokenize = popup.session.bgTokenizer.$tokenizeRow;
popup.session.bgTokenizer.$tokenizeRow = function(row: any) {
const tokens = origTokenize(row);
return retokenizeAceCompleterRow(popup.data[row], tokens);
};
// Replace the click handler with one that handles link clicks.
popup.removeAllListeners("click");
popup.on("click", function(e: any) {
if (!maybeAceCompleterLinkClick(e.domEvent)) {
completer.insertMatch();
}
e.stop();
});
}
interface TokenInfo extends ace.TokenInfo {
type: string;
}
function retokenizeAceCompleterRow(rowData: AceSuggestion, tokens: TokenInfo[]): TokenInfo[] {
if (!(rowData.funcname || rowData.example)) {
// Not a special completion, pass through the result of ACE's original tokenizing.
return tokens;
}
// ACE's original tokenizer splits rowData.caption into tokens to highlight matching portions.
// We jump in, and further divide the tokens so that those that form the link get an extra CSS
// class. ACE's will turn token.type into CSS classes by splitting the type on "." and prefixing
// the resulting substrings with "ace_".
// Funcname may be the recognized name itself (e.g. "UPPER"), or a method (like
// "Table1.lookupOne"), in which case only the portion after the dot is the recognized name.
// Figure out the portion that should be linkified.
const dot = rowData.funcname.lastIndexOf(".");
const linkStart = dot < 0 ? 0 : dot + 1;
const linkEnd = rowData.funcname.length;
const newTokens = [];
// Include into new tokens a special token that will be hidden, but include the link URL. On
// click, we find it to know what URL to open.
const href = 'https://support.getgrist.com/functions/#' +
rowData.funcname.slice(linkStart, linkEnd).toLowerCase();
newTokens.push({value: href, type: 'grist_link_hidden'});
// Find where the example value (if any) starts, so that it can be shown in grey.
let exampleStart: number | undefined;
if (rowData.example) {
if (!rowData.caption.endsWith(rowData.example)) {
// Just being cautious, this shouldn't happen.
console.warn(`Example "${rowData.example}" does not match caption "${rowData.caption}"`);
} else {
exampleStart = rowData.caption.length - rowData.example.length;
}
}
// Go through tokens, splitting them if needed, and modifying those that form the link part.
let position = 0;
for (const t of tokens) {
if (exampleStart && position + t.value.length > exampleStart) {
// Ensure that all text after `exampleStart` has the type 'grist_example'.
// Don't combine that type with the existing type, because ace highlights weirdly sometimes
// and it's best to just override that.
const end = exampleStart - position;
if (end > 0) {
newTokens.push({value: t.value.slice(0, end), type: t.type});
newTokens.push({value: t.value.slice(end), type: 'grist_example'});
} else {
newTokens.push({value: t.value, type: 'grist_example'});
}
} else {
// Handle links to documentation.
// lStart/lEnd are indices of the link within the token, possibly negative.
const lStart = linkStart - position, lEnd = linkEnd - position;
if (lStart > 0) {
const beforeLink = t.value.slice(0, lStart);
newTokens.push({value: beforeLink, type: t.type});
}
if (lEnd > 0) {
const inLink = t.value.slice(Math.max(0, lStart), lEnd);
const newType = t.type + (t.type ? '.' : '') + 'grist_link';
newTokens.push({value: inLink, type: newType});
if (lEnd < t.value.length) {
const afterLink = t.value.slice(lEnd);
newTokens.push({value: afterLink, type: t.type});
}
} else {
newTokens.push(t);
}
}
position += t.value.length;
}
return newTokens;
}
// On any click on AceCompleter popup, we check if we happened to click .ace_grist_link class. If
// so, we should be able to find the URL and open another window to it.
function maybeAceCompleterLinkClick(domEvent: Event) {
const tgt = domEvent.target as HTMLElement;
if (tgt && tgt.matches('.ace_grist_link')) {
const dest = tgt.parentElement?.querySelector('.ace_grist_link_hidden');
if (dest) {
window.open(dest.textContent!, "_blank");
return true;
}
}
return false;
}