eyelang 1.3.0 → 1.3.2
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 -1
- package/docs/guide.md +8 -2
- package/docs/language-reference.md +42 -3
- package/index.d.ts +2 -0
- package/package.json +1 -1
- package/src/parser.js +28 -0
- package/src/program.js +48 -11
- package/src/term.js +9 -0
- package/test/conformance/cases/103_angle_iri_atoms.eye +7 -0
- package/test/conformance/cases/104_mode_determinism_declarations.eye +11 -0
- package/test/conformance/expected/103_angle_iri_atoms.eye +3 -0
- package/test/conformance/expected/104_mode_determinism_declarations.eye +4 -0
- package/test/run-regression.mjs +40 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://doi.org/10.5281/zenodo.20761726)
|
|
5
5
|
|
|
6
6
|
Eyelang is a small logic programming language for rules, goals, answers, and proofs.
|
|
7
|
-
Its source syntax is Prolog-like Horn-clause syntax with a few deliberate eyelang choices, such as `?x` variables for N3/SPARQL-style readability
|
|
7
|
+
Its source syntax is Prolog-like Horn-clause syntax with a few deliberate eyelang choices, such as `?x` variables for N3/SPARQL-style readability, explicit `table(path, 2).` declarations for tabled predicates, and advisory `mode/3` declarations for host tooling.
|
|
8
8
|
It grew out of logic-language experiments in the EYE/N3 reasoning tradition, but is packaged here as its own project.
|
|
9
9
|
|
|
10
10
|
## Install and run
|
package/docs/guide.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
This guide introduces Eyelang, a small Horn-clause language and engine whose source syntax is Prolog-like but deliberately its own compact language for rules, goals, answers, and proofs. Eyelang works over ordinary terms, lists, arithmetic, strings, and finite search. Run it with the `eyelang` CLI, or use `node bin/eyelang.js` when working directly from a source checkout.
|
|
4
4
|
|
|
5
|
-
Programs write relations directly, for example `ancestor(pat, emma)` or `status(case1, accepted)`. Eyelang output is ordinary Eyelang syntax: by default, the CLI materializes selected answer facts and prints those facts only. Pass `--proof` (or `-p`) when you also want each answer followed by a `why/2` explanation fact that records the proof. Programs may add `materialize/2` declarations such as `materialize(answer, 2).` to focus output on selected predicates.
|
|
5
|
+
Programs write relations directly, for example `ancestor(pat, emma)` or `status(case1, accepted)`. Absolute IRI atoms can be written explicitly with angle brackets, for example `<https://schema.org/name>`, when a program needs web identifiers without prefix declarations. Eyelang output is ordinary Eyelang syntax: by default, the CLI materializes selected answer facts and prints those facts only. Pass `--proof` (or `-p`) when you also want each answer followed by a `why/2` explanation fact that records the proof. Programs may add `materialize/2` declarations such as `materialize(answer, 2).` to focus output on selected predicates.
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
For the normative language definition, including lexical syntax, terms, clauses, goals, built-ins, `table/2`, `materialize/2`, and conformance boundaries, read the [Eyelang language reference](language-reference.md).
|
|
@@ -568,8 +568,14 @@ Ground facts use a fast path that avoids freshening and copying a rule body. Rec
|
|
|
568
568
|
table(path, 2).
|
|
569
569
|
```
|
|
570
570
|
|
|
571
|
+
Predicates can also carry advisory mode and determinism declarations for documentation and host tooling:
|
|
571
572
|
|
|
572
|
-
|
|
573
|
+
```eyelang
|
|
574
|
+
mode(path, 2, [in, out]).
|
|
575
|
+
semidet(edge, 2).
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
For large programs, keep helper predicates selective, bind arguments early, document intended calling patterns with `mode/3` when helpful, and declare focused output predicates with `materialize/2` when default output would otherwise solve broad helper goals.
|
|
573
579
|
|
|
574
580
|
## Implementation limits
|
|
575
581
|
|
|
@@ -138,7 +138,7 @@ Each `?_` anonymous variable occurrence is fresh. A bare `_` is not a variable i
|
|
|
138
138
|
|
|
139
139
|
### 3.5 Atom constants
|
|
140
140
|
|
|
141
|
-
A plain atom constant starts with a lowercase ASCII letter and is followed by zero or more ASCII letters, digits, or underscores. A dot is not part of a plain atom; dotted web spaces such as `'be.ugent'` or `'org.schema'` MUST be quoted if they are meant as one atom constant. Names such as `a-b`
|
|
141
|
+
A plain atom constant starts with a lowercase ASCII letter and is followed by zero or more ASCII letters, digits, or underscores. A dot is not part of a plain atom; dotted web spaces such as `'be.ugent'` or `'org.schema'` MUST be quoted if they are meant as one atom constant. Names such as `a-b` MUST also be quoted if they are meant as one atom constant:
|
|
142
142
|
|
|
143
143
|
```eyelang
|
|
144
144
|
pat
|
|
@@ -148,9 +148,18 @@ case_123
|
|
|
148
148
|
'org.schema'
|
|
149
149
|
'eyereasoner.github'
|
|
150
150
|
'a-b'
|
|
151
|
-
'http://example'
|
|
152
151
|
```
|
|
153
152
|
|
|
153
|
+
Absolute IRI atom constants MAY also be written between angle brackets. The content must be an absolute IRI-like lexical value with a scheme such as `https:` or `urn:`. Eyelang stores the content as the atom value and prints absolute IRI atoms with angle brackets on read-back:
|
|
154
|
+
|
|
155
|
+
```eyelang
|
|
156
|
+
<https://example.org/alice>
|
|
157
|
+
<urn:example:bob>
|
|
158
|
+
triple(<https://example.org/alice>, <https://schema.org/name>, "Alice").
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Quoted absolute IRI atoms remain valid input, but read-back normalizes them to angle-bracket syntax. For example, `'https://example.org/alice'` prints as `<https://example.org/alice>`.
|
|
162
|
+
|
|
154
163
|
A quoted atom constant is enclosed in single quotes. A single quote inside a quoted atom constant is represented by doubling it:
|
|
155
164
|
|
|
156
165
|
```eyelang
|
|
@@ -165,6 +174,8 @@ A graphic atom constant is one or more graphic characters from this set:
|
|
|
165
174
|
#$&*+-/<=>?@^~\
|
|
166
175
|
```
|
|
167
176
|
|
|
177
|
+
Angle-bracket IRI syntax is recognized only for absolute IRI-like contents. Graphic atoms such as `<=>`, `<`, and `>=` remain graphic atoms.
|
|
178
|
+
|
|
168
179
|
### 3.6 Strings
|
|
169
180
|
|
|
170
181
|
A string is enclosed in double quotes. The implementation supports common escapes such as `\n`, `\t`, `\"`, and `\\`.
|
|
@@ -539,6 +550,33 @@ materialize(reason, 2).
|
|
|
539
550
|
|
|
540
551
|
`materialize/2` affects host output selection only; it does not change the logical meaning of the program. Materialized output facts are not asserted as new source facts for subsequent output goals. A host MAY solve several materialized predicates in one solver run, and tabled predicate answers MAY be reused within that run, but this reuse is controlled by `table/2`, not by materialization.
|
|
541
552
|
|
|
553
|
+
### 11.3 Advisory modes and determinism
|
|
554
|
+
|
|
555
|
+
```eyelang
|
|
556
|
+
mode(path, 2, [in, out]).
|
|
557
|
+
det(root, 1).
|
|
558
|
+
semidet(edge, 2).
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
`mode/3`, `det/2`, and `semidet/2` are advisory declarations. They describe a predicate group's intended calling pattern and determinism, but they do not change proof search or answer output. Because they are ordinary facts, programs may also query them.
|
|
562
|
+
|
|
563
|
+
For `mode(?name, ?arity, ?modes)`, the first argument MUST be an atom constant naming the predicate, the second argument MUST be a non-negative integer arity, and the third argument MUST be a proper list whose length is equal to the arity. Portable mode atoms are:
|
|
564
|
+
|
|
565
|
+
- `in`: the argument is expected to be supplied by the caller;
|
|
566
|
+
- `out`: the argument is expected to be produced by the predicate;
|
|
567
|
+
- `any`: no portable mode commitment is made for that argument.
|
|
568
|
+
|
|
569
|
+
`det(?name, ?arity)` declares that a predicate is intended to produce exactly one answer for calls in its documented modes. `semidet(?name, ?arity)` declares that a predicate is intended to produce zero or one answer. This specification does not require runtime enforcement; hosts MAY use these declarations for linting, documentation, indexing decisions, or editor support.
|
|
570
|
+
|
|
571
|
+
Example:
|
|
572
|
+
|
|
573
|
+
```eyelang
|
|
574
|
+
mode(member, 2, [out, in]).
|
|
575
|
+
semidet(member, 2).
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
The example documents the common checking/generation mode where the list is supplied and the member is enumerated. A future linting host could warn if a program calls `member/2` outside that intended mode, but a conforming solver still treats `mode/3` and `semidet/2` as ordinary facts plus metadata.
|
|
579
|
+
|
|
542
580
|
## 12. Eyelang Sockets
|
|
543
581
|
|
|
544
582
|
A **eyelang Socket** is a declared semantic opening in a eyelang program where facts, rules, tools, datasets, or agents can plug in knowledge through an explicit contract while preserving eyelang-readable reasoning and explanations.
|
|
@@ -637,6 +675,7 @@ A conforming eyelang implementation supports the standard language described abo
|
|
|
637
675
|
- the standard built-ins listed in section 9;
|
|
638
676
|
- `table/2` declarations;
|
|
639
677
|
- `materialize/2` declarations;
|
|
678
|
+
- advisory `mode/3`, `det/2`, and `semidet/2` declarations;
|
|
640
679
|
- default derived output;
|
|
641
680
|
- explanation output when the host exposes proof output.
|
|
642
681
|
|
|
@@ -659,7 +698,7 @@ eyelang source is intended to be familiar to Prolog readers, but eyelang is not
|
|
|
659
698
|
- no variables in functor or predicate position;
|
|
660
699
|
- no occurs check in unification.
|
|
661
700
|
|
|
662
|
-
Programs intended to be portable to eyelang SHOULD use `?` variables, avoid ISO-specific syntax, and keep terms explicit. Atom names that are not plain lowercase-starting names
|
|
701
|
+
Programs intended to be portable to eyelang SHOULD use `?` variables, avoid ISO-specific syntax, and keep terms explicit. Atom names that are not plain lowercase-starting names, graphic atom tokens, or angle-bracket absolute IRI atoms SHOULD be written as quoted atoms, for example `'a-b'`.
|
|
663
702
|
|
|
664
703
|
## 16. Examples
|
|
665
704
|
|
package/index.d.ts
CHANGED
package/package.json
CHANGED
package/src/parser.js
CHANGED
|
@@ -7,6 +7,22 @@ const TOK = {
|
|
|
7
7
|
LPAREN: '(', RPAREN: ')', LBRACKET: '[', RBRACKET: ']', COMMA: ',', BAR: '|', DOT: '.', IF: ':-'
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
+
function isAbsoluteIriText(text) {
|
|
11
|
+
return /^[A-Za-z][A-Za-z0-9+.-]*:[^\s<>"'{}|\\^`]*$/.test(text);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function hasAngleIriToken(source, pos) {
|
|
15
|
+
if (source[pos] !== '<') return false;
|
|
16
|
+
let text = '';
|
|
17
|
+
for (let i = pos + 1; i < source.length; i++) {
|
|
18
|
+
const ch = source[i];
|
|
19
|
+
if (ch === '>') return text.length > 0 && isAbsoluteIriText(text);
|
|
20
|
+
if (ch === '\n' || ch === '\r' || /\s/.test(ch) || ch === '<') return false;
|
|
21
|
+
text += ch;
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
10
26
|
function isWhitespaceCode(code) {
|
|
11
27
|
return code === 32 || code === 9 || code === 10 || code === 13 || code === 12 || code === 11;
|
|
12
28
|
}
|
|
@@ -97,6 +113,18 @@ class Parser {
|
|
|
97
113
|
}
|
|
98
114
|
if (ch === ':') throw new Error('colon names are not supported; use name or prefix_name');
|
|
99
115
|
|
|
116
|
+
if (hasAngleIriToken(this.source, this.pos)) {
|
|
117
|
+
this.take(); // <
|
|
118
|
+
let text = '';
|
|
119
|
+
while (true) {
|
|
120
|
+
if (!this.peek()) throw new Error(`parse line ${line}: unterminated IRI`);
|
|
121
|
+
const value = this.take();
|
|
122
|
+
if (value === '>') break;
|
|
123
|
+
text += value;
|
|
124
|
+
}
|
|
125
|
+
return { type: TOK.ATOM, text, line };
|
|
126
|
+
}
|
|
127
|
+
|
|
100
128
|
if (ch === '"' || ch === "'") {
|
|
101
129
|
const quote = this.take();
|
|
102
130
|
let text = '';
|
package/src/program.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Program representation and clause indexing.
|
|
2
2
|
// Indexes are deliberately conservative: they speed up common scalar arguments but never replace unification as the final check.
|
|
3
|
-
import { ATOM, COMPOUND, Env, compound, deref, flattenConjunction, isScalar, termToString } from './term.js';
|
|
3
|
+
import { ATOM, COMPOUND, Env, compound, deref, flattenConjunction, isScalar, properListItems, termToString } from './term.js';
|
|
4
4
|
import { parseClauses } from './parser.js';
|
|
5
5
|
|
|
6
6
|
export class Program {
|
|
@@ -40,6 +40,8 @@ export class Program {
|
|
|
40
40
|
argIndexes: Array.from({ length: arity }, () => ({ buckets: new Map(), fallback: [] })),
|
|
41
41
|
pairIndexes: [],
|
|
42
42
|
tabled: false,
|
|
43
|
+
mode: null,
|
|
44
|
+
determinism: null,
|
|
43
45
|
recursive: false,
|
|
44
46
|
};
|
|
45
47
|
if (arity > 2) {
|
|
@@ -70,16 +72,31 @@ export class Program {
|
|
|
70
72
|
applyDeclarations(options = {}) {
|
|
71
73
|
for (const clause of this.clauses) {
|
|
72
74
|
const h = clause.head;
|
|
73
|
-
if (clause.body.length !== 0 || h.type !== COMPOUND
|
|
74
|
-
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const group = this.groups.get(key);
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
75
|
+
if (clause.body.length !== 0 || h.type !== COMPOUND) continue;
|
|
76
|
+
|
|
77
|
+
if (h.arity === 2) {
|
|
78
|
+
const indicator = declarationIndicator(h.args[0], h.args[1]);
|
|
79
|
+
if (!indicator) continue;
|
|
80
|
+
const group = this.groups.get(indicator.key);
|
|
81
|
+
if (h.name === 'table') {
|
|
82
|
+
if (group) group.tabled = true;
|
|
83
|
+
} else if (h.name === 'materialize') {
|
|
84
|
+
this.hasMaterialize = true;
|
|
85
|
+
this.materializedGroups.add(indicator.key);
|
|
86
|
+
} else if ((h.name === 'det' || h.name === 'semidet') && group) {
|
|
87
|
+
group.determinism = h.name;
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (h.name === 'mode' && h.arity === 3) {
|
|
93
|
+
const indicator = declarationIndicator(h.args[0], h.args[1]);
|
|
94
|
+
if (!indicator) continue;
|
|
95
|
+
const modes = declarationModes(h.args[2]);
|
|
96
|
+
if (modes && modes.length === indicator.arity) {
|
|
97
|
+
const group = this.groups.get(indicator.key);
|
|
98
|
+
if (group) group.mode = modes;
|
|
99
|
+
}
|
|
83
100
|
}
|
|
84
101
|
}
|
|
85
102
|
if (options.markRecursive !== false) this.markRecursivePredicates();
|
|
@@ -159,6 +176,26 @@ export class Program {
|
|
|
159
176
|
}
|
|
160
177
|
}
|
|
161
178
|
|
|
179
|
+
|
|
180
|
+
function declarationIndicator(name, arity) {
|
|
181
|
+
if (name?.type !== ATOM || arity?.type !== 'number') return null;
|
|
182
|
+
if (!/^\d+$/.test(arity.name)) return null;
|
|
183
|
+
const arityNumber = Number(arity.name);
|
|
184
|
+
return { name: name.name, arity: arityNumber, key: `${name.name}/${arityNumber}` };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function declarationModes(term) {
|
|
188
|
+
const items = properListItems(term, new Env());
|
|
189
|
+
if (!items) return null;
|
|
190
|
+
const modes = [];
|
|
191
|
+
for (const item of items) {
|
|
192
|
+
if (item.type !== ATOM) return null;
|
|
193
|
+
if (!['in', 'out', 'any'].includes(item.name)) return null;
|
|
194
|
+
modes.push(item.name);
|
|
195
|
+
}
|
|
196
|
+
return modes;
|
|
197
|
+
}
|
|
198
|
+
|
|
162
199
|
function indexOne(index, arg, clause) {
|
|
163
200
|
if (isScalar(arg)) {
|
|
164
201
|
const bucket = index.buckets.get(arg.name);
|
package/src/term.js
CHANGED
|
@@ -135,6 +135,14 @@ export function termIsGround(term, env = new Env()) {
|
|
|
135
135
|
|
|
136
136
|
const graphicAtomChars = new Set('#$&*+-/<=>?@^~\\'.split(''));
|
|
137
137
|
|
|
138
|
+
function isAbsoluteIriText(text) {
|
|
139
|
+
return /^[A-Za-z][A-Za-z0-9+.-]*:[^\s<>"'{}|\\^`]*$/.test(text);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function writeAngleIri(name) {
|
|
143
|
+
return `<${name}>`;
|
|
144
|
+
}
|
|
145
|
+
|
|
138
146
|
function atomNeedsQuotes(name) {
|
|
139
147
|
if (!name) return true;
|
|
140
148
|
if (name === '[]') return false;
|
|
@@ -156,6 +164,7 @@ function quoteAtom(name) {
|
|
|
156
164
|
}
|
|
157
165
|
|
|
158
166
|
function writeAtom(name) {
|
|
167
|
+
if (isAbsoluteIriText(name)) return writeAngleIri(name);
|
|
159
168
|
return atomNeedsQuotes(name) ? quoteAtom(name) : name;
|
|
160
169
|
}
|
|
161
170
|
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
% Reference 3.5: absolute IRIs may be written as angle-bracket atom constants.
|
|
2
|
+
materialize(iri_subject, 1).
|
|
3
|
+
materialize(iri_object, 1).
|
|
4
|
+
triple(<https://example.org/alice>, <https://schema.org/name>, "Alice").
|
|
5
|
+
triple(<urn:example:bob>, <https://schema.org/knows>, <https://example.org/alice>).
|
|
6
|
+
iri_subject(?iri) :- triple(?iri, ?_, ?_).
|
|
7
|
+
iri_object(?iri) :- triple(?_, <https://schema.org/knows>, ?iri).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
% Reference 11.3: mode/3 and det/2 or semidet/2 are ordinary facts and advisory declarations.
|
|
2
|
+
mode(path, 2, [in, out]).
|
|
3
|
+
det(path, 2).
|
|
4
|
+
semidet(edge, 2).
|
|
5
|
+
materialize(answer, 2).
|
|
6
|
+
edge(a, b).
|
|
7
|
+
path(?x, ?y) :- edge(?x, ?y).
|
|
8
|
+
answer(mode_path, ?modes) :- mode(path, 2, ?modes).
|
|
9
|
+
answer(det_path, ok) :- det(path, 2).
|
|
10
|
+
answer(semidet_edge, ok) :- semidet(edge, 2).
|
|
11
|
+
answer(path, ?y) :- path(a, ?y).
|
package/test/run-regression.mjs
CHANGED
|
@@ -529,6 +529,27 @@ function whiteBoxCases() {
|
|
|
529
529
|
assertEqual(termToString(clauses[0].head, new Env(), true), "p(web('be.ugent', josd), 'org.schema')", 'head');
|
|
530
530
|
},
|
|
531
531
|
},
|
|
532
|
+
{
|
|
533
|
+
name: 'parser accepts angle-bracket absolute IRI atoms',
|
|
534
|
+
run: () => {
|
|
535
|
+
const clauses = parseProgramText('p(<https://example.org/alice>, <urn:example:bob>).\n');
|
|
536
|
+
assertEqual(termToString(clauses[0].head, new Env(), true), 'p(<https://example.org/alice>, <urn:example:bob>)', 'head');
|
|
537
|
+
},
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
name: 'readback prints absolute IRI atoms with angle brackets',
|
|
541
|
+
run: () => {
|
|
542
|
+
const clauses = parseProgramText("p('https://example.org/alice').\n");
|
|
543
|
+
assertEqual(termToString(clauses[0].head, new Env(), true), 'p(<https://example.org/alice>)', 'head');
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
{
|
|
547
|
+
name: 'angle IRI syntax does not steal graphic atom syntax',
|
|
548
|
+
run: () => {
|
|
549
|
+
const clauses = parseProgramText('p(<=>).\n');
|
|
550
|
+
assertEqual(termToString(clauses[0].head, new Env(), true), 'p(<=>)', 'head');
|
|
551
|
+
},
|
|
552
|
+
},
|
|
532
553
|
{
|
|
533
554
|
name: 'list construction round-trips through properListItems',
|
|
534
555
|
run: () => {
|
|
@@ -595,6 +616,25 @@ function whiteBoxCases() {
|
|
|
595
616
|
assertEqual(group.tabled, false, 'memoize/2 is not a table declaration');
|
|
596
617
|
},
|
|
597
618
|
},
|
|
619
|
+
{
|
|
620
|
+
name: 'mode and determinism declarations annotate predicate groups',
|
|
621
|
+
run: () => {
|
|
622
|
+
const program = Program.parse('mode(path, 2, [in, out]).\ndet(path, 2).\nedge(a, b).\npath(?x, ?y) :- edge(?x, ?y).\n');
|
|
623
|
+
const group = program.findGroup('path', 2);
|
|
624
|
+
assertEqual(Boolean(group), true, 'path/2 group exists');
|
|
625
|
+
assertEqual(group.mode.join(','), 'in,out', 'path/2 mode');
|
|
626
|
+
assertEqual(group.determinism, 'det', 'path/2 determinism');
|
|
627
|
+
},
|
|
628
|
+
},
|
|
629
|
+
{
|
|
630
|
+
name: 'semidet declaration annotates predicate groups',
|
|
631
|
+
run: () => {
|
|
632
|
+
const program = Program.parse('semidet(edge, 2).\nedge(a, b).\n');
|
|
633
|
+
const group = program.findGroup('edge', 2);
|
|
634
|
+
assertEqual(Boolean(group), true, 'edge/2 group exists');
|
|
635
|
+
assertEqual(group.determinism, 'semidet', 'edge/2 determinism');
|
|
636
|
+
},
|
|
637
|
+
},
|
|
598
638
|
{
|
|
599
639
|
name: 'challenging examples keep dynamic-programming predicates tabled',
|
|
600
640
|
run: () => {
|