spp-lang 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +170 -2
- package/dist/bin/spp-lang.d.ts +2 -0
- package/dist/bin/spp-lang.js +214 -0
- package/dist/src/backends/playwright/generate.d.ts +13 -0
- package/dist/src/backends/playwright/generate.js +238 -0
- package/dist/src/backends/playwright/runner/playwright.config.d.ts +2 -0
- package/dist/src/backends/playwright/runner/playwright.config.js +16 -0
- package/dist/src/backends/playwright/runtime.d.ts +14 -0
- package/dist/src/backends/playwright/runtime.js +158 -0
- package/dist/src/backends/playwright/words.d.ts +11 -0
- package/dist/src/backends/playwright/words.js +129 -0
- package/dist/src/config.d.ts +18 -0
- package/dist/src/config.js +63 -0
- package/dist/src/index.d.ts +22 -0
- package/dist/src/index.js +12 -0
- package/dist/src/language/check.d.ts +178 -0
- package/dist/src/language/check.js +709 -0
- package/dist/src/language/cognate.d.ts +137 -0
- package/dist/src/language/cognate.js +570 -0
- package/dist/src/language/load.d.ts +58 -0
- package/dist/src/language/load.js +200 -0
- package/dist/src/language/markdown.d.ts +9 -0
- package/dist/src/language/markdown.js +52 -0
- package/dist/src/language/sections.d.ts +19 -0
- package/dist/src/language/sections.js +119 -0
- package/dist/src/language/steps.d.ts +19 -0
- package/dist/src/language/steps.js +91 -0
- package/dist/src/language/types.d.ts +32 -0
- package/dist/src/language/types.js +51 -0
- package/dist/src/tools/dictionary.d.ts +3 -0
- package/dist/src/tools/dictionary.js +89 -0
- package/dist/src/tools/lsp.d.ts +1 -0
- package/dist/src/tools/lsp.js +479 -0
- package/dist/src/words/driver.d.ts +39 -0
- package/dist/src/words/driver.js +9 -0
- package/dist/src/words/shared.d.ts +56 -0
- package/dist/src/words/shared.js +272 -0
- package/package.json +65 -4
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { Context, Form, Type } from './types.ts';
|
|
2
|
+
export declare class SppError extends Error {
|
|
3
|
+
pos?: number;
|
|
4
|
+
constructor(message: string, pos?: number);
|
|
5
|
+
}
|
|
6
|
+
type Located = {
|
|
7
|
+
pos: number;
|
|
8
|
+
};
|
|
9
|
+
export type Node = Located & ({
|
|
10
|
+
type: 'num';
|
|
11
|
+
value: number;
|
|
12
|
+
} | {
|
|
13
|
+
type: 'str';
|
|
14
|
+
value: string;
|
|
15
|
+
} | {
|
|
16
|
+
type: 'sym';
|
|
17
|
+
value: string;
|
|
18
|
+
} | {
|
|
19
|
+
type: 'word';
|
|
20
|
+
name: string;
|
|
21
|
+
} | {
|
|
22
|
+
type: 'define';
|
|
23
|
+
name: string;
|
|
24
|
+
params: Param[];
|
|
25
|
+
} | {
|
|
26
|
+
type: 'block';
|
|
27
|
+
body: Program;
|
|
28
|
+
});
|
|
29
|
+
export type Param = Located & {
|
|
30
|
+
kind: string;
|
|
31
|
+
name: string;
|
|
32
|
+
};
|
|
33
|
+
export type Statement = Node[];
|
|
34
|
+
export type Program = Statement[];
|
|
35
|
+
type Lexeme = {
|
|
36
|
+
type: 'num';
|
|
37
|
+
value: number;
|
|
38
|
+
} | {
|
|
39
|
+
type: 'str';
|
|
40
|
+
value: string;
|
|
41
|
+
} | {
|
|
42
|
+
type: 'sym';
|
|
43
|
+
value: string;
|
|
44
|
+
} | {
|
|
45
|
+
type: 'word';
|
|
46
|
+
name: string;
|
|
47
|
+
} | {
|
|
48
|
+
type: ':';
|
|
49
|
+
} | {
|
|
50
|
+
type: '(';
|
|
51
|
+
} | {
|
|
52
|
+
type: ')';
|
|
53
|
+
};
|
|
54
|
+
export type Token = Lexeme & Located & {
|
|
55
|
+
length: number;
|
|
56
|
+
line: number;
|
|
57
|
+
indent: number;
|
|
58
|
+
};
|
|
59
|
+
export declare function lex(src: string): Token[];
|
|
60
|
+
export declare function parse(src: string): Program;
|
|
61
|
+
export declare class Block {
|
|
62
|
+
body: Program;
|
|
63
|
+
env: Env;
|
|
64
|
+
constructor(body: Program, env: Env);
|
|
65
|
+
toString(): string;
|
|
66
|
+
}
|
|
67
|
+
export declare class Sym {
|
|
68
|
+
name: string;
|
|
69
|
+
constructor(name: string);
|
|
70
|
+
toString(): string;
|
|
71
|
+
}
|
|
72
|
+
export declare class Definition {
|
|
73
|
+
name: string;
|
|
74
|
+
block: Block;
|
|
75
|
+
header: Param[];
|
|
76
|
+
private resolved?;
|
|
77
|
+
constructor(name: string, block: Block, header: Param[]);
|
|
78
|
+
get params(): {
|
|
79
|
+
name: string;
|
|
80
|
+
type: Type;
|
|
81
|
+
}[];
|
|
82
|
+
}
|
|
83
|
+
export declare class Definitions {
|
|
84
|
+
list: Definition[];
|
|
85
|
+
constructor(first: Definition);
|
|
86
|
+
ordered(): Definition[];
|
|
87
|
+
}
|
|
88
|
+
export declare const specificity: (d: {
|
|
89
|
+
params: {
|
|
90
|
+
type: Type;
|
|
91
|
+
}[];
|
|
92
|
+
}) => number;
|
|
93
|
+
export declare class Kinded implements Describable {
|
|
94
|
+
kind: string;
|
|
95
|
+
value: unknown;
|
|
96
|
+
constructor(kind: string, value: unknown);
|
|
97
|
+
describe(): string;
|
|
98
|
+
}
|
|
99
|
+
export declare const unwrap: (v: unknown) => unknown;
|
|
100
|
+
export interface Describable {
|
|
101
|
+
describe(): string;
|
|
102
|
+
}
|
|
103
|
+
export declare function describe(v: unknown): string;
|
|
104
|
+
type Word = readonly Form[] | Definitions | {
|
|
105
|
+
value: unknown;
|
|
106
|
+
};
|
|
107
|
+
export declare class Env {
|
|
108
|
+
parent: Env | null;
|
|
109
|
+
words: Map<string, Word>;
|
|
110
|
+
spellings: Map<string, string>;
|
|
111
|
+
kinds: Map<string, Type<unknown>>;
|
|
112
|
+
constructor(parent?: Env | null);
|
|
113
|
+
word(name: string, ...forms: Form[]): this;
|
|
114
|
+
def(name: string, definition: Definition): this;
|
|
115
|
+
kind(t: Type): this;
|
|
116
|
+
kindOf(name: string): Type | undefined;
|
|
117
|
+
builtinKinds(): Generator<Type>;
|
|
118
|
+
bind(name: string, value: unknown): this;
|
|
119
|
+
private set;
|
|
120
|
+
lookup(name: string): Word | undefined;
|
|
121
|
+
forms(name: string): readonly Form[] | undefined;
|
|
122
|
+
names(): Generator<string>;
|
|
123
|
+
}
|
|
124
|
+
export declare class Stack extends Array<unknown> {
|
|
125
|
+
take<T>(word: string, t: Type<T>): T;
|
|
126
|
+
}
|
|
127
|
+
export declare function run(program: Program, env: Env, stack: Stack, ctx: Context): Promise<void>;
|
|
128
|
+
export declare function call(block: Block, stack: Stack, ctx: Context, args?: Record<string, unknown>): Promise<void>;
|
|
129
|
+
export declare function resolve(params: Param[], env: Env): {
|
|
130
|
+
name: string;
|
|
131
|
+
type: Type;
|
|
132
|
+
}[];
|
|
133
|
+
export declare function notAKind(name: string, builtins: Iterable<Type>): string;
|
|
134
|
+
export declare function apply(name: string, forms: readonly Form[], stack: Stack, ctx: Context): Promise<void>;
|
|
135
|
+
export declare function unknownWord(name: string, known: Iterable<string>): SppError;
|
|
136
|
+
export declare function core(): Env;
|
|
137
|
+
export {};
|
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
// A small Cognate-flavoured interpreter.
|
|
2
|
+
//
|
|
3
|
+
// Cognate reads like prose because only capitalised words are code: lowercase
|
|
4
|
+
// words are comments, and statements run right to left, so a verb written
|
|
5
|
+
// first runs last, after everything it acts on has been evaluated.
|
|
6
|
+
//
|
|
7
|
+
// Click the Button "Sign in"
|
|
8
|
+
//
|
|
9
|
+
// pushes "Sign in", Button turns it into a locator, Click clicks it. That is
|
|
10
|
+
// the whole trick Spec++ is built on.
|
|
11
|
+
//
|
|
12
|
+
// Lexical rules follow CognaC (cognate-lang/cognate, src/lexer.l), except:
|
|
13
|
+
//
|
|
14
|
+
// - Commas, colons and full stops standing on their own are prose
|
|
15
|
+
// punctuation and skipped, so "Then I See "Welcome"." still parses.
|
|
16
|
+
// - Each line is one statement; there are no semicolons.
|
|
17
|
+
// - Blocks are a colon ending a line plus an indented body, as in Gherkin,
|
|
18
|
+
// not parentheses. Define is a section keyword, like Gherkin's: its
|
|
19
|
+
// colon comes first, "Define: Add", and its body is indented under it.
|
|
20
|
+
// - Comments are Gherkin's: a line starting with #.
|
|
21
|
+
import { AnyType, article, formsFor, NumberType, signature, TableType, TextType, type } from './types.js';
|
|
22
|
+
export class SppError extends Error {
|
|
23
|
+
pos;
|
|
24
|
+
constructor(message, pos) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'SppError';
|
|
27
|
+
this.pos = pos;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// ---------------------------------------------------------------- lexing
|
|
31
|
+
const IDENT = /[A-Z\-?!+/*><=^.][A-Za-z0-9\-?!'+/*>=<^.]*/y;
|
|
32
|
+
const INFORMAL = /[a-z][a-z0-9'\-?!.]*/y;
|
|
33
|
+
const NUMBER = /-?(?:[1-9][0-9]*|[0-9])(?:\.[0-9]+)?(?:e-?[0-9]+)?(?=[\s;,:]|$)/y;
|
|
34
|
+
const SYMBOL = /\\[A-Za-z0-9\-?!'+/*>=<^.]+/y;
|
|
35
|
+
const STRING = /"((?:\\[abfnrtv\\"]|[^\n"\\])*)"/y;
|
|
36
|
+
const PROSE = /[,:.]+(?=[\s;]|$)/y;
|
|
37
|
+
// a colon that opens a block is the last thing on its line
|
|
38
|
+
const LINE_END = /[ \t]*(?:\n|$)/y;
|
|
39
|
+
const ESCAPES = {
|
|
40
|
+
a: '\x07',
|
|
41
|
+
b: '\b',
|
|
42
|
+
f: '\f',
|
|
43
|
+
n: '\n',
|
|
44
|
+
r: '\r',
|
|
45
|
+
t: '\t',
|
|
46
|
+
v: '\v',
|
|
47
|
+
'\\': '\\',
|
|
48
|
+
'"': '"',
|
|
49
|
+
};
|
|
50
|
+
export function lex(src) {
|
|
51
|
+
const tokens = [];
|
|
52
|
+
let i = 0;
|
|
53
|
+
let line = 0;
|
|
54
|
+
let lineStart = 0;
|
|
55
|
+
const at = (re, from = i) => {
|
|
56
|
+
re.lastIndex = from;
|
|
57
|
+
return re.exec(src);
|
|
58
|
+
};
|
|
59
|
+
const push = (t, length) => {
|
|
60
|
+
const indent = src.slice(lineStart, i).search(/[^ \t]|$/);
|
|
61
|
+
tokens.push({ ...t, pos: i, length, line, indent });
|
|
62
|
+
};
|
|
63
|
+
while (i < src.length) {
|
|
64
|
+
const c = src[i];
|
|
65
|
+
if (c === '\n') {
|
|
66
|
+
i++;
|
|
67
|
+
line++;
|
|
68
|
+
lineStart = i;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (/\s/.test(c)) {
|
|
72
|
+
i++;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
// A comment, as in Gherkin: a line whose first character is #.
|
|
76
|
+
if (c === '#' && !src.slice(lineStart, i).trim()) {
|
|
77
|
+
const nl = src.indexOf('\n', i);
|
|
78
|
+
i = nl === -1 ? src.length : nl;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (c === ';') {
|
|
82
|
+
// Silently skipping it would run "A; B" as one statement, right to left.
|
|
83
|
+
throw new SppError('One statement per line: put what comes after the ; on a line of its own', i);
|
|
84
|
+
}
|
|
85
|
+
if (c === '(' || c === ')') {
|
|
86
|
+
// Only a Define's header has them; the parser says so anywhere else.
|
|
87
|
+
push({ type: c }, 1);
|
|
88
|
+
i++;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const string = at(STRING);
|
|
92
|
+
if (string) {
|
|
93
|
+
push({ type: 'str', value: string[1].replace(/\\(.)/g, (_, e) => ESCAPES[e]) }, string[0].length);
|
|
94
|
+
i += string[0].length;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const number = at(NUMBER);
|
|
98
|
+
if (number) {
|
|
99
|
+
push({ type: 'num', value: Number(number[0]) }, number[0].length);
|
|
100
|
+
i += number[0].length;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const prose = at(PROSE);
|
|
104
|
+
if (prose) {
|
|
105
|
+
// Punctuation between words isn't code, unless it's the colon that
|
|
106
|
+
// ends a line; the parser decides whether that opens a block.
|
|
107
|
+
if (prose[0].endsWith(':') && at(LINE_END, i + prose[0].length))
|
|
108
|
+
push({ type: ':' }, prose[0].length);
|
|
109
|
+
i += prose[0].length;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const symbol = at(SYMBOL);
|
|
113
|
+
if (symbol) {
|
|
114
|
+
push({ type: 'sym', value: symbol[0].slice(1).toLowerCase() }, symbol[0].length);
|
|
115
|
+
i += symbol[0].length;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
// a lowercase word: a comment
|
|
119
|
+
const informal = at(INFORMAL);
|
|
120
|
+
if (informal) {
|
|
121
|
+
i += informal[0].length;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const m = at(IDENT);
|
|
125
|
+
if (m) {
|
|
126
|
+
// "Sign-in," or "Visit." — let trailing prose punctuation go
|
|
127
|
+
const name = m[0].replace(/[,:.]+$/, '') || m[0];
|
|
128
|
+
push({ type: 'word', name }, name.length);
|
|
129
|
+
i += name.length;
|
|
130
|
+
// The colon in "Define: Add" is a keyword's, as in "Feature:", so it's
|
|
131
|
+
// kept for the parser wherever it is on the line.
|
|
132
|
+
if (/^define$/i.test(name) && src[i] === ':') {
|
|
133
|
+
push({ type: ':' }, 1);
|
|
134
|
+
i++;
|
|
135
|
+
}
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (c === '"')
|
|
139
|
+
throw new SppError('Unclosed string', i);
|
|
140
|
+
throw new SppError(`Unexpected character ${JSON.stringify(c)}`, i);
|
|
141
|
+
}
|
|
142
|
+
return tokens;
|
|
143
|
+
}
|
|
144
|
+
// A program is a list of statements, one per line; a statement is a list of
|
|
145
|
+
// nodes; a block node holds a program of its own. A line ending in a colon
|
|
146
|
+
// takes the more-indented lines after it as a block, placed at the end of
|
|
147
|
+
// that line.
|
|
148
|
+
export function parse(src) {
|
|
149
|
+
const lines = [];
|
|
150
|
+
for (const t of lex(src)) {
|
|
151
|
+
let last = lines.at(-1);
|
|
152
|
+
if (last?.number !== t.line) {
|
|
153
|
+
last = { number: t.line, indent: t.indent, tokens: [] };
|
|
154
|
+
lines.push(last);
|
|
155
|
+
}
|
|
156
|
+
last.tokens.push(t);
|
|
157
|
+
}
|
|
158
|
+
let i = 0;
|
|
159
|
+
function body(indent) {
|
|
160
|
+
const statements = [];
|
|
161
|
+
while (i < lines.length && lines[i].indent >= indent) {
|
|
162
|
+
const line = lines[i++];
|
|
163
|
+
if (line.indent > indent)
|
|
164
|
+
throw new SppError('Unexpected indentation', line.tokens[0].pos);
|
|
165
|
+
const last = line.tokens.at(-1);
|
|
166
|
+
const define = isDefine(line.tokens) ? line.tokens[1] : null;
|
|
167
|
+
const colon = define ?? (last?.type === ':' ? last : null);
|
|
168
|
+
const statement = statementOf(line.tokens);
|
|
169
|
+
// A trailing colon with nothing indented under it was just prose; a
|
|
170
|
+
// Define with nothing under it is a mistake.
|
|
171
|
+
const next = lines[i];
|
|
172
|
+
if (colon && next && next.indent > indent) {
|
|
173
|
+
statement.push({ type: 'block', body: body(next.indent), pos: colon.pos });
|
|
174
|
+
}
|
|
175
|
+
else if (define) {
|
|
176
|
+
throw new SppError(`Define: ${statement[0].type === 'define' ? statement[0].name : ''} needs an indented body under it`, line.tokens[0].pos);
|
|
177
|
+
}
|
|
178
|
+
if (statement.length)
|
|
179
|
+
statements.push(statement);
|
|
180
|
+
}
|
|
181
|
+
return statements;
|
|
182
|
+
}
|
|
183
|
+
const program = body(lines[0]?.indent ?? 0);
|
|
184
|
+
if (i < lines.length)
|
|
185
|
+
throw new SppError('Unexpected indentation', lines[i].tokens[0].pos);
|
|
186
|
+
return program;
|
|
187
|
+
}
|
|
188
|
+
// "Define: Add", a Define's header: a section keyword, like "Feature:".
|
|
189
|
+
const isDefine = (tokens) => tokens[0]?.type === 'word' && /^define$/i.test(tokens[0].name) && tokens[1]?.type === ':';
|
|
190
|
+
function statementOf(tokens) {
|
|
191
|
+
const first = tokens[0];
|
|
192
|
+
if (isDefine(tokens)) {
|
|
193
|
+
const name = tokens[2];
|
|
194
|
+
if (name?.type !== 'word')
|
|
195
|
+
throw new SppError('Define: needs a Capitalised name after it', first.pos);
|
|
196
|
+
return [{ type: 'define', name: name.name, params: paramsOf(tokens.slice(3)), pos: first.pos }];
|
|
197
|
+
}
|
|
198
|
+
const statement = [];
|
|
199
|
+
for (let n = 0; n < tokens.length; n++) {
|
|
200
|
+
const t = tokens[n];
|
|
201
|
+
if (t.type === ':')
|
|
202
|
+
continue;
|
|
203
|
+
if (t.type === '(' || t.type === ')') {
|
|
204
|
+
throw new SppError('( ) only name what a Define takes, as in "Define: Move Cart (Source)"; blocks are indented under a colon', t.pos);
|
|
205
|
+
}
|
|
206
|
+
if (t.type === 'word' && /^define$/i.test(t.name)) {
|
|
207
|
+
const next = tokens[n + 1];
|
|
208
|
+
if (n > 0 || next?.type === ':')
|
|
209
|
+
throw new SppError('Define: starts a line of its own, with its body indented under it', t.pos);
|
|
210
|
+
// The old way, "Define Add:".
|
|
211
|
+
throw new SppError(`Write Define: ${next?.type === 'word' ? next.name : 'Name'}, with the colon after Define, as in Feature:`, t.pos);
|
|
212
|
+
}
|
|
213
|
+
statement.push(t);
|
|
214
|
+
}
|
|
215
|
+
return statement;
|
|
216
|
+
}
|
|
217
|
+
// What a Define takes, after its name: each capitalised word is a kind, and
|
|
218
|
+
// a word in ( ) after one names it. Lowercase words are prose, as anywhere.
|
|
219
|
+
function paramsOf(tokens) {
|
|
220
|
+
const params = [];
|
|
221
|
+
const wrong = (t) => new SppError("After a Define's name come the kinds it takes, like Text or Element, each maybe named in ( )", t.pos);
|
|
222
|
+
for (let n = 0; n < tokens.length; n++) {
|
|
223
|
+
const t = tokens[n];
|
|
224
|
+
// a trailing colon, as in the old way, is prose
|
|
225
|
+
if (t.type === ':' && n === tokens.length - 1)
|
|
226
|
+
break;
|
|
227
|
+
if (t.type !== 'word')
|
|
228
|
+
throw wrong(t);
|
|
229
|
+
const param = { kind: t.name, name: t.name, pos: t.pos };
|
|
230
|
+
if (tokens[n + 1]?.type === '(') {
|
|
231
|
+
const [, name, close] = tokens.slice(n + 1, n + 4);
|
|
232
|
+
if (name?.type !== 'word' || close?.type !== ')') {
|
|
233
|
+
throw new SppError(`( ) after ${t.name} holds one Capitalised name for it, as in ${t.name} (Source)`, tokens[n + 1].pos);
|
|
234
|
+
}
|
|
235
|
+
param.name = name.name;
|
|
236
|
+
n += 3;
|
|
237
|
+
}
|
|
238
|
+
params.push(param);
|
|
239
|
+
}
|
|
240
|
+
return params;
|
|
241
|
+
}
|
|
242
|
+
// ---------------------------------------------------------------- values
|
|
243
|
+
export class Block {
|
|
244
|
+
body;
|
|
245
|
+
env;
|
|
246
|
+
constructor(body, env) {
|
|
247
|
+
this.body = body;
|
|
248
|
+
this.env = env;
|
|
249
|
+
}
|
|
250
|
+
toString() {
|
|
251
|
+
return 'a block';
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
export class Sym {
|
|
255
|
+
name;
|
|
256
|
+
constructor(name) {
|
|
257
|
+
this.name = name;
|
|
258
|
+
}
|
|
259
|
+
toString() {
|
|
260
|
+
return `\\${this.name}`;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// A Defined word: its body, and the kinds it takes, named, in reading order.
|
|
264
|
+
//
|
|
265
|
+
// Its header's kinds are looked up when it's first called, not when it's
|
|
266
|
+
// Defined, since a noun it takes may be Defined in a later file.
|
|
267
|
+
export class Definition {
|
|
268
|
+
name;
|
|
269
|
+
block;
|
|
270
|
+
header;
|
|
271
|
+
resolved;
|
|
272
|
+
constructor(name, block, header) {
|
|
273
|
+
this.name = name;
|
|
274
|
+
this.block = block;
|
|
275
|
+
this.header = header;
|
|
276
|
+
}
|
|
277
|
+
get params() {
|
|
278
|
+
this.resolved ??= resolve(this.header, this.block.env);
|
|
279
|
+
return this.resolved;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Every Define of one word, most particular first: those that take more,
|
|
283
|
+
// then those whose kinds are narrower (a Cart before an Element, anything
|
|
284
|
+
// before Any). A call runs the first whose kinds are on the stack. The checker
|
|
285
|
+
// orders them the same way, and says when two could always both fit.
|
|
286
|
+
export class Definitions {
|
|
287
|
+
list;
|
|
288
|
+
constructor(first) {
|
|
289
|
+
this.list = [first];
|
|
290
|
+
}
|
|
291
|
+
ordered() {
|
|
292
|
+
return [...this.list].sort((a, b) => b.params.length - a.params.length || specificity(b) - specificity(a));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
export const specificity = (d) => d.params.reduce((n, p) => n + (p.type.base ? 2 : p.type === AnyType ? 0 : 1), 0);
|
|
296
|
+
// What a noun leaves: a value, labelled with the noun's kind. Words that take
|
|
297
|
+
// the kind check the label; any other word gets the value itself.
|
|
298
|
+
export class Kinded {
|
|
299
|
+
kind;
|
|
300
|
+
value;
|
|
301
|
+
constructor(kind, value) {
|
|
302
|
+
this.kind = kind;
|
|
303
|
+
this.value = value;
|
|
304
|
+
}
|
|
305
|
+
describe() {
|
|
306
|
+
return describe(this.value);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
export const unwrap = (v) => (v instanceof Kinded ? v.value : v);
|
|
310
|
+
// The kind a noun, a Define that leaves one value, makes of what it leaves.
|
|
311
|
+
// Only the checker knows what that value is, so here its base is Any.
|
|
312
|
+
function nounKind(name) {
|
|
313
|
+
return {
|
|
314
|
+
name,
|
|
315
|
+
phrase: article(name),
|
|
316
|
+
members: [name],
|
|
317
|
+
base: AnyType,
|
|
318
|
+
test: (v) => v instanceof Kinded && v.kind.toLowerCase() === name.toLowerCase(),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
const isDescribable = (v) => typeof v === 'object' && v !== null && typeof v.describe === 'function';
|
|
322
|
+
export function describe(v) {
|
|
323
|
+
if (typeof v === 'string')
|
|
324
|
+
return JSON.stringify(v);
|
|
325
|
+
if (isDescribable(v))
|
|
326
|
+
return v.describe();
|
|
327
|
+
return String(v);
|
|
328
|
+
}
|
|
329
|
+
export class Env {
|
|
330
|
+
parent;
|
|
331
|
+
// Keyed in lowercase, since only a word's first letter has to be a capital.
|
|
332
|
+
words = new Map();
|
|
333
|
+
// How each word was written where it was made, for editors to offer.
|
|
334
|
+
spellings = new Map();
|
|
335
|
+
// The kinds a Define's header can name, keyed in lowercase.
|
|
336
|
+
kinds = new Map();
|
|
337
|
+
constructor(parent = null) {
|
|
338
|
+
this.parent = parent;
|
|
339
|
+
}
|
|
340
|
+
// A builtin, in one or more forms.
|
|
341
|
+
word(name, ...forms) {
|
|
342
|
+
return this.set(name, forms);
|
|
343
|
+
}
|
|
344
|
+
// A Defined word.
|
|
345
|
+
// Another Define of a word adds to it, here; one in an inner scope hides it.
|
|
346
|
+
def(name, definition) {
|
|
347
|
+
const here = this.words.get(name.toLowerCase());
|
|
348
|
+
if (!(here instanceof Definitions))
|
|
349
|
+
return this.set(name, new Definitions(definition));
|
|
350
|
+
here.list.push(definition);
|
|
351
|
+
return this;
|
|
352
|
+
}
|
|
353
|
+
// A kind a Define can take, like Text or Element. A noun makes one too.
|
|
354
|
+
kind(t) {
|
|
355
|
+
this.kinds.set(t.name.toLowerCase(), t);
|
|
356
|
+
return this;
|
|
357
|
+
}
|
|
358
|
+
kindOf(name) {
|
|
359
|
+
const key = name.toLowerCase();
|
|
360
|
+
for (let e = this; e; e = e.parent)
|
|
361
|
+
if (e.kinds.has(key))
|
|
362
|
+
return e.kinds.get(key);
|
|
363
|
+
const word = this.lookup(name);
|
|
364
|
+
if (word instanceof Definitions)
|
|
365
|
+
return nounKind(word.list[0].name);
|
|
366
|
+
}
|
|
367
|
+
// The built-in kinds, nearest first.
|
|
368
|
+
*builtinKinds() {
|
|
369
|
+
for (let e = this; e; e = e.parent)
|
|
370
|
+
yield* e.kinds.values();
|
|
371
|
+
}
|
|
372
|
+
// A value, like what a Define was given, under the name its header gives it.
|
|
373
|
+
bind(name, value) {
|
|
374
|
+
return this.set(name, { value });
|
|
375
|
+
}
|
|
376
|
+
set(name, word) {
|
|
377
|
+
this.words.set(name.toLowerCase(), word);
|
|
378
|
+
this.spellings.set(name.toLowerCase(), name);
|
|
379
|
+
return this;
|
|
380
|
+
}
|
|
381
|
+
lookup(name) {
|
|
382
|
+
const key = name.toLowerCase();
|
|
383
|
+
for (let e = this; e; e = e.parent)
|
|
384
|
+
if (e.words.has(key))
|
|
385
|
+
return e.words.get(key);
|
|
386
|
+
}
|
|
387
|
+
// A builtin's forms, or undefined if the word isn't a builtin.
|
|
388
|
+
forms(name) {
|
|
389
|
+
const word = this.lookup(name);
|
|
390
|
+
return Array.isArray(word) ? word : undefined;
|
|
391
|
+
}
|
|
392
|
+
// Every word in scope, as it was spelled.
|
|
393
|
+
*names() {
|
|
394
|
+
for (let e = this; e; e = e.parent)
|
|
395
|
+
yield* e.spellings.values();
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// ---------------------------------------------------------------- stack
|
|
399
|
+
export class Stack extends Array {
|
|
400
|
+
// Pops one value of the given type, for Define, which is syntax, not a
|
|
401
|
+
// word; words say what they take in their forms instead.
|
|
402
|
+
take(word, t) {
|
|
403
|
+
if (!this.length)
|
|
404
|
+
throw new SppError(`${word} expected ${t.phrase}, but there was nothing left`);
|
|
405
|
+
const v = this.pop();
|
|
406
|
+
if (!t.test(v))
|
|
407
|
+
throw new SppError(`${word} expected ${t.phrase}, but got ${describe(v)}`);
|
|
408
|
+
return v;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
// ---------------------------------------------------------------- evaluation
|
|
412
|
+
export async function run(program, env, stack, ctx) {
|
|
413
|
+
for (const statement of program) {
|
|
414
|
+
for (let n = statement.length - 1; n >= 0; n--) {
|
|
415
|
+
const node = statement[n];
|
|
416
|
+
try {
|
|
417
|
+
await evaluate(node, env, stack, ctx);
|
|
418
|
+
}
|
|
419
|
+
catch (err) {
|
|
420
|
+
// Outermost wins: an error deep inside a Defined word is reported at
|
|
421
|
+
// the word the step actually wrote.
|
|
422
|
+
if (err instanceof Object)
|
|
423
|
+
err.pos = node.pos;
|
|
424
|
+
throw err;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
export async function call(block, stack, ctx, args = {}) {
|
|
430
|
+
const env = new Env(block.env);
|
|
431
|
+
for (const [name, value] of Object.entries(args))
|
|
432
|
+
env.bind(name, value);
|
|
433
|
+
await run(block.body, env, stack, ctx);
|
|
434
|
+
}
|
|
435
|
+
// A Definition as a form, so it's called like any word, taking what its
|
|
436
|
+
// header says off the stack. Its body runs on a stack of its own, so it can't
|
|
437
|
+
// reach into its caller's; what it leaves goes on its caller's.
|
|
438
|
+
function formOf(d, stack) {
|
|
439
|
+
return {
|
|
440
|
+
inputs: d.params.map((p) => p.type),
|
|
441
|
+
output: null,
|
|
442
|
+
impl: async (ctx, ...args) => {
|
|
443
|
+
const own = new Stack();
|
|
444
|
+
await call(d.block, own, ctx, Object.fromEntries(d.params.map((p, i) => [p.name, args[i]])));
|
|
445
|
+
stack.push(...(isNoun(own, d.block.env) ? [new Kinded(d.name, unwrap(own[0]))] : own));
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
// Whether a Define left what a noun leaves: one value, of a built-in kind
|
|
450
|
+
// other than Any. The checker decides the same, from types.
|
|
451
|
+
function isNoun(left, env) {
|
|
452
|
+
if (left.length !== 1)
|
|
453
|
+
return false;
|
|
454
|
+
return [...env.builtinKinds()].some((k) => k !== AnyType && k.test(unwrap(left[0])));
|
|
455
|
+
}
|
|
456
|
+
// The kinds a Define's header names, or the first that isn't one.
|
|
457
|
+
export function resolve(params, env) {
|
|
458
|
+
return params.map((p) => {
|
|
459
|
+
const t = env.kindOf(p.kind);
|
|
460
|
+
if (!t)
|
|
461
|
+
throw Object.assign(new SppError(notAKind(p.kind, env.builtinKinds())), { pos: p.pos });
|
|
462
|
+
return { name: p.name, type: t };
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
export function notAKind(name, builtins) {
|
|
466
|
+
const kinds = [...new Set([...builtins].map((t) => t.name))].join(', ');
|
|
467
|
+
return `${name} isn't a kind a Define can take. The kinds are ${kinds}, and nouns: Defines that leave one of those`;
|
|
468
|
+
}
|
|
469
|
+
// Whether a value is what a form's input takes, and what the form is given.
|
|
470
|
+
// A kind's input takes the labelled value; any other, the value itself.
|
|
471
|
+
const accepts = (t, v) => (t.base ? t.test(v) : t.test(unwrap(v)));
|
|
472
|
+
const given = (t, v) => (t.base ? v : unwrap(v));
|
|
473
|
+
const BlockType = type('Block', 'an indented block after a colon', (v) => v instanceof Block);
|
|
474
|
+
// Calls the first form whose inputs are on top of the stack. If none are,
|
|
475
|
+
// says what was wanted at the first place every form had given up.
|
|
476
|
+
export async function apply(name, forms, stack, ctx) {
|
|
477
|
+
const fits = (f) => f.inputs.every((t, i) => i < stack.length && accepts(t, stack[stack.length - 1 - i]));
|
|
478
|
+
const form = forms.find(fits);
|
|
479
|
+
if (!form)
|
|
480
|
+
throw mismatch(name, forms, stack);
|
|
481
|
+
const args = form.inputs.map((t) => given(t, stack.pop()));
|
|
482
|
+
const result = await form.impl(ctx, ...args);
|
|
483
|
+
if (!form.output)
|
|
484
|
+
return;
|
|
485
|
+
// A vocabulary bug, not a mistake in the .spp file.
|
|
486
|
+
if (!form.output.test(result))
|
|
487
|
+
throw new Error(`${signature(name, form)} left ${describe(result)}`);
|
|
488
|
+
stack.push(result);
|
|
489
|
+
}
|
|
490
|
+
function mismatch(name, forms, stack) {
|
|
491
|
+
let live = forms;
|
|
492
|
+
for (let i = 0;; i++) {
|
|
493
|
+
const here = live.filter((f) => f.inputs.length > i);
|
|
494
|
+
const v = stack[stack.length - 1 - i];
|
|
495
|
+
const ok = here.filter((f) => i < stack.length && accepts(f.inputs[i], v));
|
|
496
|
+
if (!ok.length) {
|
|
497
|
+
const wanted = [...new Set(here.map((f) => f.inputs[i].phrase))].join(', or ');
|
|
498
|
+
const got = i < stack.length ? `got ${describe(v)}` : 'there was nothing left';
|
|
499
|
+
return new SppError(`${name} expected ${wanted}, but ${got}`);
|
|
500
|
+
}
|
|
501
|
+
live = ok;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
async function evaluate(node, env, stack, ctx) {
|
|
505
|
+
switch (node.type) {
|
|
506
|
+
case 'num':
|
|
507
|
+
case 'str':
|
|
508
|
+
return void stack.push(node.value);
|
|
509
|
+
case 'sym':
|
|
510
|
+
return void stack.push(new Sym(node.value));
|
|
511
|
+
case 'block':
|
|
512
|
+
return void stack.push(new Block(node.body, env));
|
|
513
|
+
case 'define':
|
|
514
|
+
return void env.def(node.name, new Definition(node.name, stack.take(`Define ${node.name}`, BlockType), node.params));
|
|
515
|
+
case 'word': {
|
|
516
|
+
const w = env.lookup(node.name);
|
|
517
|
+
if (w === undefined)
|
|
518
|
+
throw unknownWord(node.name, env.names());
|
|
519
|
+
if (w instanceof Definitions)
|
|
520
|
+
return apply(node.name, w.ordered().map((d) => formOf(d, stack)), stack, ctx);
|
|
521
|
+
if (Array.isArray(w))
|
|
522
|
+
return apply(node.name, w, stack, ctx);
|
|
523
|
+
return void stack.push(w.value);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
export function unknownWord(name, known) {
|
|
528
|
+
const near = closest(name.toLowerCase(), new Set(known));
|
|
529
|
+
const guess = near ? `Did you mean ${near[0].toUpperCase() + near.slice(1)}? ` : '';
|
|
530
|
+
return new SppError(`Unknown word ${name}. ${guess}Capitalised words are code; if it's prose, write it in lowercase.`);
|
|
531
|
+
}
|
|
532
|
+
function closest(word, names) {
|
|
533
|
+
let best;
|
|
534
|
+
let bestD = Math.max(2, Math.floor(word.length / 3)) + 1;
|
|
535
|
+
for (const n of names) {
|
|
536
|
+
const d = distance(word, n.toLowerCase());
|
|
537
|
+
if (d < bestD) {
|
|
538
|
+
best = n;
|
|
539
|
+
bestD = d;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return best;
|
|
543
|
+
}
|
|
544
|
+
function distance(a, b) {
|
|
545
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
546
|
+
for (let i = 1; i <= a.length; i++) {
|
|
547
|
+
let prev = row[0];
|
|
548
|
+
row[0] = i;
|
|
549
|
+
for (let j = 1; j <= b.length; j++) {
|
|
550
|
+
const tmp = row[j];
|
|
551
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
552
|
+
prev = tmp;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return row[b.length];
|
|
556
|
+
}
|
|
557
|
+
// ---------------------------------------------------------------- core words
|
|
558
|
+
// Deliberately small: no conditionals, loops or arithmetic. A spec should read
|
|
559
|
+
// the same every time it runs, so there's nothing in it to branch on.
|
|
560
|
+
export function core() {
|
|
561
|
+
const form = formsFor();
|
|
562
|
+
return (new Env()
|
|
563
|
+
// "Given I Visit ..." — the one capital English can't do without.
|
|
564
|
+
.word('I', form([], () => { }))
|
|
565
|
+
.word('Print', form([AnyType], (ctx, v) => (ctx.print ?? console.log)(describe(v))))
|
|
566
|
+
.kind(TextType)
|
|
567
|
+
.kind(NumberType)
|
|
568
|
+
.kind(TableType)
|
|
569
|
+
.kind(AnyType));
|
|
570
|
+
}
|