@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.
@@ -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
- /** The zero-based argument the caret is in: the top-level commas before it inside the call. */
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";
@@ -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. Only a `commands` plugin sets it, and then
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"];
@@ -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
- function availableFunctions(server) {
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 ? [] : columnItems(found.relation.columns);
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 call = callAt(text, offset, options.dialect, reserved, (name) => specNamed(name, server) !== undefined);
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.argument,
533
+ argument: argumentOf(spec, call),
475
534
  from: call.from - shift,
476
535
  to: call.to - shift,
477
536
  });
@@ -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
- /** `[name, signatures, summary, available?]`, a signature written `"date, format -> string"`. */
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
- /** Reads `"str, len? -> string"`: `?` marks an optional parameter, `...` one that repeats. */
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. */
@@ -20,7 +20,13 @@ const SHOWN_ARROW = "→";
20
20
  const OPTIONAL_MARK = "?";
21
21
  const VARIADIC_MARK = "...";
22
22
  const COMPARATOR = /^(>=|<=|>|<|=)?(\d+(?:\.\d+)*)$/;
23
- function parameter(written) {
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 { name, ...(optional ? { optional } : {}), ...(variadic ? { variadic } : {}) };
37
+ return {
38
+ name,
39
+ ...(optional ? { optional } : {}),
40
+ ...(variadic ? { variadic } : {}),
41
+ ...(lead === null ? {} : { lead }),
42
+ };
32
43
  }
33
- /** Reads `"str, len? -> string"`: `?` marks an optional parameter, `...` one that repeats. */
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(",").map(parameter),
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
- name,
48
- signatures: (typeof signatures === "string" ? [signatures] : signatures).map(parseSignature),
49
- summary,
50
- category,
51
- ...(available === undefined ? {} : { available }),
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
- return parameter.optional === true ? `[${name}]` : name;
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
- /** `(date, format) → string`, the popup's detail for one signature. */
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
- return `(${signature.parameters.map(shownParameter).join(", ")}) ${SHOWN_ARROW} ${signature.returns}`;
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 lines = spec.signatures.map((signature) => `${label}${signatureLabel(signature)}`);
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
- /** A spec as an item: `name()` with the caret between the parentheses when it takes arguments. */
109
- export function functionItem(spec, boost) {
110
- if (typeof spec === "string")
111
- return { label: spec, kind: "function", detail: null, apply: spec, boost, info: null, caret: null };
112
- const label = spec.name.toLowerCase();
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 : signatureLabel(first),
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;
@@ -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, so their commas never count.
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
@@ -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, so their commas never count.
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
- function callable(at) {
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
- if (name === undefined || word === null || !known(word))
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 name;
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 ? name : null;
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({ name, named: name === null && !grouping(text, tokens[index - 1]), open: index, commas: 0 });
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: text.slice(frame.name.from, frame.name.to),
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
  };
@@ -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 {
@@ -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.2.0";
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablecraft-app/plugin-api",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "The datasource plugin contract for TableCraft",
5
5
  "license": "MIT",
6
6
  "type": "module",