@jxsuite/schema 5.6.0 → 6.0.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/README.md +1 -0
- package/package.json +5 -1
- package/schema.json +15 -0
- package/src/document-scripts.ts +304 -0
- package/src/dynamic-tag.ts +570 -0
- package/src/guards.ts +179 -4
- package/src/jxn/complete.ts +582 -0
- package/src/jxn/document-scopes.ts +581 -0
- package/src/jxn/document.ts +6 -0
- package/src/jxn/index.ts +14 -2
- package/src/jxn/outline.ts +452 -0
- package/src/jxn/parse.ts +234 -13
- package/src/jxn/schema-types.ts +299 -0
- package/src/jxn/schema-walk.ts +221 -0
- package/src/jxn/serialize.ts +113 -5
- package/src/jxn/state-types.ts +271 -0
- package/src/jxn/string-pieces.ts +240 -0
- package/src/jxn/virtual-document.ts +1077 -0
- package/src/jxn/virtual-types.ts +94 -169
- package/src/schema.ts +22 -0
- package/types.ts +16 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a document's `state` says its types are (`spec.md` §23.3), for the two files a checker
|
|
3
|
+
* reads: one body wrapped alone (`virtual-types.ts`) and a whole `.jxn` document
|
|
4
|
+
* (`virtual-document.ts`). Both say the same thing about the same entry because both ask here.
|
|
5
|
+
*
|
|
6
|
+
* **What `State` says is what the document can prove, and no more.** A literal gives its type, an
|
|
7
|
+
* object its shape and an array the types of its items. Anything a document does not determine (a
|
|
8
|
+
* `$prototype` instance, an `$expression`, a template, a `timing: "server"` call, a `null`) is
|
|
9
|
+
* `any`, because an error about code the author cannot fix teaches them to ignore the checker.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
isBoolean,
|
|
13
|
+
isExpressionDef,
|
|
14
|
+
isFunctionDef,
|
|
15
|
+
isJsonObject,
|
|
16
|
+
isNumber,
|
|
17
|
+
isServerFnDef,
|
|
18
|
+
isString,
|
|
19
|
+
hasSchemaKeywords,
|
|
20
|
+
} from "../guards";
|
|
21
|
+
import { pointerInto, propertyName, schemaParts, written } from "./schema-types";
|
|
22
|
+
import type { JsonObject, JsonValue, JxElement, ProjectConfig } from "../../types";
|
|
23
|
+
import type { SchemaSite, TypePart } from "./schema-types";
|
|
24
|
+
|
|
25
|
+
/** The most levels of an object's shape written out before it is `any`. */
|
|
26
|
+
const MAX_NESTING = 4;
|
|
27
|
+
|
|
28
|
+
/** The most distinct item types an array's type is built from. */
|
|
29
|
+
const MAX_ITEM_TYPES = 6;
|
|
30
|
+
|
|
31
|
+
/** What a `type` keyword says with nothing beside it, which a value written there says better. */
|
|
32
|
+
const VAGUE = new Set(["any", "any[]", "{ [key: string]: any; }"]);
|
|
33
|
+
|
|
34
|
+
/** The type a value's shape gives it, widened the way a mutable binding widens. */
|
|
35
|
+
function typeOfValue(value: JsonValue | undefined, depth: number): string {
|
|
36
|
+
if (isString(value)) {
|
|
37
|
+
return "string";
|
|
38
|
+
}
|
|
39
|
+
if (isNumber(value)) {
|
|
40
|
+
return "number";
|
|
41
|
+
}
|
|
42
|
+
if (isBoolean(value)) {
|
|
43
|
+
return "boolean";
|
|
44
|
+
}
|
|
45
|
+
if (Array.isArray(value)) {
|
|
46
|
+
const types = [...new Set(value.map((item) => typeOfValue(item, depth + 1)))];
|
|
47
|
+
if (types.length === 0 || types.length > MAX_ITEM_TYPES || types.includes("any")) {
|
|
48
|
+
return "any[]";
|
|
49
|
+
}
|
|
50
|
+
return types.length === 1 ? `${types[0]}[]` : `(${types.join(" | ")})[]`;
|
|
51
|
+
}
|
|
52
|
+
if (isJsonObject(value) && depth < MAX_NESTING) {
|
|
53
|
+
const members = Object.entries(value).map(
|
|
54
|
+
([key, item]) => `${propertyName(key)}: ${typeOfValue(item, depth + 1)}`,
|
|
55
|
+
);
|
|
56
|
+
return members.length === 0 ? "Record<string, any>" : `{ ${members.join("; ")} }`;
|
|
57
|
+
}
|
|
58
|
+
return "any";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The type a typed entry declares (`spec.md` §5.3): `type` holds its schema, or is the first
|
|
63
|
+
* keyword of one whose others stand beside it.
|
|
64
|
+
*/
|
|
65
|
+
function declaredParts(entry: JsonObject, site: SchemaSite): TypePart[] {
|
|
66
|
+
const declared = isJsonObject(entry.type)
|
|
67
|
+
? schemaParts(entry.type, { ...site, pointer: pointerInto(site.pointer, "type") })
|
|
68
|
+
: schemaParts(entry, site);
|
|
69
|
+
const { default: initial } = entry;
|
|
70
|
+
// `type: "array"` beside `default: [1, 2]` is a list of numbers, which only the value says.
|
|
71
|
+
const told = initial !== undefined && initial !== null && VAGUE.has(written(declared));
|
|
72
|
+
return told ? [typeOfValue(initial, 0)] : declared;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The type of one `state` entry, by the shapes of `spec.md` §5, in parts.
|
|
77
|
+
*
|
|
78
|
+
* @param entry The entry.
|
|
79
|
+
* @param role What its document uses it as, when it is a function.
|
|
80
|
+
* @param site Where it is, and whether its document has `$defs`.
|
|
81
|
+
*/
|
|
82
|
+
export function entryParts(
|
|
83
|
+
entry: JsonValue | undefined,
|
|
84
|
+
role: "computed" | "callable" | undefined,
|
|
85
|
+
site: SchemaSite,
|
|
86
|
+
): TypePart[] {
|
|
87
|
+
if (isString(entry)) {
|
|
88
|
+
// A template is a computed value of a type the document does not give.
|
|
89
|
+
return [entry.includes("${") ? "any" : "string"];
|
|
90
|
+
}
|
|
91
|
+
if (!isJsonObject(entry)) {
|
|
92
|
+
return [typeOfValue(entry, 0)];
|
|
93
|
+
}
|
|
94
|
+
if (isFunctionDef(entry)) {
|
|
95
|
+
return [role === "computed" ? "any" : "(...args: any[]) => any"];
|
|
96
|
+
}
|
|
97
|
+
if (isString(entry.$prototype) || isExpressionDef(entry) || isServerFnDef(entry)) {
|
|
98
|
+
return ["any"];
|
|
99
|
+
}
|
|
100
|
+
return "default" in entry || hasSchemaKeywords(entry)
|
|
101
|
+
? declaredParts(entry, site)
|
|
102
|
+
: [typeOfValue(entry, 0)];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The type of one `state` entry, as text.
|
|
107
|
+
*
|
|
108
|
+
* @param entry The entry.
|
|
109
|
+
* @param role What its document uses it as, when it is a function.
|
|
110
|
+
* @param defs Whether its document has `$defs`.
|
|
111
|
+
*/
|
|
112
|
+
export function typeOfEntry(
|
|
113
|
+
entry: JsonValue | undefined,
|
|
114
|
+
role?: "computed" | "callable",
|
|
115
|
+
defs = false,
|
|
116
|
+
): string {
|
|
117
|
+
return written(entryParts(entry, role, { defs, pointer: "" }));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The `state` object of an element, when it has one. */
|
|
121
|
+
export function stateOf(node: JsonValue | undefined): Record<string, JsonValue> | null {
|
|
122
|
+
return isJsonObject(node) && isJsonObject(node.state) ? node.state : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One parameter a function's body is called with. */
|
|
126
|
+
export interface DeclaredParameter {
|
|
127
|
+
/** Where the entry writes it in `parameters`, or -1 where it does not. */
|
|
128
|
+
at: number;
|
|
129
|
+
name: string;
|
|
130
|
+
/** The type the entry declares for it, or null where it declares none. */
|
|
131
|
+
type: TypePart[] | null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The type a parameter written as an object declares (`spec.md` §23.3): the TypeScript its `text`
|
|
136
|
+
* holds, as a Custom Elements Manifest has it, or the schema it is, as a typed entry is one.
|
|
137
|
+
*/
|
|
138
|
+
function parameterType(parameter: JsonObject, site: SchemaSite): TypePart[] | null {
|
|
139
|
+
const { type } = parameter;
|
|
140
|
+
if (type === undefined) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const text = isJsonObject(type) && isString(type.text) ? type.text : null;
|
|
144
|
+
if (text?.trim() === "") {
|
|
145
|
+
// Nothing is no type, and written as one it would be an error on a line nobody wrote.
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const pointer = pointerInto(pointerInto(site.pointer, "type"), "text");
|
|
149
|
+
const declared: TypePart[] =
|
|
150
|
+
text === null
|
|
151
|
+
? declaredParts(parameter, site)
|
|
152
|
+
: /* In brackets, so that what follows it is not read into a type that ends in a function's,
|
|
153
|
+
and closed on a line of its own, so that a comment the text ends in takes nothing. */
|
|
154
|
+
["(", { kind: "text", name: text, pointer, written: text }, "\n)"];
|
|
155
|
+
return parameter.optional === true ? [...declared, " | undefined"] : declared;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The parameters a function's body is called with: `state` first, then the declared ones in their
|
|
160
|
+
* order (`spec.md` §5.3), each with the type it declares.
|
|
161
|
+
*
|
|
162
|
+
* @param definition The function's entry.
|
|
163
|
+
* @param site Where the entry is, and whether its document has `$defs`.
|
|
164
|
+
*/
|
|
165
|
+
export function declaredParameters(
|
|
166
|
+
definition: JsonValue | undefined,
|
|
167
|
+
site: SchemaSite,
|
|
168
|
+
): DeclaredParameter[] {
|
|
169
|
+
const entry = isJsonObject(definition) ? definition : {};
|
|
170
|
+
const within = pointerInto(site.pointer, "parameters");
|
|
171
|
+
const listed: DeclaredParameter[] = Array.isArray(entry.parameters)
|
|
172
|
+
? entry.parameters.flatMap((parameter, at) => {
|
|
173
|
+
if (isString(parameter)) {
|
|
174
|
+
return [{ at, name: parameter, type: null }];
|
|
175
|
+
}
|
|
176
|
+
// What is neither a name nor an object holding one is not a parameter the runtime could bind.
|
|
177
|
+
return isJsonObject(parameter) && isString(parameter.name)
|
|
178
|
+
? [
|
|
179
|
+
{
|
|
180
|
+
at,
|
|
181
|
+
name: parameter.name,
|
|
182
|
+
type: parameterType(parameter, { ...site, pointer: pointerInto(within, at) }),
|
|
183
|
+
},
|
|
184
|
+
]
|
|
185
|
+
: [];
|
|
186
|
+
})
|
|
187
|
+
: (Array.isArray(entry.arguments) ? entry.arguments : []).flatMap((name) =>
|
|
188
|
+
isString(name) ? [{ at: -1, name, type: null }] : [],
|
|
189
|
+
);
|
|
190
|
+
// A declared `state` is moved to the front, never repeated, as the runtime builds it.
|
|
191
|
+
const state = listed.find((parameter) => parameter.name === "state");
|
|
192
|
+
return [
|
|
193
|
+
{ at: state?.at ?? -1, name: "state", type: null },
|
|
194
|
+
...listed.filter((parameter) => parameter.name !== "state"),
|
|
195
|
+
];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** What a document is given that it does not say, which only its project knows. */
|
|
199
|
+
export interface OutsideState {
|
|
200
|
+
/**
|
|
201
|
+
* The components the project keeps (`imports.md` §1.4), which a document uses without declaring
|
|
202
|
+
* them: the `.jxn` file each one is, by its tag, as a path from the document.
|
|
203
|
+
*/
|
|
204
|
+
components?: Readonly<Record<string, string>> | undefined;
|
|
205
|
+
/** The project names a default layout, which wraps a document that names none. */
|
|
206
|
+
layout?: boolean | undefined;
|
|
207
|
+
/** The project has `state` of its own, which every page reads (`site-architecture.md` §3.2). */
|
|
208
|
+
state?: boolean | undefined;
|
|
209
|
+
/**
|
|
210
|
+
* The document is a layout. A page is merged with its layout before anything runs, the page's
|
|
211
|
+
* state over the layout's, so a layout reads names only a page declares (`spec.md` §23.3).
|
|
212
|
+
*/
|
|
213
|
+
wraps?: boolean | undefined;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** What a project gives the documents in it, which its file alone says. */
|
|
217
|
+
export interface ProjectGives {
|
|
218
|
+
/** It names a default layout. */
|
|
219
|
+
layout: boolean;
|
|
220
|
+
/** It has `state` of its own. */
|
|
221
|
+
state: boolean;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* What a project's file says the project gives its documents.
|
|
226
|
+
*
|
|
227
|
+
* @param config The project's configuration, as its file holds it.
|
|
228
|
+
*/
|
|
229
|
+
export function givenByProject(config: ProjectConfig): ProjectGives {
|
|
230
|
+
return {
|
|
231
|
+
layout: isString(config.defaults?.layout),
|
|
232
|
+
state: Object.keys(config.state ?? {}).length > 0,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* What a document is given by where its project keeps it.
|
|
238
|
+
*
|
|
239
|
+
* A component is a scope of its own, and nothing of the project's is merged into it. A layout is
|
|
240
|
+
* what wraps a page: the default one does not wrap it, and the state of each page it wraps reaches
|
|
241
|
+
* it. Every other document is a page, as far as its state goes.
|
|
242
|
+
*
|
|
243
|
+
* @param folder The first folder on the document's path from its project: `layouts` for
|
|
244
|
+
* `layouts/base.jxn`.
|
|
245
|
+
* @param gives What the project gives.
|
|
246
|
+
*/
|
|
247
|
+
export function outsideAt(folder: string | undefined, gives: ProjectGives): OutsideState {
|
|
248
|
+
if (folder === "components") {
|
|
249
|
+
return {};
|
|
250
|
+
}
|
|
251
|
+
return folder === "layouts"
|
|
252
|
+
? { state: gives.state, wraps: true }
|
|
253
|
+
: { layout: gives.layout, state: gives.state };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The index signature that closes `State`.
|
|
258
|
+
*
|
|
259
|
+
* A page with a layout inherits state the page does not name, so its `State` is open, and so is one
|
|
260
|
+
* in a project that has `state` of its own, and so is a layout, which is given the state of every
|
|
261
|
+
* page it wraps. Otherwise only the `$` members the host adds (`$media`, `$props`) are. `$layout:
|
|
262
|
+
* false` is a page saying it has no layout, the project's default included.
|
|
263
|
+
*
|
|
264
|
+
* @param doc The document.
|
|
265
|
+
* @param outside What its project gives it. Nothing, when the reader knows of no project.
|
|
266
|
+
*/
|
|
267
|
+
export function stateIndex(doc: JxElement, outside: OutsideState = {}): string {
|
|
268
|
+
const wrapped = doc.$layout === undefined ? outside.layout === true : doc.$layout !== false;
|
|
269
|
+
const given = outside.state === true || outside.wraps === true || wrapped;
|
|
270
|
+
return given ? "[key: string]: any;" : "[key: `$${string}`]: any;";
|
|
271
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where each character of a string's value was written (`spec.md` §23.3).
|
|
3
|
+
*
|
|
4
|
+
* A type error in a function body is found in the body's value, and has to be shown in the file,
|
|
5
|
+
* where that value is spelled as a KDL string: inside quotes, with escapes, or over several lines
|
|
6
|
+
* with the indentation of its block taken off each. This reads one string token and answers with
|
|
7
|
+
* the runs of its value, each beside the source it came from. A run that is the source character
|
|
8
|
+
* for character can be mapped one to one, so a column in it is exact. A run that is not (an escape,
|
|
9
|
+
* a line ending the file spells `\r\n`) is one indivisible piece.
|
|
10
|
+
*
|
|
11
|
+
* `@bgotink/kdl` decodes a string and does not say where its characters came from, so this reads
|
|
12
|
+
* the token again. It is checked against the decoder rather than trusted: when the runs do not
|
|
13
|
+
* spell the value the parser read, the answer is one piece covering the whole token, which places
|
|
14
|
+
* an error on the right string and never on the wrong character.
|
|
15
|
+
*/
|
|
16
|
+
import type { JxnSpan } from "./errors.ts";
|
|
17
|
+
|
|
18
|
+
/** A run of a string's value, beside the source it was written as. */
|
|
19
|
+
export interface StringPiece {
|
|
20
|
+
/** One past the last source character. */
|
|
21
|
+
end: number;
|
|
22
|
+
start: number;
|
|
23
|
+
/** The run of the value. */
|
|
24
|
+
text: string;
|
|
25
|
+
/** Whether {@link StringPiece.text} is the source, character for character. */
|
|
26
|
+
verbatim: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One line of a multi-line string: its text, and where the next line starts. */
|
|
30
|
+
interface Line {
|
|
31
|
+
end: number;
|
|
32
|
+
next: number;
|
|
33
|
+
start: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What KDL calls whitespace inside a line. */
|
|
37
|
+
const SPACE = String.raw`\t - `;
|
|
38
|
+
|
|
39
|
+
/** What KDL calls a line ending. */
|
|
40
|
+
const BREAK = String.raw`\n\r\u0085\v\f
`;
|
|
41
|
+
|
|
42
|
+
const NEWLINE = new RegExp(String.raw`\r\n|[${BREAK}]`, "gu");
|
|
43
|
+
|
|
44
|
+
/** A line holding nothing but whitespace, which a multi-line string reads as an empty one. */
|
|
45
|
+
const BLANK = new RegExp(`^[${SPACE}]*$`, "u");
|
|
46
|
+
|
|
47
|
+
/** What a backslash before whitespace discards: all of it, line endings included. */
|
|
48
|
+
const DISCARDED = new RegExp(`[${SPACE}${BREAK}]+`, "uy");
|
|
49
|
+
|
|
50
|
+
const UNICODE = /u\{([0-9A-Fa-f]{1,6})\}/uy;
|
|
51
|
+
|
|
52
|
+
/** The escapes that stand for one character each. */
|
|
53
|
+
const ESCAPES: ReadonlyMap<string, string> = new Map([
|
|
54
|
+
['"', '"'],
|
|
55
|
+
["\\", "\\"],
|
|
56
|
+
["b", "\b"],
|
|
57
|
+
["f", "\f"],
|
|
58
|
+
["n", "\n"],
|
|
59
|
+
["r", "\r"],
|
|
60
|
+
["s", " "],
|
|
61
|
+
["t", "\t"],
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/** The escape whose backslash is at `at`, or null when it is not one this reads. */
|
|
65
|
+
function readEscape(source: string, at: number, to: number): StringPiece | null {
|
|
66
|
+
const simple = ESCAPES.get(source.charAt(at + 1));
|
|
67
|
+
if (simple !== undefined) {
|
|
68
|
+
return { end: at + 2, start: at, text: simple, verbatim: false };
|
|
69
|
+
}
|
|
70
|
+
UNICODE.lastIndex = at + 1;
|
|
71
|
+
const unicode = UNICODE.exec(source);
|
|
72
|
+
if (unicode) {
|
|
73
|
+
const point = Number.parseInt(unicode[1] ?? "", 16);
|
|
74
|
+
// A surrogate is not a character, and KDL refuses one, so the parser never got this far.
|
|
75
|
+
return point > 0x10_ff_ff || (point >= 0xd8_00 && point <= 0xdf_ff)
|
|
76
|
+
? null
|
|
77
|
+
: { end: UNICODE.lastIndex, start: at, text: String.fromCodePoint(point), verbatim: false };
|
|
78
|
+
}
|
|
79
|
+
DISCARDED.lastIndex = at + 1;
|
|
80
|
+
/* Whitespace cannot run past a closing quote, so the one way this passes `to` is from inside a
|
|
81
|
+
line of a multi-line string, where it joins two lines and the line structure is not ours. */
|
|
82
|
+
return DISCARDED.test(source) && DISCARDED.lastIndex <= to
|
|
83
|
+
? { end: DISCARDED.lastIndex, start: at, text: "", verbatim: false }
|
|
84
|
+
: null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The pieces of a stretch that holds no line structure. False when something in it is not read. */
|
|
88
|
+
function readRun(
|
|
89
|
+
source: string,
|
|
90
|
+
from: number,
|
|
91
|
+
to: number,
|
|
92
|
+
escapes: boolean,
|
|
93
|
+
out: StringPiece[],
|
|
94
|
+
): boolean {
|
|
95
|
+
let at = from;
|
|
96
|
+
while (at < to) {
|
|
97
|
+
const slash = escapes ? source.indexOf("\\", at) : -1;
|
|
98
|
+
const stop = slash === -1 || slash >= to ? to : slash;
|
|
99
|
+
if (stop > at) {
|
|
100
|
+
out.push({ end: stop, start: at, text: source.slice(at, stop), verbatim: true });
|
|
101
|
+
}
|
|
102
|
+
if (stop === to) {
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
const escape = readEscape(source, stop, to);
|
|
106
|
+
if (!escape) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
out.push(escape);
|
|
110
|
+
at = escape.end;
|
|
111
|
+
}
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The lines of a stretch, each with where the next starts. */
|
|
116
|
+
function linesOf(source: string, from: number, to: number): Line[] {
|
|
117
|
+
const lines: Line[] = [];
|
|
118
|
+
let start = from;
|
|
119
|
+
NEWLINE.lastIndex = from;
|
|
120
|
+
for (;;) {
|
|
121
|
+
const match = NEWLINE.exec(source);
|
|
122
|
+
if (!match || match.index >= to) {
|
|
123
|
+
lines.push({ end: to, next: to, start });
|
|
124
|
+
return lines;
|
|
125
|
+
}
|
|
126
|
+
const next = match.index + match[0].length;
|
|
127
|
+
lines.push({ end: match.index, next, start });
|
|
128
|
+
start = next;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The pieces of a multi-line string: its lines without the indentation of the closing line, joined
|
|
134
|
+
* by `\n` whatever the file's line endings are. False when its shape is not the one this reads.
|
|
135
|
+
*/
|
|
136
|
+
function readLines(
|
|
137
|
+
source: string,
|
|
138
|
+
from: number,
|
|
139
|
+
to: number,
|
|
140
|
+
escapes: boolean,
|
|
141
|
+
out: StringPiece[],
|
|
142
|
+
): boolean {
|
|
143
|
+
const lines = linesOf(source, from, to);
|
|
144
|
+
const [first] = lines;
|
|
145
|
+
const last = lines.at(-1);
|
|
146
|
+
if (!first || !last || lines.length < 2 || first.end !== first.start) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
const prefix = source.slice(last.start, last.end);
|
|
150
|
+
if (!BLANK.test(prefix)) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
const content = lines.slice(1, -1);
|
|
154
|
+
for (const [index, line] of content.entries()) {
|
|
155
|
+
const written = source.slice(line.start, line.end);
|
|
156
|
+
const read =
|
|
157
|
+
BLANK.test(written) ||
|
|
158
|
+
(written.startsWith(prefix) &&
|
|
159
|
+
readRun(source, line.start + prefix.length, line.end, escapes, out));
|
|
160
|
+
if (!read) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
if (index < content.length - 1) {
|
|
164
|
+
out.push({
|
|
165
|
+
end: line.next,
|
|
166
|
+
start: line.end,
|
|
167
|
+
text: "\n",
|
|
168
|
+
verbatim: source.slice(line.end, line.next) === "\n",
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The pieces of a token as this reads it, unchecked. Null when it is not a string this reads. */
|
|
176
|
+
function scan(source: string, span: JxnSpan): StringPiece[] | null {
|
|
177
|
+
let open = span.start;
|
|
178
|
+
while (source.charAt(open) === "#") {
|
|
179
|
+
open += 1;
|
|
180
|
+
}
|
|
181
|
+
const hashes = open - span.start;
|
|
182
|
+
if (source.charAt(open) !== '"') {
|
|
183
|
+
// A bare word is its own value. With hashes and no quote it is a keyword, and not a string.
|
|
184
|
+
return hashes === 0
|
|
185
|
+
? [
|
|
186
|
+
{
|
|
187
|
+
end: span.end,
|
|
188
|
+
start: span.start,
|
|
189
|
+
text: source.slice(span.start, span.end),
|
|
190
|
+
verbatim: true,
|
|
191
|
+
},
|
|
192
|
+
]
|
|
193
|
+
: null;
|
|
194
|
+
}
|
|
195
|
+
const multiline = source.startsWith('"""', open);
|
|
196
|
+
const quotes = multiline ? 3 : 1;
|
|
197
|
+
const from = open + quotes;
|
|
198
|
+
const to = span.end - quotes - hashes;
|
|
199
|
+
if (to < from || source.slice(to, span.end) !== '"'.repeat(quotes) + "#".repeat(hashes)) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
const out: StringPiece[] = [];
|
|
203
|
+
const read = multiline ? readLines : readRun;
|
|
204
|
+
return read(source, from, to, hashes === 0, out) ? out : null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Join the runs that are one stretch of source, and drop the ones that hold nothing. */
|
|
208
|
+
function joined(pieces: readonly StringPiece[]): StringPiece[] {
|
|
209
|
+
const out: StringPiece[] = [];
|
|
210
|
+
for (const piece of pieces) {
|
|
211
|
+
const before = out.at(-1);
|
|
212
|
+
if (piece.text === "") {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (before?.verbatim && piece.verbatim && before.end === piece.start) {
|
|
216
|
+
before.end = piece.end;
|
|
217
|
+
before.text += piece.text;
|
|
218
|
+
} else {
|
|
219
|
+
out.push({ ...piece });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The runs of a string's value, in order, each beside the source it was written as.
|
|
227
|
+
*
|
|
228
|
+
* @param source The file.
|
|
229
|
+
* @param span The string's token: a bare word, or everything from its first `#` or quote to its
|
|
230
|
+
* last.
|
|
231
|
+
* @param value What the parser read the token as.
|
|
232
|
+
* @returns Runs whose text spells `value`. An empty value has none.
|
|
233
|
+
*/
|
|
234
|
+
export function stringPieces(source: string, span: JxnSpan, value: string): StringPiece[] {
|
|
235
|
+
const pieces = scan(source, span);
|
|
236
|
+
if (pieces && pieces.map((piece) => piece.text).join("") === value) {
|
|
237
|
+
return joined(pieces);
|
|
238
|
+
}
|
|
239
|
+
return joined([{ end: span.end, start: span.start, text: value, verbatim: false }]);
|
|
240
|
+
}
|