/* * This file is part of TREB. * * TREB is free software: you can redistribute it and/or modify it under the * terms of the GNU General Public License as published by the Free Software * Foundation, either version 3 of the License, or (at your option) any * later version. * * TREB is distributed in the hope that it will be useful, but WITHOUT ANY * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more * details. * * You should have received a copy of the GNU General Public License along * with TREB. If not, see . * * Copyright 2022-2026 trebco, llc. * info@treb.app * */ /** * this is the data side of autocomplete (maintaining the list, matching). * we add this to grid because grid controls the editors; clients can pass * in lists. * * TODO: structure * TODO: other symbols... [FIXME: defined names need to go in here] * TODO: context -- cell vs annotation (...) * * FIXME: why does this use different definitions than the functions? * can't we merge the two? * * [I think they may have been developed independently and them converged...] * */ import { Localization } from 'treb-base-types'; export interface ArgumentDescriptor { name?: string; } /* export enum DescriptorType { Function, Token } */ export interface FunctionDescriptor { name: string; description?: string; arguments?: ArgumentDescriptor[]; /** switching to literals */ type?: 'function'|'token'; /** this is a named range or named expression. meaning a user/non-system name. */ named?: boolean; /** scope refers to sheet IDs. names may be scoped. */ scope?: number; } export interface AutocompleteMatchData { text: string; cursor: number; } export interface AutocompleteExecResult { // completions?: string[]; completions?: FunctionDescriptor[]; token?: string; position?: number; tooltip?: string; arguments?: string; description?: string; function_position?: number; } export interface TooltipParserResult { function: string|undefined; argument: number; position: number; } export class AutocompleteMatcher { private function_names: string[] = []; // private function_map: {[index: string]: FunctionDescriptor} = {}; private argument_separator = Localization.argument_separator.charCodeAt(0); /** * making this public (and scrubbing the type). we need it public so we * can check collisions. I'm not sure why it was originally private... * * that's backwards. this should be private, and anyone checking * collisions should ask. also updating to modern type. * * OK so now a map. all names should be lower-cased for consistency (they * can maintain canonical names inside the object). */ private function_map: Map = new Map(); public RemoveFunctions(functions: FunctionDescriptor|FunctionDescriptor[]): void { if (!Array.isArray(functions)) { functions = [functions]; } for (const entry of functions) { this.function_map.delete(entry.name.toLowerCase()); } this.UpdateNameList(); } public AddFunctions(functions: FunctionDescriptor|FunctionDescriptor[]): void { if (!Array.isArray(functions)) { functions = [functions]; } for (const entry of functions) { this.function_map.set(entry.name.toLowerCase(), entry); } this.UpdateNameList(); } public SetFunctions(functions: FunctionDescriptor[]): void { this.function_map.clear(); for (const entry of functions) { this.function_map.set(entry.name.toLowerCase(), entry); } this.UpdateNameList(); } /** * returns the canonical version of the name, if it exists. * @param name * @returns */ public NormalizeIdentifier(name: string): string|undefined { return this.function_map.get(name.toLowerCase())?.name; } /** * accessor for entries. we're not stopping you from modifying * in place, for now, but don't do that. */ public Get(name: string): FunctionDescriptor|undefined { return this.function_map.get(name.toLowerCase()); } public Exec(data: AutocompleteMatchData): AutocompleteExecResult { // ac/tt only for formula if (data.text[0] !== '=') { return {}; } // console.info(data); let match; let result: AutocompleteExecResult = {}; // ac only at the end of the string if (data.cursor === data.text.length) { // FIXME: quoted strings... // if it's a token, and ends with a legal character // UPDATE: adding the negative leading \d to fix entering complex numbers // match = data.text.match(/(?:^|[^A-Za-z_\d])([A-Za-z_][\w\d_.]*)\s*$/); match = data.text.match(/(?:^|[^a-zA-Z\u00C0-\u024F_\d])([a-zA-Z\u00C0-\u024F_][\w\d\u00C0-\u024F_.]*)\s*$/); if (match) { const token = match[1]; const rex = new RegExp('^' + token.replace('.', '\\.'), 'i'); const list = this.function_names.filter((name) => rex.test(name)).map((name) => this.function_map.get(name.toLowerCase())).filter((test): test is FunctionDescriptor => !!test); result = { completions: list, token, position: data.cursor - token.length, }; } else { // console.info("NOP"); } } const parsed = this.ParseTooltip(data.text.substr(0, data.cursor)); if (parsed.function) { const func = this.function_map.get(parsed.function.toLowerCase()); if (func) { // if (func.canonical_name) result.tooltip = func.canonical_name; // else result.tooltip = tt.toUpperCase(); result.tooltip = '' + func.name + ''; result.arguments = '(' + (func.arguments || []).map((desc, index) => { const argument = desc.name || 'argument'; return (index === parsed.argument) ? `${argument}` : argument; }).join(Localization.argument_separator + ' ') + ')'; result.description = func.description ? `${func.description}` : ''; result.function_position = parsed.position || 0; } } return result; } /** * baby parser for generating tooltips. we want the name of the * current function, and the index of the current argument. * * not handled: escaped quotes (not even sure what the syntax for that is) */ public ParseTooltip(expression: string): TooltipParserResult { // these two things are actually unrelated, we just need to push/pop them at the same time const stack: Array<{ buffer: string; position: number; argument: number; }> = []; let argument = 0; let buffer = ''; // state flag let quote = false; let position = 0; for (const letter of expression) { const char = letter.charCodeAt(0); if (quote) { if (char === 0x22) { quote = false; } } else { switch (char) { case 0x28: // OPEN_PAREN: stack.push({ buffer: buffer.trim(), // there is no case where spaces get in this buffer argument, position: position - buffer.length, }); buffer = ''; argument = 0; break; case this.argument_separator: argument++; break; case 0x29: // CLOSE_PAREN: argument = stack.pop()?.argument || 0; break; case 0x22: // QUOTE: buffer = ''; quote = true; break; default: // these are legal symbol characters if ( (char >= 0x61 && char <= 0x7a) // a-z || (char >= 0x41 && char <= 0x5a) // A-Z || (char >= 0x30 && char <= 0x39) // 0-9 || (char >= 0x00C0 && char <= 0x024F) // accented characters || (char === 0x5f) // _ || (char === 0x2e)) { // . buffer += letter; } else { buffer = ''; } } } position++; } const last_func = stack.pop(); return { function: last_func?.buffer || undefined, position: last_func?.position || 0, argument, }; } /** * we maintain a sorted list of names. this needs to get updated * when the list changes. */ private UpdateNameList() { this.function_names = [...this.function_map.keys()].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); } }