@openpresentation/opf-editor 0.26.5 → 0.26.7

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.
@@ -0,0 +1,426 @@
1
+ // Go to definition, find references and rename for the ids of an OPF document (artifacts/source-editor/SPEC.md, section 8.3), as plain functions over the
2
+ // text. `analyze(text, format, { catalogs })` reads the text once (tolerantly: it may be broken or half typed) and lists every place an id is written as an
3
+ // occurrence; everything else (the symbol at an offset, its definition, its references, the edits of a rename) is a lookup in that list.
4
+ //
5
+ // An occurrence is `{ kind, id, role, scope, from, to, tokenFrom, tokenTo, lead, ... }`: `kind` is what the id names (`asset`, `variable`, `dataset`, `column`,
6
+ // `cite`, `slide`, `beat`, `region`, or a catalog kind such as `layouts`), `role` is `def` (where it is declared) or `ref` (where it is used), `scope` tells
7
+ // apart ids that are only unique inside something (a column of a dataset, a beat of a narrative, a record of a catalog group), `from`..`to` is the id itself
8
+ // (what a rename replaces) and `tokenFrom`..`tokenTo` the whole token a click or the caret has to be in (the quoted string, `asset:logo`, `{{name|upper}}`).
9
+ // Plain functions, no DOM.
10
+ import { catalogKinds, catalogReferenceSites, resolveReference, schemas } from "@openpresentation/opf";
11
+ import { mergeCatalogs } from "./catalogs.js";
12
+ import { childOf, nodeAtPath, plainOf, propOf, readDocument, readDocumentInPieces, yieldToBrowser } from "./source-navigation-read.js";
13
+ import { columnSourceFor, flatRegions, beatsOf, referenceSiteAt, valueAt } from "./source-references.js";
14
+
15
+ export const NOUNS = Object.freeze({
16
+ asset: "asset", variable: "variable", dataset: "dataset", column: "dataset column", cite: "reference", slide: "slide", beat: "narrative beat", region: "layout region",
17
+ layouts: "layout", themes: "theme", colorSchemes: "color scheme", fontSchemes: "font scheme", narratives: "narrative", audiences: "audience", purposes: "purpose", tones: "tone",
18
+ });
19
+
20
+ /** The kinds a rename can change (the ids the document itself declares). */
21
+ const RENAMABLE = new Set(["asset", "variable", "dataset", "cite", "slide", ...catalogKinds]);
22
+
23
+ const BUILT_IN = /^(?:speakers$|(?:deck|speaker|organization|slide)\.)/;
24
+ const TOKEN = /(?<![\w.:/-])(asset|var):([A-Za-z0-9][A-Za-z0-9._-]*)(?![A-Za-z0-9_\\-])/g;
25
+ const MUSTACHE = /\\?\{\{\s*([A-Za-z][A-Za-z0-9._-]*)\s*(?:\|[^{}\n]*)?\}\}/dg;
26
+ // Keys that name a column of a dataset (REFERENCE_RULES says which field takes which, and the schema decides: these are only the keys worth asking about).
27
+ const COLUMN_KEYS = new Set(["category", "x", "series", "line", "secondaryAxis", "fields"]);
28
+ // Subtrees whose strings are declarations or data, not uses.
29
+ const SKIP_WALK = new Set(["assets", "datasets", "variables", "catalogs", "extensions"]);
30
+
31
+ // ---- what an id looks like ----------------------------------------------------------------------------------------------------------
32
+ const definitions = schemas?.presentation?.$defs ?? {};
33
+ const fromSchema = (...path) => { let node = definitions; for (const step of path) node = node?.[step]; return typeof node === "string" ? new RegExp(node) : null; };
34
+ const SAFE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
35
+ const LOOSE_HINT = "must start with a letter or a digit and use only letters, digits, '.', '_' and '-'";
36
+ const KEBAB_HINT = "must be lowercase: start with a letter, then lowercase letters, digits and '-'";
37
+ const KEBAB = /^[a-z][a-z0-9-]*$/;
38
+ const PATTERNS = {
39
+ asset: { re: fromSchema("Assets", "propertyNames", "pattern") ?? SAFE, hint: LOOSE_HINT },
40
+ dataset: { re: fromSchema("Datasets", "propertyNames", "pattern") ?? SAFE, hint: LOOSE_HINT },
41
+ variable: { re: fromSchema("Variables", "propertyNames", "pattern") ?? KEBAB, hint: KEBAB_HINT },
42
+ catalog: { re: fromSchema("CatalogRecords", "propertyNames", "pattern") ?? KEBAB, hint: KEBAB_HINT },
43
+ // The schema leaves these two free; the editor keeps them to the characters that need no quoting in any of the three formats.
44
+ cite: { re: SAFE, hint: LOOSE_HINT },
45
+ slide: { re: SAFE, hint: LOOSE_HINT },
46
+ };
47
+ const patternFor = (kind) => PATTERNS[catalogKinds.includes(kind) ? "catalog" : kind];
48
+
49
+ /** A YAML plain scalar that would read as something other than text (`true`, `no`, `12`, `1e3`, `null`). */
50
+ const readsAsOther = (name) => /^(?:true|false|yes|no|on|off|y|n|null|~)$/i.test(name) || /^[-+]?(?:\.[0-9]|[0-9])[0-9_.eE+-]*$/.test(name) || /^0[xob][0-9a-f]+$/i.test(name);
51
+
52
+ // ---- lines --------------------------------------------------------------------------------------------------------------------------
53
+ function lineStarts(text) {
54
+ const starts = [0];
55
+ for (let index = 0; index < text.length; index++) {
56
+ const code = text.charCodeAt(index);
57
+ if (code === 13) { if (text.charCodeAt(index + 1) === 10) index++; starts.push(index + 1); } else if (code === 10) starts.push(index + 1);
58
+ }
59
+ return starts;
60
+ }
61
+
62
+ /** `{ number, from, to, text }` for the line (one-based) that holds `offset`. */
63
+ export function lineAt(analysis, offset) {
64
+ const starts = analysis.lines ??= lineStarts(analysis.text);
65
+ let low = 0, high = starts.length;
66
+ while (low + 1 < high) {
67
+ const middle = (low + high) >> 1;
68
+ if (starts[middle] <= offset) low = middle; else high = middle;
69
+ }
70
+ const from = starts[low];
71
+ let to = low + 1 < starts.length ? starts[low + 1] : analysis.text.length;
72
+ while (to > from && (analysis.text[to - 1] === "\n" || analysis.text[to - 1] === "\r")) to--;
73
+ return { number: low + 1, from, to, text: analysis.text.slice(from, to) };
74
+ }
75
+
76
+ /** The line (one-based `number`) as `lineAt` gives it, and how many lines the text has. */
77
+ export function lineByNumber(analysis, number) {
78
+ const starts = analysis.lines ??= lineStarts(analysis.text);
79
+ return lineAt(analysis, starts[Math.max(0, Math.min(starts.length - 1, number - 1))]);
80
+ }
81
+ export const lineCount = (analysis) => (analysis.lines ??= lineStarts(analysis.text)).length;
82
+
83
+ // ---- reading the occurrences --------------------------------------------------------------------------------------------------------
84
+ const stringValue = (node) => (node?.t === "string" ? node.value : undefined);
85
+
86
+ /**
87
+ * Read `text` (`format`: "json", "yaml" or "md") once: `{ text, format, occurrences, root, catalogs }`. `catalogs` are the registered catalogs of the host.
88
+ */
89
+ export function analyze(text, format, options = {}) {
90
+ return analysisOf(text, format, readDocument(text, format), options);
91
+ }
92
+
93
+ /** `analyze` for the code editor: a big YAML text is read in pieces, with the main thread given back between them, so no task is as long as the text is big. */
94
+ export async function analyzeInPieces(text, format, options = {}) {
95
+ const read = await readDocumentInPieces(text, format);
96
+ await yieldToBrowser();
97
+ return analysisOf(text, format, read, options);
98
+ }
99
+
100
+ function analysisOf(text, format, read, { catalogs = [] } = {}) {
101
+ let registered = [];
102
+ try { registered = mergeCatalogs(catalogs); } catch { registered = []; }
103
+ const analysis = { text, format, root: read.root, scan: read.scan, catalogs: registered, occurrences: [], resolved: new Map() };
104
+ if (read.root) collect(analysis);
105
+ collectTokens(analysis);
106
+ analysis.occurrences.sort((a, b) => a.from - b.from || a.tokenFrom - b.tokenFrom);
107
+ return analysis;
108
+ }
109
+
110
+ function add(analysis, kind, id, role, from, to, extra = {}) {
111
+ const occurrence = { kind, id, role, scope: "", from, to, tokenFrom: from, tokenTo: to, lead: "", ...extra };
112
+ analysis.occurrences.push(occurrence);
113
+ return occurrence;
114
+ }
115
+
116
+ // A declaration or a use written as a whole key or string: the token is the quoted text, the id its content.
117
+ function keyOccurrence(analysis, kind, prop, extra) {
118
+ return add(analysis, kind, prop.key, "def", prop.cFrom, prop.cTo, { tokenFrom: prop.from, tokenTo: prop.to, blockTo: Math.max(prop.to, prop.value?.to ?? 0), bare: analysis.format !== "json" && prop.cFrom === prop.from, ...extra });
119
+ }
120
+
121
+ function stringOccurrence(analysis, kind, node, role, extra) {
122
+ return add(analysis, kind, node.value, role, node.cFrom, node.cTo, { tokenFrom: node.from, tokenTo: node.to, bare: analysis.format !== "json" && !node.quoted && !node.block, ...extra });
123
+ }
124
+
125
+ function resolveCached(analysis, kind, reference, group) {
126
+ const key = `${kind}\0${reference}\0${group ?? ""}`;
127
+ if (analysis.resolved.has(key)) return analysis.resolved.get(key);
128
+ let found;
129
+ try { found = resolveReference({ catalogs: analysis.catalogsPlain ??= plainOf(childOf(analysis.root, "catalogs")) }, kind, reference, { catalogs: analysis.catalogs, ...(group ? { group } : {}) }); } catch { found = undefined; }
130
+ analysis.resolved.set(key, found);
131
+ return found;
132
+ }
133
+
134
+ const identityOf = (resolved) => (resolved ? `${resolved.group}:${resolved.id}` : null);
135
+
136
+ function collect(analysis) {
137
+ const { root } = analysis;
138
+ const prop = (key) => propOf(root, key);
139
+
140
+ // Declarations -----------------------------------------------------------------------------------------------------------------------
141
+ for (const [key, kind] of [["assets", "asset"], ["variables", "variable"]]) {
142
+ const map = prop(key)?.value;
143
+ if (map?.t === "object") for (const entry of map.props) keyOccurrence(analysis, kind, entry);
144
+ }
145
+ const datasets = prop("datasets")?.value;
146
+ if (datasets?.t === "object") {
147
+ for (const entry of datasets.props) {
148
+ keyOccurrence(analysis, "dataset", entry);
149
+ const columns = childOf(entry.value, "columns");
150
+ if (columns?.t !== "array") continue;
151
+ for (const column of columns.items) {
152
+ const name = column.t === "string" ? column : childOf(column, "name");
153
+ if (name?.t === "string" && name.value) stringOccurrence(analysis, "column", name, "def", { scope: entry.key });
154
+ }
155
+ }
156
+ }
157
+ const references = prop("references")?.value;
158
+ if (references?.t === "array") for (const entry of references.items) { const id = childOf(entry, "id"); if (id?.t === "string" && id.value) stringOccurrence(analysis, "cite", id, "def"); }
159
+ const groups = prop("catalogs")?.value;
160
+ if (groups?.t === "object") {
161
+ for (const group of groups.props) {
162
+ if (group.value?.t !== "object") continue;
163
+ for (const kind of catalogKinds) {
164
+ const records = childOf(group.value, kind);
165
+ if (records?.t !== "object") continue;
166
+ for (const record of records.props) {
167
+ keyOccurrence(analysis, kind, record, { scope: group.key });
168
+ const identity = `${group.key}:${record.key}`;
169
+ if (kind === "layouts") declareRegions(analysis, childOf(record.value, "regions"), identity);
170
+ if (kind === "narratives") {
171
+ const beats = childOf(record.value, "beats");
172
+ if (beats?.t === "array") for (const beat of beats.items) { const id = childOf(beat, "id"); if (id?.t === "string" && id.value) stringOccurrence(analysis, "beat", id, "def", { scope: identity }); }
173
+ }
174
+ }
175
+ }
176
+ }
177
+ }
178
+ const slides = prop("slides")?.value;
179
+ if (slides?.t === "array") {
180
+ for (const slide of slides.items) { const id = childOf(slide, "id"); if (id?.t === "string" && id.value) stringOccurrence(analysis, "slide", id, "def"); }
181
+ }
182
+
183
+ // Uses of catalog ids (core lists every place the schema reads one) -------------------------------------------------------------------
184
+ const narrativeNode = prop("narrative")?.value;
185
+ const narrative = stringValue(narrativeNode) ? resolveCached(analysis, "narratives", narrativeNode.value) : undefined;
186
+ const trimmed = {};
187
+ for (const key of ["narrative", "audience", "purpose", "tone", "language", "design", "catalogs"]) if (prop(key)) trimmed[key] = plainOf(prop(key).value);
188
+ trimmed.slides = (slides?.t === "array" ? slides.items : []).map((slide) => ({ layout: plainOf(childOf(slide, "layout")), design: plainOf(childOf(slide, "design")) }));
189
+ let sites = [];
190
+ try { sites = catalogReferenceSites(trimmed); } catch { sites = []; }
191
+ for (const site of sites) {
192
+ const node = nodeAtPath(root, site.path);
193
+ if (node?.t !== "string" || !node.value) continue;
194
+ const colon = node.value.indexOf(":");
195
+ const resolved = resolveCached(analysis, site.kind, node.value, site.group);
196
+ const group = resolved?.group ?? (colon > 0 ? node.value.slice(0, colon) : "default");
197
+ const id = colon > 0 ? node.value.slice(colon + 1) : node.value;
198
+ const raw = analysis.text.slice(node.cFrom, node.cTo);
199
+ const exact = raw === node.value;
200
+ const occurrence = stringOccurrence(analysis, site.kind, node, "ref", { scope: group, ref: node.value });
201
+ occurrence.id = id;
202
+ if (exact && colon > 0) { occurrence.from = node.cFrom + colon + 1; occurrence.bare = false; } else if (colon > 0) occurrence.lead = node.value.slice(0, colon + 1);
203
+ }
204
+
205
+ // Uses found by the name of the key ------------------------------------------------------------------------------------------------
206
+ const layoutOf = (slide) => {
207
+ const layout = stringValue(childOf(slide, "layout"));
208
+ if (!layout || layout === "auto") return null;
209
+ return { reference: layout, identity: identityOf(resolveCached(analysis, "layouts", layout)) };
210
+ };
211
+ const columns = [];
212
+ const walk = (node, path, slide, layout) => {
213
+ if (node.t === "array") { node.items.forEach((item, index) => { path.push(index); walk(item, path, slide, layout); path.pop(); }); return; }
214
+ if (node.t !== "object") return;
215
+ for (const entry of node.props) {
216
+ const value = entry.value;
217
+ if (!value || entry.key === "extensions" || (SKIP_WALK.has(entry.key) && path.length === 0)) continue;
218
+ const strings = value.t === "string" ? [value] : value.t === "array" ? value.items.filter((item) => item.t === "string") : [];
219
+ if (entry.key === "dataset" && value.t === "string" && value.value) stringOccurrence(analysis, "dataset", value, "ref");
220
+ else if (entry.key === "cite") for (const item of strings) { if (item.value) stringOccurrence(analysis, "cite", item, "ref"); }
221
+ else if (entry.key === "beat" && slide && path.length === 2) {
222
+ for (const item of strings) if (item.value) stringOccurrence(analysis, "beat", item, "ref", { scope: identityOf(narrative) ?? "", ref: narrativeNode?.value });
223
+ } else if (entry.key === "region" && slide && layout && value.t === "string") regionTokens(analysis, value, layout);
224
+ else if (COLUMN_KEYS.has(entry.key)) {
225
+ if (value.t === "string") columns.push({ path: [...path, entry.key], node: value });
226
+ else if (value.t === "array") value.items.forEach((item, index) => { if (item.t === "string") columns.push({ path: [...path, entry.key, index], node: item }); });
227
+ }
228
+ if (value.t === "object" || value.t === "array") {
229
+ path.push(entry.key);
230
+ walk(value, path, slide, layout);
231
+ path.pop();
232
+ }
233
+ }
234
+ };
235
+ for (const entry of root.props) {
236
+ if (SKIP_WALK.has(entry.key) || !entry.value) continue;
237
+ if (entry.key === "slides" && entry.value.t === "array") {
238
+ entry.value.items.forEach((slide, index) => walk(slide, ["slides", index], true, layoutOf(slide)));
239
+ continue;
240
+ }
241
+ walk({ t: "object", props: [entry] }, [], false, null);
242
+ }
243
+ if (columns.length) columnUses(analysis, columns);
244
+ }
245
+
246
+ // A layout's regions, nested ones included.
247
+ function declareRegions(analysis, regions, scope) {
248
+ if (regions?.t !== "array") return;
249
+ for (const region of regions.items) {
250
+ const name = childOf(region, "name");
251
+ if (name?.t === "string" && name.value) stringOccurrence(analysis, "region", name, "def", { scope });
252
+ declareRegions(analysis, childOf(region, "regions"), scope);
253
+ }
254
+ }
255
+
256
+ // `region` is a name or names joined by `+` and `:` (`left`, `top:left`, `middle+bottom`): each name is a use.
257
+ function regionTokens(analysis, node, layout) {
258
+ if (!layout.identity) return;
259
+ const raw = analysis.text.slice(node.cFrom, node.cTo);
260
+ if (raw !== node.value) return;
261
+ const re = /[A-Za-z0-9_-]+/g;
262
+ for (let match = re.exec(raw); match; match = re.exec(raw)) {
263
+ add(analysis, "region", match[0], "ref", node.cFrom + match.index, node.cFrom + match.index + match[0].length, { scope: layout.identity, ref: layout.reference });
264
+ }
265
+ }
266
+
267
+ // Dataset columns named by chart and table fields: only where the schema says the field names columns (`referenceSiteAt`) and the dataset is known.
268
+ function columnUses(analysis, candidates) {
269
+ const plain = plainOf(analysis.root, (key, depth) => depth === 0 && (key === "catalogs" || key === "assets"));
270
+ const read = (path) => valueAt(plain, path);
271
+ // The schema answers for the shape of a path, not for its indexes: one answer per shape.
272
+ const shapes = new Map();
273
+ for (const candidate of candidates) {
274
+ if (!candidate.node.value) continue;
275
+ const shape = candidate.path.map((step) => (typeof step === "number" ? "#" : step)).join("/");
276
+ if (!shapes.has(shape)) { const site = referenceSiteAt(candidate.path, plain); shapes.set(shape, site?.kind === "datasetColumn" ? candidate.path.length - site.path.length : null); }
277
+ const trim = shapes.get(shape);
278
+ if (trim === null) continue;
279
+ const source = columnSourceFor({ kind: "datasetColumn", path: candidate.path.slice(0, candidate.path.length - trim) }, read);
280
+ if (source?.dataset !== undefined) stringOccurrence(analysis, "column", candidate.node, "ref", { scope: source.dataset });
281
+ }
282
+ }
283
+
284
+ // `asset:id`, `var:id` and `{{name}}` are found in the text itself, wherever they are written (a title, a front matter value, a Markdown image).
285
+ function collectTokens(analysis) {
286
+ const { text } = analysis;
287
+ const skip = [];
288
+ for (const fence of analysis.scan?.fences ?? []) if (fence.kind === "code") skip.push([fence.from, fence.to]);
289
+ for (const comment of analysis.scan?.comments ?? []) if (comment.kind === "comment") skip.push([comment.from, comment.to]);
290
+ const skipped = (index) => skip.some(([from, to]) => index >= from && index < to);
291
+ const variables = new Set(analysis.occurrences.filter((o) => o.kind === "variable" && o.role === "def").map((o) => o.id));
292
+ TOKEN.lastIndex = 0;
293
+ for (let match = TOKEN.exec(text); match; match = TOKEN.exec(text)) {
294
+ let id = match[2];
295
+ while (id.endsWith(".")) id = id.slice(0, -1);
296
+ if (!id || (skip.length && skipped(match.index))) continue;
297
+ const from = match.index + match[1].length + 1;
298
+ let tokenFrom = match.index, tokenTo = from + id.length;
299
+ const quote = text[tokenFrom - 1];
300
+ if ((quote === '"' || quote === "'") && text[tokenTo] === quote) { tokenFrom--; tokenTo++; }
301
+ if (match[1] === "asset") add(analysis, "asset", id, "ref", from, from + id.length, { tokenFrom, tokenTo });
302
+ else add(analysis, "variable", id, "ref", from, from + id.length, { tokenFrom, tokenTo, builtin: BUILT_IN.test(id) && !variables.has(id) });
303
+ }
304
+ MUSTACHE.lastIndex = 0;
305
+ for (let match = MUSTACHE.exec(text); match; match = MUSTACHE.exec(text)) {
306
+ if (match[0][0] === "\\" || (skip.length && skipped(match.index))) continue;
307
+ const [from, to] = match.indices[1];
308
+ add(analysis, "variable", match[1], "ref", from, to, { tokenFrom: match.index, tokenTo: match.index + match[0].length, builtin: BUILT_IN.test(match[1]) && !variables.has(match[1]), mustache: true });
309
+ }
310
+ }
311
+
312
+ // ---- lookups ------------------------------------------------------------------------------------------------------------------------
313
+ /** The key two occurrences share when they are the same symbol. */
314
+ export const symbolKey = (occurrence) => `${occurrence.kind}\0${occurrence.scope}\0${occurrence.id}`;
315
+
316
+ /** The symbol under (or just before) `offset`: the occurrence whose token holds it, the smallest when several do. */
317
+ export function symbolAt(analysis, offset) {
318
+ let best = null;
319
+ for (const occurrence of analysis.occurrences) {
320
+ if (occurrence.tokenFrom > offset || offset > occurrence.tokenTo) continue;
321
+ // The id itself wins over the rest of its token (`asset:` of `asset:logo`), and a smaller token over a larger one.
322
+ const inside = offset >= occurrence.from && offset <= occurrence.to;
323
+ const span = occurrence.tokenTo - occurrence.tokenFrom;
324
+ if (!best || (inside && !best.inside) || (inside === best.inside && span < best.span)) best = { occurrence, inside, span };
325
+ }
326
+ return best?.occurrence ?? null;
327
+ }
328
+
329
+ /** Every occurrence of the symbol of `occurrence`, in text order. */
330
+ export function occurrencesOf(analysis, occurrence) {
331
+ const key = symbolKey(occurrence);
332
+ return analysis.occurrences.filter((other) => symbolKey(other) === key);
333
+ }
334
+
335
+ /** The declarations of the symbol, in text order. */
336
+ export const declarationsOf = (analysis, occurrence) => occurrencesOf(analysis, occurrence).filter((other) => other.role === "def");
337
+
338
+ /**
339
+ * Where the symbol is defined: `{ where: "document", occurrence }` (a declaration in the text), `{ where: "app", record, group, source, region?, beat? }` (a
340
+ * record only a registered catalog has), `{ where: "built-in" }` or null (nothing defines it).
341
+ */
342
+ export function definitionOf(analysis, occurrence) {
343
+ if (occurrence.builtin) return { where: "built-in" };
344
+ const declared = declarationsOf(analysis, occurrence)[0];
345
+ if (declared) return { where: "document", occurrence: declared };
346
+ if (catalogKinds.includes(occurrence.kind)) {
347
+ const reference = occurrence.ref ?? occurrence.id;
348
+ const resolved = resolveCached(analysis, occurrence.kind, reference);
349
+ if (resolved?.origin === "host") return { where: "app", kind: occurrence.kind, reference, id: resolved.id, group: resolved.group, source: resolved.source, record: resolved.record };
350
+ return null;
351
+ }
352
+ if (occurrence.kind === "region" || occurrence.kind === "beat") {
353
+ const owner = occurrence.kind === "region" ? "layouts" : "narratives";
354
+ const resolved = occurrence.ref ? resolveCached(analysis, owner, occurrence.ref) : undefined;
355
+ if (resolved?.origin !== "host") return null;
356
+ const found = occurrence.kind === "region" ? flatRegions(resolved.record).find((region) => region.name === occurrence.id) : beatsOf(resolved.record).find((beat) => beat.id === occurrence.id);
357
+ return found ? { where: "app", kind: occurrence.kind, reference: occurrence.ref, id: occurrence.id, group: resolved.group, source: resolved.source, record: resolved.record, member: found } : null;
358
+ }
359
+ return null;
360
+ }
361
+
362
+ /** Why a rename cannot happen for this symbol, or "". */
363
+ export function renameBlocker(analysis, occurrence, { readOnly = false } = {}) {
364
+ const label = `${NOUNS[occurrence.kind] ?? occurrence.kind} ${occurrence.id}`;
365
+ if (readOnly) return "Cannot rename: the editor is read-only";
366
+ if (occurrence.builtin) return `Cannot rename: ${occurrence.id} is a built-in variable`;
367
+ if (!RENAMABLE.has(occurrence.kind)) return `Cannot rename: a ${NOUNS[occurrence.kind]} is named where it is declared, in the ${occurrence.kind === "column" ? "dataset" : occurrence.kind === "region" ? "layout" : "narrative"} record`;
368
+ if (!declarationsOf(analysis, occurrence).length) {
369
+ if (catalogKinds.includes(occurrence.kind) && definitionOf(analysis, occurrence)?.where === "app") return `Cannot rename: ${label} is provided by the app`;
370
+ return `Cannot rename: ${label} is not declared in this document`;
371
+ }
372
+ return "";
373
+ }
374
+
375
+ /** The message for a name the symbol cannot take, or "". */
376
+ export function validateName(analysis, occurrence, name) {
377
+ if (!name || !name.trim()) return "The name cannot be empty";
378
+ if (name === occurrence.id) return "";
379
+ const pattern = patternFor(occurrence.kind);
380
+ if (pattern && !pattern.re.test(name)) return `A ${NOUNS[occurrence.kind]} id ${pattern.hint}`;
381
+ const taken = analysis.occurrences.some((other) => other.role === "def" && other.kind === occurrence.kind && other.scope === occurrence.scope && other.id === name);
382
+ if (taken) return `${NOUNS[occurrence.kind][0].toUpperCase()}${NOUNS[occurrence.kind].slice(1)} ${name} already exists`;
383
+ return "";
384
+ }
385
+
386
+ /**
387
+ * The edits that rename the symbol to `name`: `{ edits: [{ from, to, insert }], caret: [from, to] }` (`caret` is the renamed symbol at the occurrence given,
388
+ * as offsets of the text after the edits), or null when the name is the same. The edits are in offsets of the current text and do not overlap.
389
+ */
390
+ export function renameEdits(analysis, occurrence, name) {
391
+ if (name === occurrence.id) return null;
392
+ const all = occurrencesOf(analysis, occurrence);
393
+ const edits = [];
394
+ for (const other of all) {
395
+ const quote = analysis.format !== "json" && other.bare && readsAsOther(name);
396
+ edits.push({ from: other.from, to: other.to, insert: quote ? `"${name}"` : `${other.lead}${name}`, other });
397
+ }
398
+ edits.sort((a, b) => a.from - b.from);
399
+ let shift = 0, caret = [occurrence.from, occurrence.to];
400
+ for (const edit of edits) {
401
+ if (edit.other === occurrence) {
402
+ const quoted = edit.insert.startsWith('"') && edit.other.bare;
403
+ caret = [edit.from + shift + (quoted ? 1 : edit.other.lead.length), edit.from + shift + edit.insert.length - (quoted ? 1 : 0)];
404
+ }
405
+ shift += edit.insert.length - (edit.to - edit.from);
406
+ }
407
+ return { edits: edits.map(({ from, to, insert }) => ({ from, to, insert })), caret };
408
+ }
409
+
410
+ /** The one-based number of the slide that holds `offset`, or null. */
411
+ export function slideNumberAt(analysis, offset) {
412
+ const slides = childOf(analysis.root, "slides");
413
+ if (slides?.t !== "array") return null;
414
+ let low = 0, high = slides.items.length - 1;
415
+ while (low <= high) {
416
+ const middle = (low + high) >> 1, slide = slides.items[middle];
417
+ if (offset < slide.from) high = middle - 1; else if (offset > slide.to) low = middle + 1; else return middle + 1;
418
+ }
419
+ return null;
420
+ }
421
+
422
+ /** The references of the symbol for a list: the declarations first, then the uses in text order. */
423
+ export function referenceList(analysis, occurrence) {
424
+ const all = occurrencesOf(analysis, occurrence);
425
+ return [...all.filter((other) => other.role === "def"), ...all.filter((other) => other.role !== "def")];
426
+ }