@tablecraft-app/plugin-api 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/completion.d.ts +6 -1
- package/dist/index.d.ts +1 -1
- package/dist/keyspace.d.ts +12 -0
- package/dist/plugin.d.ts +13 -2
- package/dist/sql/complete.js +67 -8
- package/dist/sql/functions.d.ts +20 -4
- package/dist/sql/functions.js +115 -24
- package/dist/sql/signature.d.ts +9 -1
- package/dist/sql/signature.js +48 -10
- package/dist/testing/index.d.ts +14 -1
- package/dist/testing/index.js +11 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/completion.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ export interface FunctionParameter {
|
|
|
22
22
|
readonly optional?: boolean;
|
|
23
23
|
/** Repeats: the call may pass it any number of times. */
|
|
24
24
|
readonly variadic?: boolean;
|
|
25
|
+
/** The keyword written before it instead of a comma, such as `FROM` in `EXTRACT(unit FROM date)`; `|` separates alternatives. */
|
|
26
|
+
readonly lead?: string;
|
|
25
27
|
}
|
|
26
28
|
/** One way to call a function: its parameters in order and what it returns. */
|
|
27
29
|
export interface FunctionSignature {
|
|
@@ -33,7 +35,10 @@ export interface SignatureHelp {
|
|
|
33
35
|
readonly name: string;
|
|
34
36
|
readonly summary: string;
|
|
35
37
|
readonly signatures: readonly FunctionSignature[];
|
|
36
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* The zero-based parameter the caret is in: the top-level commas before it inside the call, or,
|
|
40
|
+
* when the signature has keyword-led parameters (`lead`), the one the separators walk to.
|
|
41
|
+
*/
|
|
37
42
|
readonly argument: number;
|
|
38
43
|
/** The call's name to the end of its arguments, or to the caret while it is still open. */
|
|
39
44
|
readonly from: number;
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export { COMPLETION_KINDS, type CompletionCatalog, type CompletionItem, type Com
|
|
|
3
3
|
export { INSPECTION_IDS, type InspectionId, type InspectionRequest, type Problem, PROBLEM_SEVERITIES, type ProblemSeverity, type QuickFix, type TextEdit, } from "./inspection.js";
|
|
4
4
|
export { ConflictError, DefinitionNotFoundError, type ErrorPosition, errorPositionOf, isPluginError, isTlsSettingsError, offsetOfPosition, PLUGIN_ERROR_CODES, PluginError, type PluginErrorCode, type PluginErrorOptions, TlsSettingsError, } from "./errors.js";
|
|
5
5
|
export { type ExplainedPlan, type Explainability, type ExplainOptions, type PlanNode, type PlanProperty, } from "./explain.js";
|
|
6
|
-
export { KEY_TYPES, type KeyApplied, type KeyApplyOptions, type KeyBytes, type KeyChange, type KeyContents, type KeyDatabase, type KeyDetails, type KeyExpiry, type KeyInfo, type KeyName, type KeyReadRequest, type KeyScan, type KeyScanRequest, type Keyspace, type KeyType, } from "./keyspace.js";
|
|
6
|
+
export { KEY_TYPES, type KeyApplied, type KeyApplyOptions, type KeyBytes, type KeyChange, type KeyContents, type KeyDatabase, type KeyDetails, type KeyExpiry, type KeyInfo, type KeyName, type KeyReadRequest, type KeyScan, type KeyScanRequest, type KeySource, type Keyspace, type KeyType, } from "./keyspace.js";
|
|
7
7
|
export { OBJECT_KINDS, type CatalogObject, type CheckDesign, type ColumnDesign, type DdlContext, type DdlScript, type ForeignKeyDesign, type IndexDesign, type ObjectChange, type ObjectChangeKind, type ObjectEditor, type ObjectFeatures, type ObjectKind, type TableDesign, } from "./objects.js";
|
|
8
8
|
export { CONNECTION_FIELD_GROUPS, CONNECTION_FIELD_KINDS, CONNECTION_URL_FORMS, CONNECTION_URL_PARTS, connectionFieldSchema, connectionUrlSchema, MANIFEST_FILE, networkSpecSchema, pluginManifestSchema, TLS_FIELD_ROLES, tlsSpecSchema, type ConnectionField, type ConnectionFieldGroup, type ConnectionFieldKind, type ConnectionUrl, type ConnectionUrlForm, type ConnectionUrlPart, type NetworkSpec, type PluginManifest, type TlsFieldRole, type TlsSpec, } from "./manifest.js";
|
|
9
9
|
export { ConnectionUrlError, formatConnectionUrl, parseConnectionUrl, type ConnectionForm, type ConnectionUrlSpec, type ConnectionValue, type ParsedConnectionUrl, } from "./url.js";
|
package/dist/keyspace.d.ts
CHANGED
|
@@ -15,6 +15,18 @@ export interface KeyBytes {
|
|
|
15
15
|
}
|
|
16
16
|
/** A key's name: bytes, since a key may be any binary string. */
|
|
17
17
|
export type KeyName = KeyBytes;
|
|
18
|
+
/**
|
|
19
|
+
* The one key a command's reply was read from (decision 119), so the host can open the key or
|
|
20
|
+
* edit the reply as the key. `whole` is true when the reply holds the entire key, such as
|
|
21
|
+
* `HGETALL` or `LRANGE k 0 -1`; its columns are then the ones `Keyspace.read` gives that type,
|
|
22
|
+
* without positions a key tab adds (a zset's `#`), and a string's is one `value` column.
|
|
23
|
+
*/
|
|
24
|
+
export interface KeySource {
|
|
25
|
+
readonly database: number;
|
|
26
|
+
readonly key: KeyName;
|
|
27
|
+
readonly type: KeyType;
|
|
28
|
+
readonly whole: boolean;
|
|
29
|
+
}
|
|
18
30
|
/** One logical database of the keyspace: Redis' `db0`–`db15`, or the one database of a cluster. */
|
|
19
31
|
export interface KeyDatabase {
|
|
20
32
|
readonly index: number;
|
package/dist/plugin.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { Introspector, Namespace, Relation, Routine } from "./catalog.js";
|
|
|
3
3
|
import type { CompletionCatalog, CompletionRequest, CompletionResult, Declaration, ResolveRequest, SignatureHelp } from "./completion.js";
|
|
4
4
|
import type { ExplainedPlan, Explainability, ExplainOptions } from "./explain.js";
|
|
5
5
|
import type { InspectionRequest, Problem } from "./inspection.js";
|
|
6
|
-
import type { Keyspace } from "./keyspace.js";
|
|
6
|
+
import type { KeySource, Keyspace } from "./keyspace.js";
|
|
7
7
|
import type { ObjectEditor } from "./objects.js";
|
|
8
8
|
import type { SessionMonitor } from "./sessions.js";
|
|
9
9
|
import type { TypeClass, WireValue } from "./values.js";
|
|
@@ -58,16 +58,27 @@ export interface ExecuteResult {
|
|
|
58
58
|
readonly messages?: readonly ServerMessage[];
|
|
59
59
|
/**
|
|
60
60
|
* A command's one-value answer, such as a status, a number or a string (decision 112): the
|
|
61
|
-
* console shows it in Output and opens no result
|
|
61
|
+
* console shows it in Output and opens no result unless it is `read`. Only a `commands` plugin sets it, and then
|
|
62
62
|
* `columns` and `rows` are empty.
|
|
63
63
|
*/
|
|
64
64
|
readonly reply?: CommandReply;
|
|
65
|
+
/**
|
|
66
|
+
* The one key the command read, when it read exactly one (decision 119); absent for writes,
|
|
67
|
+
* keyless and multi-key commands. Only a `keyValue` plugin sets it.
|
|
68
|
+
*/
|
|
69
|
+
readonly source?: KeySource;
|
|
65
70
|
}
|
|
66
71
|
/** A scalar reply to a command (decision 112). */
|
|
67
72
|
export interface CommandReply {
|
|
68
73
|
readonly value: WireValue;
|
|
69
74
|
/** The reply as the server's own client prints it: `"Ann"`, `(integer) 5`, `(nil)`, `OK`. */
|
|
70
75
|
readonly text: string;
|
|
76
|
+
/**
|
|
77
|
+
* The reply is a value read, such as a bulk string from `GET` or `HGET` or the nil of a missing
|
|
78
|
+
* one (decision 119): the console opens it as a one-row `value` result besides Output. Absent
|
|
79
|
+
* for statuses, numbers and errors, which stay in Output.
|
|
80
|
+
*/
|
|
81
|
+
readonly read?: boolean;
|
|
71
82
|
}
|
|
72
83
|
/** How serious a `ServerMessage` is, from the server's own word for it. */
|
|
73
84
|
export declare const SERVER_MESSAGE_LEVELS: readonly ["error", "warning", "notice", "info"];
|
package/dist/sql/complete.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { functionItem, functionName, isAvailable } from "./functions.js";
|
|
1
|
+
import { functionItem, functionName, functionPackage, isAvailable, leadMatches, memberItem, } from "./functions.js";
|
|
2
2
|
import { callAt } from "./signature.js";
|
|
3
3
|
import { parseSimpleSelect, SELECT_READ_ONLY_REASONS } from "./select.js";
|
|
4
4
|
import { scopeAt } from "./scope.js";
|
|
@@ -76,6 +76,50 @@ const QUOTES = new Set(['"', "`", "["]);
|
|
|
76
76
|
/** Below every other namespace: `USE mysql` is legitimate but rarely meant. */
|
|
77
77
|
const SYSTEM_NAMESPACE_BOOST = -20;
|
|
78
78
|
const CURRENT_DETAIL = "current";
|
|
79
|
+
const COMMA = ",";
|
|
80
|
+
const MEMBER_SEPARATOR = ".";
|
|
81
|
+
/**
|
|
82
|
+
* Walks `separators` through `signature`: a comma moves to the next parameter without a lead, a
|
|
83
|
+
* word to the next one it leads. A word nothing leads is an argument's own (`YEAR`, `DECIMAL`).
|
|
84
|
+
* Past the last parameter a comma keeps counting, as the hint's variadic rule expects; a variadic
|
|
85
|
+
* parameter keyword-led ones follow (`CHAR(code... USING charset)`) takes every comma.
|
|
86
|
+
*/
|
|
87
|
+
function argumentAt(signature, separators) {
|
|
88
|
+
const { parameters } = signature;
|
|
89
|
+
let at = 0;
|
|
90
|
+
let entered = false;
|
|
91
|
+
let led = 0;
|
|
92
|
+
for (const separator of separators) {
|
|
93
|
+
if (separator === COMMA) {
|
|
94
|
+
const next = parameters.findIndex((parameter, index) => index > at && parameter.lead === undefined);
|
|
95
|
+
if (next >= 0)
|
|
96
|
+
at = next;
|
|
97
|
+
else if (!(parameters[at]?.variadic === true && at < parameters.length - 1))
|
|
98
|
+
at += 1;
|
|
99
|
+
entered = true;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const next = parameters.findIndex((parameter, index) => (index > at || (index === at && !entered)) &&
|
|
103
|
+
parameter.lead !== undefined &&
|
|
104
|
+
leadMatches(parameter.lead, separator));
|
|
105
|
+
if (next < 0)
|
|
106
|
+
continue;
|
|
107
|
+
at = next;
|
|
108
|
+
entered = true;
|
|
109
|
+
led += 1;
|
|
110
|
+
}
|
|
111
|
+
return { argument: at, led };
|
|
112
|
+
}
|
|
113
|
+
/** The argument the caret is in, by the overload whose leads the call follows most; the first on a tie. */
|
|
114
|
+
function argumentOf(spec, call) {
|
|
115
|
+
let best = null;
|
|
116
|
+
for (const signature of spec.signatures) {
|
|
117
|
+
const found = argumentAt(signature, call.separators ?? []);
|
|
118
|
+
if (best === null || found.led > best.led)
|
|
119
|
+
best = found;
|
|
120
|
+
}
|
|
121
|
+
return best?.argument ?? call.argument;
|
|
122
|
+
}
|
|
79
123
|
function relationKind(relation) {
|
|
80
124
|
return relation.kind === "table" ? "table" : "view";
|
|
81
125
|
}
|
|
@@ -93,6 +137,13 @@ export function sqlCompleter(options) {
|
|
|
93
137
|
function specNamed(name, server) {
|
|
94
138
|
return specs.get(name.toUpperCase())?.find((spec) => isAvailable(spec, server));
|
|
95
139
|
}
|
|
140
|
+
/** The members of the package `qualifier` names, as offered after its dot. */
|
|
141
|
+
function memberItems(qualifier, server) {
|
|
142
|
+
const name = qualifier.map((part) => part.name.toUpperCase()).join(MEMBER_SEPARATOR);
|
|
143
|
+
return availableFunctions(server, true)
|
|
144
|
+
.filter((spec) => typeof spec !== "string" && functionPackage(spec) === name)
|
|
145
|
+
.map((spec) => memberItem(spec, BOOST.function));
|
|
146
|
+
}
|
|
96
147
|
function quoted(name) {
|
|
97
148
|
return options.plainIdentifier.test(name) && !reserved.has(name.toUpperCase())
|
|
98
149
|
? name
|
|
@@ -115,10 +166,13 @@ export function sqlCompleter(options) {
|
|
|
115
166
|
.filter((keyword) => !except.has(keyword.toUpperCase()))
|
|
116
167
|
.map((keyword) => item("keyword", keyword, keyword, null));
|
|
117
168
|
}
|
|
118
|
-
|
|
169
|
+
/** Plain functions, or with `members` the package members only: a bare name never offers `pkg.member`. */
|
|
170
|
+
function availableFunctions(server, members = false) {
|
|
119
171
|
const offered = new Set();
|
|
120
172
|
return options.functions.filter((spec) => {
|
|
121
173
|
const name = functionName(spec).toUpperCase();
|
|
174
|
+
if ((functionPackage(spec) !== null) !== members)
|
|
175
|
+
return false;
|
|
122
176
|
if (offered.has(name) || (typeof spec !== "string" && !isAvailable(spec, server)))
|
|
123
177
|
return false;
|
|
124
178
|
offered.add(name);
|
|
@@ -131,21 +185,22 @@ export function sqlCompleter(options) {
|
|
|
131
185
|
if (last === undefined)
|
|
132
186
|
return [];
|
|
133
187
|
const relational = clause === "relation" || clause === "join";
|
|
188
|
+
const members = relational ? [] : memberItems(qualifier, lookup.server);
|
|
134
189
|
if (qualifier.length === 1 && !relational) {
|
|
135
190
|
const source = sourceNamed(query, last);
|
|
136
191
|
if (source !== undefined)
|
|
137
|
-
return columnItems(await lookup.columnsOf(source, query));
|
|
192
|
+
return [...columnItems(await lookup.columnsOf(source, query)), ...members];
|
|
138
193
|
const cte = cteNamed(query, last);
|
|
139
194
|
if (cte !== undefined)
|
|
140
|
-
return columnItems(await lookup.cteColumns(cte, 1));
|
|
195
|
+
return [...columnItems(await lookup.cteColumns(cte, 1)), ...members];
|
|
141
196
|
}
|
|
142
197
|
const namespace = await lookup.namespace(qualifier);
|
|
143
198
|
if (namespace !== undefined)
|
|
144
|
-
return relationItems(await lookup.relations(namespace.path), null);
|
|
199
|
+
return [...members, ...relationItems(await lookup.relations(namespace.path), null)];
|
|
145
200
|
if (relational)
|
|
146
201
|
return [];
|
|
147
202
|
const found = await lookup.relation(qualifier.slice(0, -1), last);
|
|
148
|
-
return found === null ?
|
|
203
|
+
return found === null ? members : [...columnItems(found.relation.columns), ...members];
|
|
149
204
|
}
|
|
150
205
|
async function relationContextItems(scope, lookup) {
|
|
151
206
|
const current = lookup.current;
|
|
@@ -463,7 +518,11 @@ export function sqlCompleter(options) {
|
|
|
463
518
|
function signatureAt(request, catalog) {
|
|
464
519
|
const { text, offset, shift } = wrap(request);
|
|
465
520
|
const server = serverOf(catalog);
|
|
466
|
-
const
|
|
521
|
+
const callable = (name) => {
|
|
522
|
+
const spec = specNamed(name, server);
|
|
523
|
+
return spec !== undefined && spec.bare !== true;
|
|
524
|
+
};
|
|
525
|
+
const call = callAt(text, offset, options.dialect, reserved, callable);
|
|
467
526
|
const spec = call === null ? undefined : specNamed(call.name, server);
|
|
468
527
|
if (call === null || spec === undefined)
|
|
469
528
|
return Promise.resolve(null);
|
|
@@ -471,7 +530,7 @@ export function sqlCompleter(options) {
|
|
|
471
530
|
name: spec.name.toLowerCase(),
|
|
472
531
|
summary: spec.summary,
|
|
473
532
|
signatures: spec.signatures,
|
|
474
|
-
argument: call
|
|
533
|
+
argument: argumentOf(spec, call),
|
|
475
534
|
from: call.from - shift,
|
|
476
535
|
to: call.to - shift,
|
|
477
536
|
});
|
package/dist/sql/functions.d.ts
CHANGED
|
@@ -21,26 +21,42 @@ export interface FunctionSpec {
|
|
|
21
21
|
readonly summary: string;
|
|
22
22
|
readonly category: FunctionCategory;
|
|
23
23
|
readonly available?: ServerRange;
|
|
24
|
+
/** Written without parentheses, such as Oracle's `SYSDATE`; the call is the name alone and has no hint. */
|
|
25
|
+
readonly bare?: true;
|
|
24
26
|
}
|
|
25
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* `[name, signatures, summary, available?]`, a signature written `"date, format -> string"`, a
|
|
29
|
+
* keyword-led parameter `"unit FROM date -> integer"`, a bare function `"= date"` and a procedure
|
|
30
|
+
* `"str ->"`. A name `PKG.MEMBER` is offered after `pkg.`.
|
|
31
|
+
*/
|
|
26
32
|
export type FunctionEntry = readonly [
|
|
27
33
|
name: string,
|
|
28
34
|
signatures: string | readonly string[],
|
|
29
35
|
summary: string,
|
|
30
36
|
available?: ServerRange
|
|
31
37
|
];
|
|
32
|
-
/**
|
|
38
|
+
/** Whether `lead` is `word` or offers it among its `|` alternatives. */
|
|
39
|
+
export declare function leadMatches(lead: string, word: string): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Reads `"str, len? -> string"`: `?` marks an optional parameter, `...` one that repeats, an
|
|
42
|
+
* upper-case word between names the keyword before the next (`"unit FROM date -> integer"`).
|
|
43
|
+
* `"= date"` is a bare function's and `"str ->"` a procedure's, which returns nothing.
|
|
44
|
+
*/
|
|
33
45
|
export declare function parseSignature(written: string): FunctionSignature;
|
|
34
46
|
/** Specs for one category, from compact entries a plugin's data file lists. */
|
|
35
47
|
export declare function functionSpecs(category: FunctionCategory, entries: readonly FunctionEntry[]): FunctionSpec[];
|
|
36
48
|
/** Whether `spec` exists on `server`; an unknown server has every function any flavour has. */
|
|
37
49
|
export declare function isAvailable(spec: FunctionSpec, server: ServerIdentity | null): boolean;
|
|
38
|
-
/** `(date, format) → string`, the popup's detail for one signature
|
|
50
|
+
/** `(date, format) → string`, the popup's detail for one signature; a lead replaces the comma, a procedure has no `→`. */
|
|
39
51
|
export declare function signatureLabel(signature: FunctionSignature): string;
|
|
40
52
|
/** Every signature on its own line, then the summary. */
|
|
41
53
|
export declare function functionInfo(spec: FunctionSpec): string;
|
|
42
|
-
/** A spec as an item: `name()` with the caret between the parentheses when it takes arguments. */
|
|
54
|
+
/** A spec as an item: `name()` with the caret between the parentheses when it takes arguments, the name alone when bare. */
|
|
43
55
|
export declare function functionItem(spec: FunctionSpec | string, boost: number): CompletionItem;
|
|
56
|
+
/** The package a spec named `PKG.MEMBER` belongs to, upper case; null for a plain function. */
|
|
57
|
+
export declare function functionPackage(spec: FunctionSpec | string): string | null;
|
|
58
|
+
/** A package member as offered after `pkg.`: the member's name alone, the info naming it in full. */
|
|
59
|
+
export declare function memberItem(spec: FunctionSpec, boost: number): CompletionItem;
|
|
44
60
|
/** The name a function entry is offered under, either form. */
|
|
45
61
|
export declare function functionName(spec: FunctionSpec | string): string;
|
|
46
62
|
/** Aggregate and scalar functions every built-in dialect has; a plugin adds its own. Written in our own words. */
|
package/dist/sql/functions.js
CHANGED
|
@@ -20,7 +20,13 @@ const SHOWN_ARROW = "→";
|
|
|
20
20
|
const OPTIONAL_MARK = "?";
|
|
21
21
|
const VARIADIC_MARK = "...";
|
|
22
22
|
const COMPARATOR = /^(>=|<=|>|<|=)?(\d+(?:\.\d+)*)$/;
|
|
23
|
-
|
|
23
|
+
const BARE_MARK = "=";
|
|
24
|
+
const LEAD_WORD = /^[A-Z][A-Z_]*$/;
|
|
25
|
+
const LEAD_CHOICE = "|";
|
|
26
|
+
const PARAMETER_SEPARATOR = ",";
|
|
27
|
+
const COMMA_SHOWN = ", ";
|
|
28
|
+
const MEMBER_SEPARATOR = ".";
|
|
29
|
+
function parameter(written, lead) {
|
|
24
30
|
let name = written.trim();
|
|
25
31
|
const variadic = name.endsWith(VARIADIC_MARK);
|
|
26
32
|
if (variadic)
|
|
@@ -28,28 +34,85 @@ function parameter(written) {
|
|
|
28
34
|
const optional = name.endsWith(OPTIONAL_MARK);
|
|
29
35
|
if (optional)
|
|
30
36
|
name = name.slice(0, -OPTIONAL_MARK.length).trim();
|
|
31
|
-
return {
|
|
37
|
+
return {
|
|
38
|
+
name,
|
|
39
|
+
...(optional ? { optional } : {}),
|
|
40
|
+
...(variadic ? { variadic } : {}),
|
|
41
|
+
...(lead === null ? {} : { lead }),
|
|
42
|
+
};
|
|
32
43
|
}
|
|
33
|
-
|
|
44
|
+
function isLeadWord(word) {
|
|
45
|
+
return word.split(LEAD_CHOICE).every((choice) => LEAD_WORD.test(choice));
|
|
46
|
+
}
|
|
47
|
+
/** Whether `lead` is `word` or offers it among its `|` alternatives. */
|
|
48
|
+
export function leadMatches(lead, word) {
|
|
49
|
+
return lead.split(LEAD_CHOICE).includes(word);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* One comma-separated part: an upper-case word between names leads the next, as one before the
|
|
53
|
+
* first parameter does; one standing alone is a keyword choice. After a comma the keyword is the
|
|
54
|
+
* argument's own (`date, INTERVAL expr unit`), since the comma already separates it.
|
|
55
|
+
*/
|
|
56
|
+
function leadParameters(part, index) {
|
|
57
|
+
const words = part.trim().split(/\s+/);
|
|
58
|
+
const parameters = [];
|
|
59
|
+
let lead = null;
|
|
60
|
+
let name = [];
|
|
61
|
+
const flush = () => {
|
|
62
|
+
if (name.length > 0)
|
|
63
|
+
parameters.push(parameter(name.join(" "), lead));
|
|
64
|
+
name = [];
|
|
65
|
+
lead = null;
|
|
66
|
+
};
|
|
67
|
+
words.forEach((word, at) => {
|
|
68
|
+
const next = words[at + 1];
|
|
69
|
+
const afterComma = at === 0 && index > 0;
|
|
70
|
+
if (!afterComma && isLeadWord(word) && next !== undefined && !isLeadWord(next)) {
|
|
71
|
+
flush();
|
|
72
|
+
lead = word;
|
|
73
|
+
}
|
|
74
|
+
else
|
|
75
|
+
name.push(word);
|
|
76
|
+
});
|
|
77
|
+
flush();
|
|
78
|
+
return parameters;
|
|
79
|
+
}
|
|
80
|
+
function isBareSignature(written) {
|
|
81
|
+
return written.trim().startsWith(BARE_MARK);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Reads `"str, len? -> string"`: `?` marks an optional parameter, `...` one that repeats, an
|
|
85
|
+
* upper-case word between names the keyword before the next (`"unit FROM date -> integer"`).
|
|
86
|
+
* `"= date"` is a bare function's and `"str ->"` a procedure's, which returns nothing.
|
|
87
|
+
*/
|
|
34
88
|
export function parseSignature(written) {
|
|
89
|
+
if (isBareSignature(written))
|
|
90
|
+
return { parameters: [], returns: written.trim().slice(BARE_MARK.length).trim() };
|
|
35
91
|
const arrow = written.lastIndexOf(RETURNS_ARROW);
|
|
36
92
|
if (arrow < 0)
|
|
37
93
|
throw new Error(`Signature "${written}" has no "${RETURNS_ARROW} type"; add what it returns.`);
|
|
38
94
|
const list = written.slice(0, arrow).trim();
|
|
39
95
|
return {
|
|
40
|
-
parameters: list === "" ? [] : list.split(
|
|
96
|
+
parameters: list === "" ? [] : list.split(PARAMETER_SEPARATOR).flatMap(leadParameters),
|
|
41
97
|
returns: written.slice(arrow + RETURNS_ARROW.length).trim(),
|
|
42
98
|
};
|
|
43
99
|
}
|
|
44
100
|
/** Specs for one category, from compact entries a plugin's data file lists. */
|
|
45
101
|
export function functionSpecs(category, entries) {
|
|
46
|
-
return entries.map(([name, signatures, summary, available]) =>
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
102
|
+
return entries.map(([name, signatures, summary, available]) => {
|
|
103
|
+
const written = typeof signatures === "string" ? [signatures] : signatures;
|
|
104
|
+
const bare = written.filter(isBareSignature).length;
|
|
105
|
+
if (bare > 0 && bare < written.length)
|
|
106
|
+
throw new Error(`${name} mixes "${BARE_MARK} type" with called signatures; write the bare form as "-> type".`);
|
|
107
|
+
return {
|
|
108
|
+
name,
|
|
109
|
+
signatures: written.map(parseSignature),
|
|
110
|
+
summary,
|
|
111
|
+
category,
|
|
112
|
+
...(available === undefined ? {} : { available }),
|
|
113
|
+
...(bare > 0 ? { bare: true } : {}),
|
|
114
|
+
};
|
|
115
|
+
});
|
|
53
116
|
}
|
|
54
117
|
function compareVersions(left, right) {
|
|
55
118
|
for (let at = 0; at < Math.max(left.length, right.length); at += 1) {
|
|
@@ -91,37 +154,65 @@ export function isAvailable(spec, server) {
|
|
|
91
154
|
.filter((comparator) => comparator !== "")
|
|
92
155
|
.every((comparator) => satisfies(server.version, comparator));
|
|
93
156
|
}
|
|
94
|
-
function shownParameter(parameter) {
|
|
157
|
+
function shownParameter(parameter, index) {
|
|
95
158
|
const name = parameter.variadic === true ? `${parameter.name}${VARIADIC_MARK}` : parameter.name;
|
|
96
|
-
|
|
159
|
+
const led = parameter.lead === undefined ? name : `${parameter.lead} ${name}`;
|
|
160
|
+
const separator = index === 0 ? "" : parameter.lead === undefined ? COMMA_SHOWN : " ";
|
|
161
|
+
return separator + (parameter.optional === true ? `[${led}]` : led);
|
|
97
162
|
}
|
|
98
|
-
|
|
163
|
+
function returnsLabel(returns) {
|
|
164
|
+
return returns === "" ? "" : `${SHOWN_ARROW} ${returns}`;
|
|
165
|
+
}
|
|
166
|
+
/** `(date, format) → string`, the popup's detail for one signature; a lead replaces the comma, a procedure has no `→`. */
|
|
99
167
|
export function signatureLabel(signature) {
|
|
100
|
-
|
|
168
|
+
const list = `(${signature.parameters.map(shownParameter).join("")})`;
|
|
169
|
+
const returns = returnsLabel(signature.returns);
|
|
170
|
+
return returns === "" ? list : `${list} ${returns}`;
|
|
171
|
+
}
|
|
172
|
+
function shownSignature(spec, signature) {
|
|
173
|
+
return spec.bare === true ? returnsLabel(signature.returns) : signatureLabel(signature);
|
|
101
174
|
}
|
|
102
175
|
/** Every signature on its own line, then the summary. */
|
|
103
176
|
export function functionInfo(spec) {
|
|
104
177
|
const label = spec.name.toLowerCase();
|
|
105
|
-
const
|
|
178
|
+
const gap = spec.bare === true ? " " : "";
|
|
179
|
+
const lines = spec.signatures.map((signature) => `${label}${gap}${shownSignature(spec, signature)}`);
|
|
106
180
|
return `${lines.join("\n")}\n\n${spec.summary}`;
|
|
107
181
|
}
|
|
108
|
-
/**
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
182
|
+
/**
|
|
183
|
+
* A spec as an item: `name()` with the caret between the parentheses when it takes arguments, or
|
|
184
|
+
* the name alone for a bare one. `label` is what the popup offers, the member alone after `pkg.`.
|
|
185
|
+
*/
|
|
186
|
+
function specItem(spec, label, boost) {
|
|
113
187
|
const first = spec.signatures[0];
|
|
114
188
|
const takesArguments = spec.signatures.some((signature) => signature.parameters.length > 0);
|
|
189
|
+
const bare = spec.bare === true;
|
|
115
190
|
return {
|
|
116
191
|
label,
|
|
117
192
|
kind: "function",
|
|
118
|
-
detail: first === undefined ? null :
|
|
119
|
-
apply: `${label}()`,
|
|
193
|
+
detail: first === undefined ? null : shownSignature(spec, first),
|
|
194
|
+
apply: bare ? label : `${label}()`,
|
|
120
195
|
boost,
|
|
121
196
|
info: functionInfo(spec),
|
|
122
|
-
caret: takesArguments ? label.length + 1 : null,
|
|
197
|
+
caret: takesArguments && !bare ? label.length + 1 : null,
|
|
123
198
|
};
|
|
124
199
|
}
|
|
200
|
+
/** A spec as an item: `name()` with the caret between the parentheses when it takes arguments, the name alone when bare. */
|
|
201
|
+
export function functionItem(spec, boost) {
|
|
202
|
+
if (typeof spec === "string")
|
|
203
|
+
return { label: spec, kind: "function", detail: null, apply: spec, boost, info: null, caret: null };
|
|
204
|
+
return specItem(spec, spec.name.toLowerCase(), boost);
|
|
205
|
+
}
|
|
206
|
+
/** The package a spec named `PKG.MEMBER` belongs to, upper case; null for a plain function. */
|
|
207
|
+
export function functionPackage(spec) {
|
|
208
|
+
const name = functionName(spec);
|
|
209
|
+
const dot = name.lastIndexOf(MEMBER_SEPARATOR);
|
|
210
|
+
return dot < 0 ? null : name.slice(0, dot).toUpperCase();
|
|
211
|
+
}
|
|
212
|
+
/** A package member as offered after `pkg.`: the member's name alone, the info naming it in full. */
|
|
213
|
+
export function memberItem(spec, boost) {
|
|
214
|
+
return specItem(spec, spec.name.slice(spec.name.lastIndexOf(MEMBER_SEPARATOR) + 1).toLowerCase(), boost);
|
|
215
|
+
}
|
|
125
216
|
/** The name a function entry is offered under, either form. */
|
|
126
217
|
export function functionName(spec) {
|
|
127
218
|
return typeof spec === "string" ? spec : spec.name;
|
package/dist/sql/signature.d.ts
CHANGED
|
@@ -3,13 +3,21 @@ import type { SqlDialectOptions } from "./tokens.js";
|
|
|
3
3
|
export interface CallAt {
|
|
4
4
|
/** As written. */
|
|
5
5
|
readonly name: string;
|
|
6
|
+
/** The top-level commas before the caret. */
|
|
6
7
|
readonly argument: number;
|
|
8
|
+
/**
|
|
9
|
+
* The call's top-level `,` and words before the caret, in order, words upper case: what moves a
|
|
10
|
+
* keyword-led parameter list such as `EXTRACT(YEAR FROM d)` along. Always set by `callAt`;
|
|
11
|
+
* optional so a value built against the older shape still type-checks (decision 106).
|
|
12
|
+
*/
|
|
13
|
+
readonly separators?: readonly string[];
|
|
7
14
|
readonly from: number;
|
|
8
15
|
readonly to: number;
|
|
9
16
|
}
|
|
10
17
|
/**
|
|
11
18
|
* Finds the innermost call around `offset` whose name `known` accepts, counting its top-level
|
|
12
|
-
* commas; strings, comments and quoted names are tokens of their own,
|
|
19
|
+
* commas and listing its separators; strings, comments and quoted names are tokens of their own,
|
|
20
|
+
* so their commas never count. `pkg.member (` asks `known` for `PKG.MEMBER`.
|
|
13
21
|
* Grouping parentheses are looked through; a subquery, a non-function name before `(` or a caret
|
|
14
22
|
* in a string or comment ends the search with null. `reserved` holds the dialect's keywords: a
|
|
15
23
|
* function spelled like one (`VALUES`, `LEFT`) is a call only where an operand fits, not after a
|
package/dist/sql/signature.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { statementTokens } from "./scope.js";
|
|
2
|
+
const COMMA = ",";
|
|
3
|
+
const MEMBER_SEPARATOR = ".";
|
|
4
|
+
/** Calls a procedure by name, so it may come before a package member as it never may before a function. */
|
|
5
|
+
const CALL_WORD = "CALL";
|
|
2
6
|
/** Words a relation or routine name follows, so `TABLE upper (` defines rather than calls. */
|
|
3
7
|
const NAME_INTRODUCERS = new Set([
|
|
4
8
|
"CALL",
|
|
@@ -25,7 +29,8 @@ function upperWord(text, token) {
|
|
|
25
29
|
}
|
|
26
30
|
/**
|
|
27
31
|
* Finds the innermost call around `offset` whose name `known` accepts, counting its top-level
|
|
28
|
-
* commas; strings, comments and quoted names are tokens of their own,
|
|
32
|
+
* commas and listing its separators; strings, comments and quoted names are tokens of their own,
|
|
33
|
+
* so their commas never count. `pkg.member (` asks `known` for `PKG.MEMBER`.
|
|
29
34
|
* Grouping parentheses are looked through; a subquery, a non-function name before `(` or a caret
|
|
30
35
|
* in a string or comment ends the search with null. `reserved` holds the dialect's keywords: a
|
|
31
36
|
* function spelled like one (`VALUES`, `LEFT`) is a call only where an operand fits, not after a
|
|
@@ -35,23 +40,42 @@ export function callAt(text, offset, dialect, reserved, known) {
|
|
|
35
40
|
const { tokens, literal } = statementTokens(text, offset, dialect);
|
|
36
41
|
if (literal)
|
|
37
42
|
return null;
|
|
38
|
-
|
|
43
|
+
/** `pkg.member` ending at `at`, when the package's spec knows the member. */
|
|
44
|
+
function member(at) {
|
|
39
45
|
const name = tokens[at];
|
|
46
|
+
const owner = tokens[at - 2];
|
|
40
47
|
const word = upperWord(text, name);
|
|
41
|
-
|
|
48
|
+
const ownerWord = upperWord(text, owner);
|
|
49
|
+
if (name === undefined || owner === undefined || word === null || ownerWord === null)
|
|
50
|
+
return null;
|
|
51
|
+
if (!known(`${ownerWord}${MEMBER_SEPARATOR}${word}`))
|
|
42
52
|
return null;
|
|
53
|
+
const previous = upperWord(text, tokens[at - 3]);
|
|
54
|
+
if (previous !== null && previous !== CALL_WORD && NAME_INTRODUCERS.has(previous))
|
|
55
|
+
return null;
|
|
56
|
+
return {
|
|
57
|
+
written: `${text.slice(owner.from, owner.to)}${MEMBER_SEPARATOR}${text.slice(name.from, name.to)}`,
|
|
58
|
+
from: owner.from,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function callable(at) {
|
|
62
|
+
const name = tokens[at];
|
|
43
63
|
const before = tokens[at - 1];
|
|
44
|
-
if (symbol(text, before,
|
|
64
|
+
if (symbol(text, before, MEMBER_SEPARATOR))
|
|
65
|
+
return member(at);
|
|
66
|
+
const word = upperWord(text, name);
|
|
67
|
+
if (name === undefined || word === null || !known(word))
|
|
45
68
|
return null;
|
|
69
|
+
const found = { written: text.slice(name.from, name.to), from: name.from };
|
|
46
70
|
const previous = upperWord(text, before);
|
|
47
71
|
if (previous !== null && NAME_INTRODUCERS.has(previous))
|
|
48
72
|
return null;
|
|
49
73
|
if (!reserved.has(word))
|
|
50
|
-
return
|
|
74
|
+
return found;
|
|
51
75
|
const operandFits = before === undefined ||
|
|
52
76
|
(before.kind === "symbol" && !symbol(text, before, ")")) ||
|
|
53
77
|
(previous !== null && reserved.has(previous));
|
|
54
|
-
return operandFits ?
|
|
78
|
+
return operandFits ? found : null;
|
|
55
79
|
}
|
|
56
80
|
const frames = [];
|
|
57
81
|
let index = 0;
|
|
@@ -61,14 +85,27 @@ export function callAt(text, offset, dialect, reserved, known) {
|
|
|
61
85
|
break;
|
|
62
86
|
if (symbol(text, token, "(")) {
|
|
63
87
|
const name = callable(index - 1);
|
|
64
|
-
frames.push({
|
|
88
|
+
frames.push({
|
|
89
|
+
name,
|
|
90
|
+
named: name === null && !grouping(text, tokens[index - 1]),
|
|
91
|
+
open: index,
|
|
92
|
+
commas: 0,
|
|
93
|
+
separators: [],
|
|
94
|
+
});
|
|
65
95
|
}
|
|
66
96
|
else if (symbol(text, token, ")"))
|
|
67
97
|
frames.pop();
|
|
68
|
-
else if (symbol(text, token,
|
|
98
|
+
else if (symbol(text, token, COMMA)) {
|
|
69
99
|
const top = frames.at(-1);
|
|
70
|
-
if (top !== undefined)
|
|
100
|
+
if (top !== undefined) {
|
|
71
101
|
top.commas += 1;
|
|
102
|
+
top.separators.push(COMMA);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
const word = upperWord(text, token);
|
|
107
|
+
if (word !== null)
|
|
108
|
+
frames.at(-1)?.separators.push(word);
|
|
72
109
|
}
|
|
73
110
|
}
|
|
74
111
|
for (let depth = frames.length - 1; depth >= 0; depth -= 1) {
|
|
@@ -81,8 +118,9 @@ export function callAt(text, offset, dialect, reserved, known) {
|
|
|
81
118
|
continue;
|
|
82
119
|
}
|
|
83
120
|
return {
|
|
84
|
-
name:
|
|
121
|
+
name: frame.name.written,
|
|
85
122
|
argument: frame.commas,
|
|
123
|
+
separators: frame.separators,
|
|
86
124
|
from: frame.name.from,
|
|
87
125
|
to: closeOf(text, tokens, index, frames.length - depth) ?? offset,
|
|
88
126
|
};
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { z, type DatasourcePlugin, type KeyChange, type KeyContents, type KeyType, type PluginManifest, type Routine, type RoutineArgument, type Row, type ServerMessage } from "@tablecraft-app/plugin-api";
|
|
1
|
+
import { z, type DatasourcePlugin, type KeyChange, type KeyContents, type KeySource, type KeyType, type PluginManifest, type Routine, type RoutineArgument, type Row, type ServerMessage } from "@tablecraft-app/plugin-api";
|
|
2
2
|
export interface ContractFixture {
|
|
3
3
|
/** The plugin's parsed `table-craft-plugin.json`, whose connection form the suite checks. */
|
|
4
4
|
readonly manifest: PluginManifest;
|
|
@@ -201,6 +201,19 @@ export interface KeyspaceFixture {
|
|
|
201
201
|
};
|
|
202
202
|
/** A scratch key the suite creates, changes and removes. */
|
|
203
203
|
readonly edit: KeyEditFixture;
|
|
204
|
+
/**
|
|
205
|
+
* Commands run in a console and the key each reply names as its source, null for none
|
|
206
|
+
* (decision 119): one that reads a whole key, one that reads part of one, and one that reads
|
|
207
|
+
* several. Without it the reply-source tests are skipped.
|
|
208
|
+
*/
|
|
209
|
+
readonly sources?: readonly KeySourceFixture[];
|
|
210
|
+
}
|
|
211
|
+
/** A command and the source its reply names (decision 119). */
|
|
212
|
+
export interface KeySourceFixture {
|
|
213
|
+
readonly command: string;
|
|
214
|
+
readonly source: KeySource | null;
|
|
215
|
+
/** The reply is a value read, which the console opens as a one-row result. */
|
|
216
|
+
readonly read?: boolean;
|
|
204
217
|
}
|
|
205
218
|
/** How the suite changes one element of a scratch key and reads it back (decision 115). */
|
|
206
219
|
export interface KeyEditFixture {
|
package/dist/testing/index.js
CHANGED
|
@@ -1934,6 +1934,17 @@ export function runPluginContract(plugin, fixture) {
|
|
|
1934
1934
|
expect(cursor).toBeNull();
|
|
1935
1935
|
return { keys, batches };
|
|
1936
1936
|
}
|
|
1937
|
+
it.runIf(fixture.keyspace?.sources !== undefined)("givenSingleKeyRead_whenExecute_thenReplyNamesItsSourceKey", async () => {
|
|
1938
|
+
await withSession(plugin, fixture, async (session) => {
|
|
1939
|
+
for (const expected of keyspaceFixture().sources ?? []) {
|
|
1940
|
+
const result = await session.execute(expected.command);
|
|
1941
|
+
await collect(result.rows);
|
|
1942
|
+
expect(result.source ?? null, expected.command).toEqual(expected.source);
|
|
1943
|
+
if (expected.read !== undefined)
|
|
1944
|
+
expect(result.reply?.read ?? false, expected.command).toBe(expected.read);
|
|
1945
|
+
}
|
|
1946
|
+
});
|
|
1947
|
+
});
|
|
1937
1948
|
it("givenKeyspace_whenDatabases_thenFixtureDatabaseCountedAndOneCurrent", async () => {
|
|
1938
1949
|
await withSession(plugin, fixture, async (session) => {
|
|
1939
1950
|
const databases = await keyspaceOf(session).databases();
|
package/dist/version.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The plugin contract's version, by semver (decision 106): a host loads a plugin built for the same
|
|
3
3
|
* major and an older or equal minor. Typed `string` so a bump does not change the API report.
|
|
4
4
|
*/
|
|
5
|
-
export const API_VERSION = "1.
|
|
5
|
+
export const API_VERSION = "1.4.0";
|
|
6
6
|
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
7
7
|
/** `major.minor.patch` and nothing else; pre-release and build tags are not accepted. */
|
|
8
8
|
export function parseVersion(text) {
|