@dxos/datalog 0.0.1 → 0.13.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.
Files changed (61) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +58 -1
  3. package/dist/lib/Ast.mjs +72 -0
  4. package/dist/lib/Ast.mjs.map +1 -0
  5. package/dist/lib/Builtin.mjs +37 -0
  6. package/dist/lib/Builtin.mjs.map +1 -0
  7. package/dist/lib/Checker.mjs +3 -0
  8. package/dist/lib/Engine.mjs +3 -0
  9. package/dist/lib/Parser.mjs +2 -0
  10. package/dist/lib/chunk-Checker.mjs +414 -0
  11. package/dist/lib/chunk-Checker.mjs.map +1 -0
  12. package/dist/lib/chunk-Engine.mjs +488 -0
  13. package/dist/lib/chunk-Engine.mjs.map +1 -0
  14. package/dist/lib/chunk-Parser.mjs +366 -0
  15. package/dist/lib/chunk-Parser.mjs.map +1 -0
  16. package/dist/lib/chunk-rolldown-runtime.mjs +13 -0
  17. package/dist/lib/index.mjs +6 -0
  18. package/dist/types/src/Ast.d.ts +81 -0
  19. package/dist/types/src/Ast.d.ts.map +1 -0
  20. package/dist/types/src/Builtin.d.ts +35 -0
  21. package/dist/types/src/Builtin.d.ts.map +1 -0
  22. package/dist/types/src/Checker.d.ts +61 -0
  23. package/dist/types/src/Checker.d.ts.map +1 -0
  24. package/dist/types/src/Checker.test.d.ts +2 -0
  25. package/dist/types/src/Checker.test.d.ts.map +1 -0
  26. package/dist/types/src/Engine.d.ts +55 -0
  27. package/dist/types/src/Engine.d.ts.map +1 -0
  28. package/dist/types/src/Engine.test.d.ts +2 -0
  29. package/dist/types/src/Engine.test.d.ts.map +1 -0
  30. package/dist/types/src/Engine.workerd.test.d.ts +2 -0
  31. package/dist/types/src/Engine.workerd.test.d.ts.map +1 -0
  32. package/dist/types/src/Parser.d.ts +43 -0
  33. package/dist/types/src/Parser.d.ts.map +1 -0
  34. package/dist/types/src/Parser.test.d.ts +2 -0
  35. package/dist/types/src/Parser.test.d.ts.map +1 -0
  36. package/dist/types/src/index.d.ts +6 -0
  37. package/dist/types/src/index.d.ts.map +1 -0
  38. package/dist/types/src/internal/lexer.d.ts +46 -0
  39. package/dist/types/src/internal/lexer.d.ts.map +1 -0
  40. package/dist/types/src/internal/plan.d.ts +96 -0
  41. package/dist/types/src/internal/plan.d.ts.map +1 -0
  42. package/dist/types/src/internal/relation.d.ts +20 -0
  43. package/dist/types/src/internal/relation.d.ts.map +1 -0
  44. package/dist/types/src/internal/stratify.d.ts +28 -0
  45. package/dist/types/src/internal/stratify.d.ts.map +1 -0
  46. package/dist/types/tsconfig.tsbuildinfo +1 -0
  47. package/package.json +57 -5
  48. package/src/Ast.ts +136 -0
  49. package/src/Builtin.ts +73 -0
  50. package/src/Checker.test.ts +99 -0
  51. package/src/Checker.ts +156 -0
  52. package/src/Engine.test.ts +239 -0
  53. package/src/Engine.ts +614 -0
  54. package/src/Engine.workerd.test.ts +33 -0
  55. package/src/Parser.test.ts +84 -0
  56. package/src/Parser.ts +180 -0
  57. package/src/index.ts +9 -0
  58. package/src/internal/lexer.ts +154 -0
  59. package/src/internal/plan.ts +346 -0
  60. package/src/internal/relation.ts +95 -0
  61. package/src/internal/stratify.ts +114 -0
