@dxos/datalog 0.0.1 → 0.12.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 +105 -0
- package/README.md +58 -1
- package/dist/lib/Ast.mjs +72 -0
- package/dist/lib/Ast.mjs.map +1 -0
- package/dist/lib/Builtin.mjs +37 -0
- package/dist/lib/Builtin.mjs.map +1 -0
- package/dist/lib/Checker.mjs +3 -0
- package/dist/lib/Engine.mjs +3 -0
- package/dist/lib/Parser.mjs +2 -0
- package/dist/lib/chunk-Checker.mjs +414 -0
- package/dist/lib/chunk-Checker.mjs.map +1 -0
- package/dist/lib/chunk-Engine.mjs +488 -0
- package/dist/lib/chunk-Engine.mjs.map +1 -0
- package/dist/lib/chunk-Parser.mjs +366 -0
- package/dist/lib/chunk-Parser.mjs.map +1 -0
- package/dist/lib/chunk-rolldown-runtime.mjs +13 -0
- package/dist/lib/index.mjs +6 -0
- package/dist/types/src/Ast.d.ts +81 -0
- package/dist/types/src/Ast.d.ts.map +1 -0
- package/dist/types/src/Builtin.d.ts +35 -0
- package/dist/types/src/Builtin.d.ts.map +1 -0
- package/dist/types/src/Checker.d.ts +61 -0
- package/dist/types/src/Checker.d.ts.map +1 -0
- package/dist/types/src/Checker.test.d.ts +2 -0
- package/dist/types/src/Checker.test.d.ts.map +1 -0
- package/dist/types/src/Engine.d.ts +55 -0
- package/dist/types/src/Engine.d.ts.map +1 -0
- package/dist/types/src/Engine.test.d.ts +2 -0
- package/dist/types/src/Engine.test.d.ts.map +1 -0
- package/dist/types/src/Engine.workerd.test.d.ts +2 -0
- package/dist/types/src/Engine.workerd.test.d.ts.map +1 -0
- package/dist/types/src/Parser.d.ts +43 -0
- package/dist/types/src/Parser.d.ts.map +1 -0
- package/dist/types/src/Parser.test.d.ts +2 -0
- package/dist/types/src/Parser.test.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +6 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/internal/lexer.d.ts +46 -0
- package/dist/types/src/internal/lexer.d.ts.map +1 -0
- package/dist/types/src/internal/plan.d.ts +96 -0
- package/dist/types/src/internal/plan.d.ts.map +1 -0
- package/dist/types/src/internal/relation.d.ts +20 -0
- package/dist/types/src/internal/relation.d.ts.map +1 -0
- package/dist/types/src/internal/stratify.d.ts +28 -0
- package/dist/types/src/internal/stratify.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +57 -5
- package/src/Ast.ts +136 -0
- package/src/Builtin.ts +73 -0
- package/src/Checker.test.ts +99 -0
- package/src/Checker.ts +156 -0
- package/src/Engine.test.ts +239 -0
- package/src/Engine.ts +614 -0
- package/src/Engine.workerd.test.ts +33 -0
- package/src/Parser.test.ts +84 -0
- package/src/Parser.ts +180 -0
- package/src/index.ts +9 -0
- package/src/internal/lexer.ts +154 -0
- package/src/internal/plan.ts +346 -0
- package/src/internal/relation.ts +95 -0
- package/src/internal/stratify.ts +114 -0
package/src/Ast.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
// @import-as-namespace
|
|
6
|
+
|
|
7
|
+
/** A ground value; symbols (`dima`) and strings (`"dima"`) are the same value. */
|
|
8
|
+
export type Value = string | number;
|
|
9
|
+
|
|
10
|
+
/** A ground row of a relation. */
|
|
11
|
+
export type Tuple = ReadonlyArray<Value>;
|
|
12
|
+
|
|
13
|
+
/** Source location (1-based line and column). */
|
|
14
|
+
export type Position = { readonly line: number; readonly column: number };
|
|
15
|
+
|
|
16
|
+
export type Variable = {
|
|
17
|
+
readonly type: 'variable';
|
|
18
|
+
readonly name: string;
|
|
19
|
+
/** Set for `_`; each occurrence is a distinct, never-joined variable. */
|
|
20
|
+
readonly anonymous?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type Constant = { readonly type: 'constant'; readonly value: Value };
|
|
24
|
+
|
|
25
|
+
export type Term = Variable | Constant;
|
|
26
|
+
|
|
27
|
+
export type Atom = { readonly predicate: string; readonly terms: ReadonlyArray<Term> };
|
|
28
|
+
|
|
29
|
+
export type ComparisonOperator = '=' | '!=' | '<' | '<=' | '>' | '>=';
|
|
30
|
+
|
|
31
|
+
export type AggregateFunction = 'count' | 'min' | 'max' | 'sum';
|
|
32
|
+
|
|
33
|
+
export type AtomLiteral = {
|
|
34
|
+
readonly type: 'atom';
|
|
35
|
+
readonly atom: Atom;
|
|
36
|
+
readonly negated: boolean;
|
|
37
|
+
readonly position?: Position;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type ComparisonLiteral = {
|
|
41
|
+
readonly type: 'comparison';
|
|
42
|
+
readonly operator: ComparisonOperator;
|
|
43
|
+
readonly left: Term;
|
|
44
|
+
readonly right: Term;
|
|
45
|
+
readonly position?: Position;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** `Result = fn Target : { body }`; `Target` is absent for `count`. */
|
|
49
|
+
export type AggregateLiteral = {
|
|
50
|
+
readonly type: 'aggregate';
|
|
51
|
+
readonly function: AggregateFunction;
|
|
52
|
+
readonly result: Term;
|
|
53
|
+
readonly target?: Term;
|
|
54
|
+
readonly body: ReadonlyArray<Literal>;
|
|
55
|
+
readonly position?: Position;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type Literal = AtomLiteral | ComparisonLiteral | AggregateLiteral;
|
|
59
|
+
|
|
60
|
+
/** A rule; a fact is a rule with an empty body. */
|
|
61
|
+
export type Rule = {
|
|
62
|
+
readonly head: Atom;
|
|
63
|
+
readonly body: ReadonlyArray<Literal>;
|
|
64
|
+
readonly position?: Position;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type Program = { readonly rules: ReadonlyArray<Rule> };
|
|
68
|
+
|
|
69
|
+
/** Creates a variable term. */
|
|
70
|
+
export const variable = (name: string): Variable => ({ type: 'variable', name });
|
|
71
|
+
|
|
72
|
+
/** Creates a constant term. */
|
|
73
|
+
export const constant = (value: Value): Constant => ({ type: 'constant', value });
|
|
74
|
+
|
|
75
|
+
/** Creates an atom. */
|
|
76
|
+
export const atom = (predicate: string, terms: ReadonlyArray<Term>): Atom => ({ predicate, terms });
|
|
77
|
+
|
|
78
|
+
/** Named (non-anonymous) variables of a term list, in order of first occurrence. */
|
|
79
|
+
export const variablesOf = (terms: ReadonlyArray<Term>): string[] => {
|
|
80
|
+
const names: string[] = [];
|
|
81
|
+
for (const term of terms) {
|
|
82
|
+
if (term.type === 'variable' && !term.anonymous && !names.includes(term.name)) {
|
|
83
|
+
names.push(term.name);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return names;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Named variables a literal mentions, including those inside an aggregate body. */
|
|
90
|
+
export const literalVariables = (literal: Literal): string[] => {
|
|
91
|
+
switch (literal.type) {
|
|
92
|
+
case 'atom':
|
|
93
|
+
return variablesOf(literal.atom.terms);
|
|
94
|
+
case 'comparison':
|
|
95
|
+
return variablesOf([literal.left, literal.right]);
|
|
96
|
+
case 'aggregate': {
|
|
97
|
+
const terms = [literal.result, ...(literal.target ? [literal.target] : [])];
|
|
98
|
+
return [...new Set([...variablesOf(terms), ...literal.body.flatMap(literalVariables)])];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** Formats a value as dialect source. */
|
|
104
|
+
export const formatValue = (value: Value): string =>
|
|
105
|
+
typeof value === 'number' ? String(value) : /^[a-z][A-Za-z0-9_]*$/.test(value) ? value : JSON.stringify(value);
|
|
106
|
+
|
|
107
|
+
/** Formats a term as dialect source. */
|
|
108
|
+
export const formatTerm = (term: Term): string =>
|
|
109
|
+
term.type === 'constant' ? formatValue(term.value) : term.anonymous ? '_' : term.name;
|
|
110
|
+
|
|
111
|
+
/** Formats an atom as dialect source. */
|
|
112
|
+
export const formatAtom = (atom: Atom): string =>
|
|
113
|
+
atom.terms.length === 0 ? atom.predicate : `${atom.predicate}(${atom.terms.map(formatTerm).join(', ')})`;
|
|
114
|
+
|
|
115
|
+
/** Formats a literal as dialect source. */
|
|
116
|
+
export const formatLiteral = (literal: Literal): string => {
|
|
117
|
+
switch (literal.type) {
|
|
118
|
+
case 'atom':
|
|
119
|
+
return `${literal.negated ? 'not ' : ''}${formatAtom(literal.atom)}`;
|
|
120
|
+
case 'comparison':
|
|
121
|
+
return `${formatTerm(literal.left)} ${literal.operator} ${formatTerm(literal.right)}`;
|
|
122
|
+
case 'aggregate': {
|
|
123
|
+
const target = literal.target ? ` ${formatTerm(literal.target)}` : '';
|
|
124
|
+
return `${formatTerm(literal.result)} = ${literal.function}${target} : { ${literal.body.map(formatLiteral).join(', ')} }`;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** Formats a rule as dialect source. */
|
|
130
|
+
export const formatRule = (rule: Rule): string =>
|
|
131
|
+
rule.body.length === 0
|
|
132
|
+
? `${formatAtom(rule.head)}.`
|
|
133
|
+
: `${formatAtom(rule.head)} :- ${rule.body.map(formatLiteral).join(', ')}.`;
|
|
134
|
+
|
|
135
|
+
/** Formats a program as dialect source, one rule per line. */
|
|
136
|
+
export const format = (program: Program): string => program.rules.map(formatRule).join('\n');
|
package/src/Builtin.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
// @import-as-namespace
|
|
6
|
+
|
|
7
|
+
import type * as Ast from './Ast.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A binding pattern, one character per argument: `b` must be bound before the call, `f` may be
|
|
11
|
+
* free and is bound by the call. `"fb"` lets the first argument be generated from the second.
|
|
12
|
+
*/
|
|
13
|
+
export type Mode = string;
|
|
14
|
+
|
|
15
|
+
/** Arguments as seen by a built-in: `undefined` marks a free position of the selected mode. */
|
|
16
|
+
export type Arguments = ReadonlyArray<Ast.Value | undefined>;
|
|
17
|
+
|
|
18
|
+
/** A predicate implemented in code rather than by rules. */
|
|
19
|
+
export type Builtin = {
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly arity: number;
|
|
22
|
+
/** Accepted binding patterns; the all-bound pattern is always accepted. */
|
|
23
|
+
readonly modes: ReadonlyArray<Mode>;
|
|
24
|
+
/**
|
|
25
|
+
* Set when the result can change with no change to the facts (a clock), so the engine
|
|
26
|
+
* re-evaluates the rules that use it on `refresh`.
|
|
27
|
+
*/
|
|
28
|
+
readonly volatile?: boolean;
|
|
29
|
+
/** Returns every full argument tuple that holds and agrees with the bound arguments. */
|
|
30
|
+
readonly evaluate: (args: Arguments) => Iterable<Ast.Tuple>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Built-ins by name. */
|
|
34
|
+
export type Registry = ReadonlyMap<string, Builtin>;
|
|
35
|
+
|
|
36
|
+
/** Defines a built-in. */
|
|
37
|
+
export const make = (builtin: Builtin): Builtin => builtin;
|
|
38
|
+
|
|
39
|
+
/** Defines a test-only built-in: holds iff `test` returns true for its fully bound arguments. */
|
|
40
|
+
export const predicate = (
|
|
41
|
+
name: string,
|
|
42
|
+
arity: number,
|
|
43
|
+
test: (args: Ast.Tuple) => boolean,
|
|
44
|
+
options: { volatile?: boolean } = {},
|
|
45
|
+
): Builtin => ({
|
|
46
|
+
name,
|
|
47
|
+
arity,
|
|
48
|
+
modes: ['b'.repeat(arity)],
|
|
49
|
+
volatile: options.volatile,
|
|
50
|
+
evaluate: (args) => {
|
|
51
|
+
const bound = args.filter((arg): arg is Ast.Value => arg !== undefined);
|
|
52
|
+
return bound.length === arity && test(bound) ? [bound] : [];
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** Builds a registry, rejecting duplicate names. */
|
|
57
|
+
export const registry = (...builtins: ReadonlyArray<Builtin>): Registry => {
|
|
58
|
+
const entries = new Map<string, Builtin>();
|
|
59
|
+
for (const builtin of builtins) {
|
|
60
|
+
if (entries.has(builtin.name)) {
|
|
61
|
+
throw new Error(`Duplicate built-in: ${builtin.name}`);
|
|
62
|
+
}
|
|
63
|
+
if (builtin.modes.some((mode) => mode.length !== builtin.arity || /[^bf]/.test(mode))) {
|
|
64
|
+
throw new Error(`Invalid mode for built-in ${builtin.name}/${builtin.arity}`);
|
|
65
|
+
}
|
|
66
|
+
entries.set(builtin.name, builtin);
|
|
67
|
+
}
|
|
68
|
+
return entries;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** True if `builtin` can be called with exactly the `bound` argument positions bound. */
|
|
72
|
+
export const acceptsBinding = (builtin: Builtin, bound: ReadonlyArray<boolean>): boolean =>
|
|
73
|
+
bound.every(Boolean) || builtin.modes.some((mode) => [...mode].every((flag, index) => flag === 'f' || bound[index]));
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import * as Builtin from './Builtin.ts';
|
|
8
|
+
import * as Checker from './Checker.ts';
|
|
9
|
+
import * as Parser from './Parser.ts';
|
|
10
|
+
|
|
11
|
+
const builtins = Builtin.registry(
|
|
12
|
+
Builtin.make({ name: 'about', arity: 2, modes: ['fb'], evaluate: () => [] }),
|
|
13
|
+
Builtin.predicate('later', 2, ([left, right]) => String(left) > String(right)),
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const check = (source: string, options: Checker.Options = { builtins }) => Checker.check(Parser.parse(source), options);
|
|
17
|
+
|
|
18
|
+
describe('Checker', () => {
|
|
19
|
+
test('accepts a safe, stratified program', ({ expect }) => {
|
|
20
|
+
expect(
|
|
21
|
+
check(`
|
|
22
|
+
path(X, Y) :- edge(X, Y).
|
|
23
|
+
path(X, Z) :- path(X, Y), edge(Y, Z).
|
|
24
|
+
unreachable(X, Y) :- node(X), node(Y), not path(X, Y).
|
|
25
|
+
hub(X, N) :- node(X), N = count : { edge(X, _) }, N > 2.
|
|
26
|
+
topic(F) :- about(F, "release").
|
|
27
|
+
recent(F) :- said(F, T), later(T, "2027-01-01").
|
|
28
|
+
`),
|
|
29
|
+
).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('reports arity mismatches with positions', ({ expect }) => {
|
|
33
|
+
const diagnostics = check('p(X) :- q(X).\nr(X) :- q(X, X).');
|
|
34
|
+
expect(diagnostics).toEqual([
|
|
35
|
+
expect.objectContaining({ code: 'arity', rule: 1, position: { line: 2, column: 9 } }),
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('reports declared relation and built-in arity mismatches', ({ expect }) => {
|
|
40
|
+
const diagnostics = check('p(X) :- fact(X), about(X).', { builtins, relations: { fact: 4 } });
|
|
41
|
+
expect(diagnostics.map(({ code }) => code)).toEqual(['arity', 'arity']);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('reports unsafe rules', ({ expect }) => {
|
|
45
|
+
const diagnostics = check(`
|
|
46
|
+
head(X) :- q(Y).
|
|
47
|
+
negated(X) :- q(X), not r(Y).
|
|
48
|
+
compared(X) :- q(X), Y > 3.
|
|
49
|
+
fact(X).
|
|
50
|
+
`);
|
|
51
|
+
expect(diagnostics.map(({ code, rule }) => [code, rule])).toEqual([
|
|
52
|
+
['unsafe', 0],
|
|
53
|
+
['unsafe', 1],
|
|
54
|
+
['unsafe', 2],
|
|
55
|
+
['unsafe', 3],
|
|
56
|
+
]);
|
|
57
|
+
expect(diagnostics[1].message).toContain('variable Y under negation');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('allows anonymous variables under negation', ({ expect }) => {
|
|
61
|
+
expect(check('empty :- marker(goal), not waiting(_).')).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('checks built-in binding modes', ({ expect }) => {
|
|
65
|
+
expect(check('p(F) :- about(F, T).').map(({ message }) => message)).toEqual([
|
|
66
|
+
expect.stringContaining('no binding mode of built-in about/2 accepts F, T unbound'),
|
|
67
|
+
expect.stringContaining('head variable F is not bound'),
|
|
68
|
+
]);
|
|
69
|
+
expect(check('p(T) :- later(T, "x").').map(({ code }) => code)).toEqual(['unsafe', 'unsafe']);
|
|
70
|
+
expect(check('p(F) :- q(F), not about(F, "x").')).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('rejects rules that define a built-in', ({ expect }) => {
|
|
74
|
+
expect(check('about(F, T) :- q(F, T).').map(({ code }) => code)).toEqual(['builtin-head']);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('rejects negation through recursion', ({ expect }) => {
|
|
78
|
+
const diagnostics = check('p :- not q.\nq :- not p.');
|
|
79
|
+
expect(diagnostics).toEqual([expect.objectContaining({ code: 'unstratifiable' })]);
|
|
80
|
+
expect(diagnostics[0].message).toContain('p, q');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('rejects aggregation through recursion', ({ expect }) => {
|
|
84
|
+
const diagnostics = check('size(N) :- N = count : { member(_) }.\nmember(X) :- size(X).');
|
|
85
|
+
expect(diagnostics.map(({ code }) => code)).toEqual(['unstratifiable']);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('reports undefined predicates when relations are declared', ({ expect }) => {
|
|
89
|
+
const diagnostics = check('p(X) :- fact(X), typo(X).', { builtins, relations: { fact: 1 } });
|
|
90
|
+
expect(diagnostics).toEqual([
|
|
91
|
+
expect.objectContaining({ code: 'undefined', message: expect.stringContaining('typo/1') }),
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('CheckError formats diagnostics', ({ expect }) => {
|
|
96
|
+
const error = new Checker.CheckError(check('p :- not q.\nq :- not p.'));
|
|
97
|
+
expect(error.message).toMatch(/^Invalid program:\n1:1 unstratifiable:/);
|
|
98
|
+
});
|
|
99
|
+
});
|
package/src/Checker.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
// @import-as-namespace
|
|
6
|
+
|
|
7
|
+
import { BaseError } from '@dxos/errors';
|
|
8
|
+
|
|
9
|
+
import * as Ast from './Ast.ts';
|
|
10
|
+
import type * as Builtin from './Builtin.ts';
|
|
11
|
+
import { Slots, planBody } from './internal/plan.ts';
|
|
12
|
+
import { stratify } from './internal/stratify.ts';
|
|
13
|
+
|
|
14
|
+
export type DiagnosticCode = 'arity' | 'builtin-head' | 'undefined' | 'unsafe' | 'unstratifiable';
|
|
15
|
+
|
|
16
|
+
/** A static error in a program. */
|
|
17
|
+
export type Diagnostic = {
|
|
18
|
+
readonly code: DiagnosticCode;
|
|
19
|
+
readonly message: string;
|
|
20
|
+
/** Index of the offending rule. */
|
|
21
|
+
readonly rule?: number;
|
|
22
|
+
readonly position?: Ast.Position;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type Options = {
|
|
26
|
+
readonly builtins?: Builtin.Registry;
|
|
27
|
+
/**
|
|
28
|
+
* Base relations and their arities. When given, a body predicate that is neither defined by a
|
|
29
|
+
* rule, declared here nor a built-in is reported as `undefined`.
|
|
30
|
+
*/
|
|
31
|
+
readonly relations?: Readonly<Record<string, number>>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Thrown when a program with diagnostics is evaluated. */
|
|
35
|
+
export class CheckError extends BaseError.extend('CheckError', 'Invalid program') {
|
|
36
|
+
// A typed field rather than `context`, which would repeat every diagnostic in the message as JSON.
|
|
37
|
+
readonly diagnostics: ReadonlyArray<Diagnostic>;
|
|
38
|
+
|
|
39
|
+
constructor(diagnostics: ReadonlyArray<Diagnostic>) {
|
|
40
|
+
super({ message: `Invalid program:\n${diagnostics.map(formatDiagnostic).join('\n')}` });
|
|
41
|
+
this.diagnostics = diagnostics;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Formats a diagnostic as `line:column code: message`. */
|
|
46
|
+
export const formatDiagnostic = (diagnostic: Diagnostic): string => {
|
|
47
|
+
const at = diagnostic.position ? `${diagnostic.position.line}:${diagnostic.position.column} ` : '';
|
|
48
|
+
return `${at}${diagnostic.code}: ${diagnostic.message}`;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Checks arity consistency, built-in heads, undefined predicates, range restriction (safety) and
|
|
53
|
+
* stratification; an empty result means the program can be evaluated.
|
|
54
|
+
*/
|
|
55
|
+
export const check = (program: Ast.Program, options: Options = {}): Diagnostic[] => {
|
|
56
|
+
const builtins = options.builtins ?? new Map();
|
|
57
|
+
const diagnostics: Diagnostic[] = [];
|
|
58
|
+
const arities = new Map<string, number>();
|
|
59
|
+
for (const builtin of builtins.values()) {
|
|
60
|
+
arities.set(builtin.name, builtin.arity);
|
|
61
|
+
}
|
|
62
|
+
for (const [name, arity] of Object.entries(options.relations ?? {})) {
|
|
63
|
+
arities.set(name, arity);
|
|
64
|
+
}
|
|
65
|
+
const defined = new Set(program.rules.map((rule) => rule.head.predicate));
|
|
66
|
+
|
|
67
|
+
const visitAtom = (atom: Ast.Atom, rule: number, position: Ast.Position | undefined) => {
|
|
68
|
+
const expected = arities.get(atom.predicate);
|
|
69
|
+
if (expected === undefined) {
|
|
70
|
+
arities.set(atom.predicate, atom.terms.length);
|
|
71
|
+
} else if (expected !== atom.terms.length) {
|
|
72
|
+
diagnostics.push({
|
|
73
|
+
code: 'arity',
|
|
74
|
+
message: `${atom.predicate}/${atom.terms.length} used where ${atom.predicate}/${expected} is expected`,
|
|
75
|
+
rule,
|
|
76
|
+
position,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const visitLiteral = (literal: Ast.Literal, rule: number, fallback: Ast.Position | undefined) => {
|
|
81
|
+
const position = literal.position ?? fallback;
|
|
82
|
+
if (literal.type === 'atom') {
|
|
83
|
+
visitAtom(literal.atom, rule, position);
|
|
84
|
+
const predicate = literal.atom.predicate;
|
|
85
|
+
if (
|
|
86
|
+
options.relations &&
|
|
87
|
+
!defined.has(predicate) &&
|
|
88
|
+
!builtins.has(predicate) &&
|
|
89
|
+
!(predicate in options.relations)
|
|
90
|
+
) {
|
|
91
|
+
diagnostics.push({
|
|
92
|
+
code: 'undefined',
|
|
93
|
+
message: `${predicate}/${literal.atom.terms.length} is not a relation, rule or built-in`,
|
|
94
|
+
rule,
|
|
95
|
+
position,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
} else if (literal.type === 'aggregate') {
|
|
99
|
+
literal.body.forEach((inner) => visitLiteral(inner, rule, position));
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
program.rules.forEach((rule, index) => {
|
|
104
|
+
if (builtins.has(rule.head.predicate)) {
|
|
105
|
+
diagnostics.push({
|
|
106
|
+
code: 'builtin-head',
|
|
107
|
+
message: `rule defines built-in ${rule.head.predicate}`,
|
|
108
|
+
rule: index,
|
|
109
|
+
position: rule.position,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
visitAtom(rule.head, index, rule.position);
|
|
113
|
+
rule.body.forEach((literal) => visitLiteral(literal, index, rule.position));
|
|
114
|
+
|
|
115
|
+
const head = new Set(Ast.variablesOf(rule.head.terms));
|
|
116
|
+
const plan = planBody({ body: rule.body, builtins, slots: new Slots(), outer: head });
|
|
117
|
+
for (const { literal, reason } of plan.unresolved) {
|
|
118
|
+
diagnostics.push({
|
|
119
|
+
code: 'unsafe',
|
|
120
|
+
message: `${reason} in: ${Ast.formatRule(rule)}`,
|
|
121
|
+
rule: index,
|
|
122
|
+
position: rule.body[literal].position ?? rule.position,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
const unboundHead = [...head].filter((name) => !plan.bound.has(name));
|
|
126
|
+
if (unboundHead.length > 0) {
|
|
127
|
+
diagnostics.push({
|
|
128
|
+
code: 'unsafe',
|
|
129
|
+
message: `head variable ${unboundHead.join(', ')} is not bound by the body in: ${Ast.formatRule(rule)}`,
|
|
130
|
+
rule: index,
|
|
131
|
+
position: rule.position,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
if (rule.head.terms.some((term) => term.type === 'variable' && term.anonymous)) {
|
|
135
|
+
diagnostics.push({
|
|
136
|
+
code: 'unsafe',
|
|
137
|
+
message: `anonymous variable in head of: ${Ast.formatRule(rule)}`,
|
|
138
|
+
rule: index,
|
|
139
|
+
position: rule.position,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const stratification = stratify(program.rules, builtins);
|
|
145
|
+
if (!stratification.ok) {
|
|
146
|
+
for (const cycle of stratification.cycles) {
|
|
147
|
+
diagnostics.push({
|
|
148
|
+
code: 'unstratifiable',
|
|
149
|
+
message: `negation or aggregation through recursion among: ${cycle.join(', ')}`,
|
|
150
|
+
rule: program.rules.findIndex((rule) => cycle.includes(rule.head.predicate)),
|
|
151
|
+
position: program.rules.find((rule) => cycle.includes(rule.head.predicate))?.position,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return diagnostics;
|
|
156
|
+
};
|