@@ -0,0 +1,84 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import * as Ast from './Ast.ts';
8
+ import * as Parser from './Parser.ts';
9
+
10
+ describe('Parser', () => {
11
+ test('parses facts, rules, strings, numbers and anonymous variables', ({ expect }) => {
12
+ const program = Parser.parse(`
13
+ % a comment
14
+ edge(a, "b c", 3).
15
+ path(X, Y) :- edge(X, Y, _). // trailing comment
16
+ /* block
17
+ comment */
18
+ wake(followup) :- elapsed(goal, 2d), not achieved(goal).
19
+ `);
20
+ expect(program.rules).toHaveLength(3);
21
+ expect(program.rules[0].head.terms).toEqual([Ast.constant('a'), Ast.constant('b c'), Ast.constant(3)]);
22
+ expect(program.rules[1].body[0]).toMatchObject({
23
+ type: 'atom',
24
+ negated: false,
25
+ atom: { predicate: 'edge', terms: [{ name: 'X' }, { name: 'Y' }, { anonymous: true }] },
26
+ });
27
+ expect(program.rules[2].body[0]).toMatchObject({ atom: { terms: [{ value: 'goal' }, { value: '2d' }] } });
28
+ expect(program.rules[2].body[1]).toMatchObject({ type: 'atom', negated: true, atom: { predicate: 'achieved' } });
29
+ expect(program.rules[1].position).toEqual({ line: 4, column: 7 });
30
+ });
31
+
32
+ test('parses negation forms, comparisons and zero-arity atoms', ({ expect }) => {
33
+ const program = Parser.parse('p :- q(X), !r(X), not(s(X)), X != "x", X >= -2.5, done.');
34
+ const [q, r, s, notEqual, greater, done] = program.rules[0].body;
35
+ expect(q).toMatchObject({ type: 'atom', negated: false });
36
+ expect(r).toMatchObject({ type: 'atom', negated: true, atom: { predicate: 'r' } });
37
+ expect(s).toMatchObject({ type: 'atom', negated: true, atom: { predicate: 's' } });
38
+ expect(notEqual).toMatchObject({ type: 'comparison', operator: '!=', right: { value: 'x' } });
39
+ expect(greater).toMatchObject({ type: 'comparison', operator: '>=', right: { value: -2.5 } });
40
+ expect(done).toMatchObject({ type: 'atom', atom: { predicate: 'done', terms: [] } });
41
+ });
42
+
43
+ test('parses aggregates', ({ expect }) => {
44
+ const program = Parser.parse(`
45
+ total(N) :- N = count : { item(_) }.
46
+ cheapest(M) :- M = min P : { price(_, P) }.
47
+ spend(C, S) :- customer(C), S = sum A : order(C, A).
48
+ `);
49
+ expect(program.rules[0].body[0]).toMatchObject({ type: 'aggregate', function: 'count', target: undefined });
50
+ expect(program.rules[1].body[0]).toMatchObject({ type: 'aggregate', function: 'min', target: { name: 'P' } });
51
+ expect(program.rules[2].body[1]).toMatchObject({
52
+ type: 'aggregate',
53
+ function: 'sum',
54
+ body: [{ type: 'atom', atom: { predicate: 'order' } }],
55
+ });
56
+ });
57
+
58
+ test('round-trips through format', ({ expect }) => {
59
+ const source =
60
+ 'achieved(goal) :- fact(F, dima, helps_with, "agent plugin"), polarity(F, "+"), N = count : { p(F) }, N > 1.';
61
+ expect(Ast.format(Parser.parse(source))).toBe(source);
62
+ expect(Parser.parse(Ast.format(Parser.parse(source)))).toEqual(Parser.parse(source));
63
+ });
64
+
65
+ test.for([
66
+ ['p(X) :- q(X)', 1, 13, "Expected '.', ',' or ':-' but reached end of input"],
67
+ ['p(X) :- q(X)\n.\nr(', 3, 3, 'Expected a term but reached end of input'],
68
+ ['p(X :- q(X).', 1, 5, "Expected ',' or ')' but found ':-'"],
69
+ ['p(X) :- \n q(X), "a".', 2, 12, "Expected a comparison operator but found '.'"],
70
+ ['p("unterminated) .', 1, 3, 'Unterminated string'],
71
+ ['p(X) :- q(X) ; r(X).', 1, 14, "Unexpected character ';'"],
72
+ ['Foo(X).', 1, 1, "Expected a predicate name but found 'Foo'"],
73
+ ['p(N) :- N = avg X : { q(X) }.', 1, 13, "Expected count, min, max or sum but found 'avg'"],
74
+ ] as const)('rejects %j at %i:%i', ([source, line, column, reason], { expect }) => {
75
+ let error: unknown;
76
+ try {
77
+ Parser.parse(source);
78
+ } catch (caught) {
79
+ error = caught;
80
+ }
81
+ expect(error).toBeInstanceOf(Parser.ParseError);
82
+ expect(error).toMatchObject({ position: { line, column }, reason });
83
+ });
84
+ });
package/src/Parser.ts ADDED
@@ -0,0 +1,180 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // @import-as-namespace
6
+
7
+ import { BaseError } from '@dxos/errors';
8
+
9
+ import type * as Ast from './Ast.ts';
10
+ import { LexError, type Token, tokenize } from './internal/lexer.ts';
11
+
12
+ /** A syntax error with the 1-based position of the offending token. */
13
+ export class ParseError extends BaseError.extend('ParseError', 'Syntax error') {
14
+ constructor(
15
+ readonly reason: string,
16
+ readonly position: Ast.Position,
17
+ ) {
18
+ super({ message: reason, context: { line: position.line, column: position.column } });
19
+ }
20
+ }
21
+
22
+ const AGGREGATES: ReadonlyArray<Ast.AggregateFunction> = ['count', 'min', 'max', 'sum'];
23
+
24
+ const isAggregateFunction = (text: string): text is Ast.AggregateFunction => AGGREGATES.some((name) => name === text);
25
+
26
+ const OPERATORS: ReadonlyArray<Ast.ComparisonOperator> = ['=', '!=', '<', '<=', '>', '>='];
27
+
28
+ const toOperator = (text: string): Ast.ComparisonOperator | undefined => OPERATORS.find((op) => op === text);
29
+
30
+ /**
31
+ * Parses dialect source (Soufflé-like: `head :- body.`, `not`/`!` negation, comparisons and
32
+ * `N = count : { … }` aggregates) into a program.
33
+ * @throws ParseError on the first syntax error.
34
+ */
35
+ export const parse = (source: string): Ast.Program => {
36
+ let tokens: Token[];
37
+ try {
38
+ tokens = tokenize(source);
39
+ } catch (error) {
40
+ if (error instanceof LexError) {
41
+ throw new ParseError(error.reason, error.position);
42
+ }
43
+ throw error;
44
+ }
45
+
46
+ let cursor = 0;
47
+ let anonymous = 0;
48
+ const end = (): Ast.Position => {
49
+ const last = tokens.at(-1);
50
+ return last
51
+ ? { line: last.position.line, column: last.position.column + last.text.length }
52
+ : { line: 1, column: 1 };
53
+ };
54
+ const peek = (ahead = 0): Token | undefined => tokens[cursor + ahead];
55
+ const fail = (expected: string, token = peek()): never => {
56
+ throw new ParseError(
57
+ token ? `Expected ${expected} but found '${token.text}'` : `Expected ${expected} but reached end of input`,
58
+ token?.position ?? end(),
59
+ );
60
+ };
61
+ const expect = (type: Token['type'], description = `'${type}'`): Token => {
62
+ const token = peek();
63
+ if (!token || token.type !== type) {
64
+ return fail(description);
65
+ }
66
+ cursor++;
67
+ return token;
68
+ };
69
+
70
+ const term = (): Ast.Term => {
71
+ const token = peek();
72
+ switch (token?.type) {
73
+ case 'variable':
74
+ cursor++;
75
+ return token.text === '_'
76
+ ? { type: 'variable', name: `_${anonymous++}`, anonymous: true }
77
+ : { type: 'variable', name: token.text };
78
+ case 'identifier':
79
+ case 'string':
80
+ case 'number':
81
+ cursor++;
82
+ return { type: 'constant', value: token.value ?? token.text };
83
+ default:
84
+ return fail('a term');
85
+ }
86
+ };
87
+
88
+ const atom = (): Ast.Atom => {
89
+ const name = expect('identifier', 'a predicate name');
90
+ const terms: Ast.Term[] = [];
91
+ if (peek()?.type === '(') {
92
+ cursor++;
93
+ if (peek()?.type !== ')') {
94
+ terms.push(term());
95
+ while (peek()?.type === ',') {
96
+ cursor++;
97
+ terms.push(term());
98
+ }
99
+ }
100
+ expect(')', "',' or ')'");
101
+ }
102
+ return { predicate: name.text, terms };
103
+ };
104
+
105
+ const aggregate = (result: Ast.Term, position: Ast.Position): Ast.AggregateLiteral => {
106
+ const name = expect('identifier', 'an aggregate function');
107
+ if (!isAggregateFunction(name.text)) {
108
+ return fail('count, min, max or sum', name);
109
+ }
110
+ const target = name.text === 'count' ? undefined : term();
111
+ expect(':', "':'");
112
+ let body: Ast.Literal[];
113
+ if (peek()?.type === '{') {
114
+ cursor++;
115
+ body = conjunction();
116
+ expect('}', "',' or '}'");
117
+ } else {
118
+ body = [literal()];
119
+ }
120
+ if (body.some((item) => item.type === 'aggregate')) {
121
+ throw new ParseError('Aggregates cannot be nested', position);
122
+ }
123
+ return { type: 'aggregate', function: name.text, result, target, body, position };
124
+ };
125
+
126
+ /** `fn :` or `fn Term :` after `=`; unknown function names are reported by `aggregate`. */
127
+ const isAggregateAhead = (): boolean =>
128
+ peek()?.type === 'identifier' && (peek(1)?.type === ':' || peek(2)?.type === ':');
129
+
130
+ const literal = (): Ast.Literal => {
131
+ const token = peek();
132
+ if (!token) {
133
+ return fail('a literal');
134
+ }
135
+ const position = token.position;
136
+ if (token.type === '!' || (token.type === 'identifier' && token.text === 'not' && peek(1)?.type !== ':-')) {
137
+ cursor++;
138
+ if (peek()?.type === '(' && token.type === 'identifier') {
139
+ cursor++;
140
+ const negated = atom();
141
+ expect(')', "')'");
142
+ return { type: 'atom', atom: negated, negated: true, position };
143
+ }
144
+ return { type: 'atom', atom: atom(), negated: true, position };
145
+ }
146
+ if (peek(1)?.type === 'operator' || token.type !== 'identifier') {
147
+ const left = term();
148
+ const operatorToken = expect('operator', 'a comparison operator');
149
+ const operator = toOperator(operatorToken.text) ?? fail('a comparison operator', operatorToken);
150
+ if (operator === '=' && isAggregateAhead()) {
151
+ return aggregate(left, position);
152
+ }
153
+ return { type: 'comparison', operator, left, right: term(), position };
154
+ }
155
+ return { type: 'atom', atom: atom(), negated: false, position };
156
+ };
157
+
158
+ const conjunction = (): Ast.Literal[] => {
159
+ const literals = [literal()];
160
+ while (peek()?.type === ',') {
161
+ cursor++;
162
+ literals.push(literal());
163
+ }
164
+ return literals;
165
+ };
166
+
167
+ const rules: Ast.Rule[] = [];
168
+ while (cursor < tokens.length) {
169
+ const position = peek()?.position;
170
+ const head = atom();
171
+ let body: Ast.Literal[] = [];
172
+ if (peek()?.type === ':-') {
173
+ cursor++;
174
+ body = conjunction();
175
+ }
176
+ expect('.', "'.', ',' or ':-'");
177
+ rules.push({ head, body, position });
178
+ }
179
+ return { rules };
180
+ };
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * as Ast from './Ast.ts';
6
+ export * as Builtin from './Builtin.ts';
7
+ export * as Checker from './Checker.ts';
8
+ export * as Engine from './Engine.ts';
9
+ export * as Parser from './Parser.ts';
@@ -0,0 +1,154 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { BaseError } from '@dxos/errors';
6
+
7
+ import type * as Ast from '../Ast.ts';
8
+
9
+ export type TokenType =
10
+ | 'identifier'
11
+ | 'variable'
12
+ | 'string'
13
+ | 'number'
14
+ | 'operator'
15
+ | ':-'
16
+ | ':'
17
+ | '('
18
+ | ')'
19
+ | '{'
20
+ | '}'
21
+ | ','
22
+ | '.'
23
+ | '!';
24
+
25
+ export type Token = {
26
+ readonly type: TokenType;
27
+ readonly text: string;
28
+ readonly value?: Ast.Value;
29
+ readonly position: Ast.Position;
30
+ };
31
+
32
+ /** A malformed token with the 1-based position where it starts. */
33
+ export class LexError extends BaseError.extend('LexError', 'Invalid token') {
34
+ constructor(
35
+ readonly reason: string,
36
+ readonly position: Ast.Position,
37
+ ) {
38
+ super({ message: reason, context: { line: position.line, column: position.column } });
39
+ }
40
+ }
41
+
42
+ const PUNCTUATION: Record<string, TokenType | undefined> = {
43
+ ':': ':',
44
+ '(': '(',
45
+ ')': ')',
46
+ '{': '{',
47
+ '}': '}',
48
+ ',': ',',
49
+ '.': '.',
50
+ '!': '!',
51
+ };
52
+
53
+ const ESCAPES: Record<string, string> = { 'n': '\n', 't': '\t', 'r': '\r', '\\': '\\', '"': '"', "'": "'" };
54
+
55
+ /** Splits dialect source into tokens with 1-based positions. */
56
+ export const tokenize = (source: string): Token[] => {
57
+ const tokens: Token[] = [];
58
+ let offset = 0;
59
+ let line = 1;
60
+ let lineStart = 0;
61
+ const here = (): Ast.Position => ({ line, column: offset - lineStart + 1 });
62
+ const advance = (count: number) => {
63
+ for (let index = 0; index < count; index++) {
64
+ if (source[offset] === '\n') {
65
+ line++;
66
+ lineStart = offset + 1;
67
+ }
68
+ offset++;
69
+ }
70
+ };
71
+
72
+ while (offset < source.length) {
73
+ const char = source[offset];
74
+ const rest = source.slice(offset);
75
+ if (/\s/.test(char)) {
76
+ advance(1);
77
+ continue;
78
+ }
79
+ if (char === '%' || rest.startsWith('//')) {
80
+ while (offset < source.length && source[offset] !== '\n') {
81
+ advance(1);
82
+ }
83
+ continue;
84
+ }
85
+ if (rest.startsWith('/*')) {
86
+ const start = here();
87
+ const end = source.indexOf('*/', offset + 2);
88
+ if (end < 0) {
89
+ throw new LexError('Unterminated comment', start);
90
+ }
91
+ advance(end + 2 - offset);
92
+ continue;
93
+ }
94
+
95
+ const position = here();
96
+ if (char === '"' || char === "'") {
97
+ let value = '';
98
+ let cursor = offset + 1;
99
+ while (cursor < source.length && source[cursor] !== char) {
100
+ if (source[cursor] === '\n') {
101
+ throw new LexError('Unterminated string', position);
102
+ }
103
+ if (source[cursor] === '\\') {
104
+ const escaped = source[cursor + 1];
105
+ value += ESCAPES[escaped] ?? escaped ?? '';
106
+ cursor += 2;
107
+ } else {
108
+ value += source[cursor++];
109
+ }
110
+ }
111
+ if (cursor >= source.length) {
112
+ throw new LexError('Unterminated string', position);
113
+ }
114
+ tokens.push({ type: 'string', text: source.slice(offset, cursor + 1), value, position });
115
+ advance(cursor + 1 - offset);
116
+ continue;
117
+ }
118
+
119
+ // A digit run glued to letters (`2d`, `30d`) is a symbol, which keeps durations unquoted.
120
+ const numeric = /^-?\d+(\.\d+)?(?![A-Za-z_\d])/.exec(rest);
121
+ if (numeric) {
122
+ tokens.push({ type: 'number', text: numeric[0], value: Number(numeric[0]), position });
123
+ advance(numeric[0].length);
124
+ continue;
125
+ }
126
+ const word = /^[A-Za-z0-9_][A-Za-z0-9_]*/.exec(rest);
127
+ if (word) {
128
+ const text = word[0];
129
+ const type: TokenType = /^[A-Z_]/.test(text) ? 'variable' : 'identifier';
130
+ tokens.push({ type, text, value: type === 'identifier' ? text : undefined, position });
131
+ advance(text.length);
132
+ continue;
133
+ }
134
+ const operator = /^(<=|>=|!=|=|<|>)/.exec(rest);
135
+ if (operator) {
136
+ tokens.push({ type: 'operator', text: operator[0], position });
137
+ advance(operator[0].length);
138
+ continue;
139
+ }
140
+ if (rest.startsWith(':-')) {
141
+ tokens.push({ type: ':-', text: ':-', position });
142
+ advance(2);
143
+ continue;
144
+ }
145
+ const punctuation = PUNCTUATION[char];
146
+ if (punctuation) {
147
+ tokens.push({ type: punctuation, text: char, position });
148
+ advance(1);
149
+ continue;
150
+ }
151
+ throw new LexError(`Unexpected character '${char}'`, position);
152
+ }
153
+ return tokens;
154
+ };