patram 0.0.2 → 0.1.1
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/bin/patram.js +25 -147
- package/lib/build-graph-identity.js +238 -0
- package/lib/build-graph.js +143 -77
- package/lib/check-graph.js +23 -7
- package/lib/claim-helpers.js +55 -0
- package/lib/command-output.js +83 -0
- package/lib/layout-stored-queries.js +213 -0
- package/lib/list-queries.js +18 -0
- package/lib/list-source-files.js +50 -15
- package/lib/load-patram-config.js +106 -18
- package/lib/load-patram-config.types.ts +9 -0
- package/lib/load-project-graph.js +124 -0
- package/lib/output-view.types.ts +73 -0
- package/lib/parse-claims.js +38 -158
- package/lib/parse-claims.types.ts +7 -0
- package/lib/parse-cli-arguments-helpers.js +273 -0
- package/lib/parse-cli-arguments.js +114 -0
- package/lib/parse-cli-arguments.types.ts +24 -0
- package/lib/parse-cli-color-options.js +44 -0
- package/lib/parse-cli-query-pagination.js +49 -0
- package/lib/parse-jsdoc-blocks.js +184 -0
- package/lib/parse-jsdoc-claims.js +280 -0
- package/lib/parse-jsdoc-prose.js +111 -0
- package/lib/parse-markdown-claims.js +242 -0
- package/lib/parse-markdown-directives.js +136 -0
- package/lib/parse-where-clause.js +312 -0
- package/lib/patram-cli.js +337 -0
- package/lib/patram-config.js +3 -1
- package/lib/patram-config.types.ts +2 -1
- package/lib/query-graph.js +256 -0
- package/lib/render-check-output.js +315 -0
- package/lib/render-json-output.js +108 -0
- package/lib/render-output-view.js +193 -0
- package/lib/render-plain-output.js +237 -0
- package/lib/render-rich-output.js +293 -0
- package/lib/render-rich-source.js +1333 -0
- package/lib/resolve-check-target.js +190 -0
- package/lib/resolve-output-mode.js +60 -0
- package/lib/resolve-patram-graph-config.js +88 -0
- package/lib/resolve-where-clause.js +51 -0
- package/lib/show-document.js +311 -0
- package/lib/source-file-defaults.js +28 -0
- package/lib/write-paged-output.js +87 -0
- package/package.json +21 -10
- package/bin/patram.test.js +0 -184
- package/lib/build-graph.test.js +0 -141
- package/lib/check-graph.test.js +0 -103
- package/lib/list-source-files.test.js +0 -101
- package/lib/load-patram-config.test.js +0 -211
- package/lib/parse-claims.test.js +0 -113
- package/lib/patram-config.test.js +0 -147
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/* eslint-disable max-lines */
|
|
2
|
+
/**
|
|
3
|
+
* @import { ParsedCliArguments } from './parse-cli-arguments.types.ts';
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import process from 'node:process';
|
|
7
|
+
|
|
8
|
+
import { checkGraph } from './check-graph.js';
|
|
9
|
+
import {
|
|
10
|
+
shouldPageCommandOutput,
|
|
11
|
+
writeCommandOutput,
|
|
12
|
+
} from './command-output.js';
|
|
13
|
+
import { listRepoFiles } from './list-source-files.js';
|
|
14
|
+
import { listQueries } from './list-queries.js';
|
|
15
|
+
import { loadPatramConfig } from './load-patram-config.js';
|
|
16
|
+
import { loadProjectGraph } from './load-project-graph.js';
|
|
17
|
+
import { parseCliArguments } from './parse-cli-arguments.js';
|
|
18
|
+
import { DEFAULT_QUERY_LIMIT, queryGraph } from './query-graph.js';
|
|
19
|
+
import {
|
|
20
|
+
renderCheckDiagnostics,
|
|
21
|
+
renderCheckSuccess,
|
|
22
|
+
} from './render-check-output.js';
|
|
23
|
+
import {
|
|
24
|
+
resolveCheckTarget,
|
|
25
|
+
selectCheckTargetDiagnostics,
|
|
26
|
+
selectCheckTargetSourceFiles,
|
|
27
|
+
} from './resolve-check-target.js';
|
|
28
|
+
import {
|
|
29
|
+
createOutputView,
|
|
30
|
+
createShowOutputView,
|
|
31
|
+
} from './render-output-view.js';
|
|
32
|
+
import { resolveWhereClause } from './resolve-where-clause.js';
|
|
33
|
+
import { resolveOutputMode } from './resolve-output-mode.js';
|
|
34
|
+
import { loadShowOutput } from './show-document.js';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Patram command execution flow.
|
|
38
|
+
*
|
|
39
|
+
* Loads repo state and routes `check`, `query`, `queries`, and `show` through
|
|
40
|
+
* the shared output pipeline.
|
|
41
|
+
*
|
|
42
|
+
* Kind: cli
|
|
43
|
+
* Status: active
|
|
44
|
+
* Implements Command: ../docs/reference/commands/check.md
|
|
45
|
+
* Implements Command: ../docs/reference/commands/query.md
|
|
46
|
+
* Implements Command: ../docs/reference/commands/queries.md
|
|
47
|
+
* Implements Command: ../docs/reference/commands/show.md
|
|
48
|
+
* Tracked in: ../docs/plans/v0/source-anchor-dogfooding.md
|
|
49
|
+
* Decided by: ../docs/decisions/cli-output-architecture.md
|
|
50
|
+
* Decided by: ../docs/decisions/cli-argument-parser.md
|
|
51
|
+
* @patram
|
|
52
|
+
* @see {@link ./parse-cli-arguments.js}
|
|
53
|
+
* @see {@link ./render-output-view.js}
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Run the Patram CLI.
|
|
58
|
+
*
|
|
59
|
+
* @param {string[]} cli_arguments
|
|
60
|
+
* @param {{ stderr: { write(chunk: string): boolean }, stdout: { isTTY?: boolean, write(chunk: string): boolean }, write_paged_output?: (output_text: string) => Promise<void> }} io_context
|
|
61
|
+
* @returns {Promise<number>}
|
|
62
|
+
*/
|
|
63
|
+
export async function main(cli_arguments, io_context) {
|
|
64
|
+
const parsed_arguments = parseCliArguments(cli_arguments);
|
|
65
|
+
|
|
66
|
+
if (!parsed_arguments.success) {
|
|
67
|
+
io_context.stderr.write(`${parsed_arguments.message}\n`);
|
|
68
|
+
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const parsed_command = parsed_arguments.value;
|
|
73
|
+
|
|
74
|
+
if (parsed_command.command_name === 'check') {
|
|
75
|
+
return runCheckCommand(parsed_command, io_context);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (parsed_command.command_name === 'query') {
|
|
79
|
+
return runQueryCommand(parsed_command, io_context);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (parsed_command.command_name === 'queries') {
|
|
83
|
+
return runQueriesCommand(parsed_command, io_context);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (parsed_command.command_name === 'show') {
|
|
87
|
+
return runShowCommand(parsed_command, io_context);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
io_context.stderr.write('Unknown command.\n');
|
|
91
|
+
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {ParsedCliArguments} parsed_command
|
|
97
|
+
* @param {{ stderr: { write(chunk: string): boolean }, stdout: { isTTY?: boolean, write(chunk: string): boolean }, write_paged_output?: (output_text: string) => Promise<void> }} io_context
|
|
98
|
+
* @returns {Promise<number>}
|
|
99
|
+
*/
|
|
100
|
+
async function runCheckCommand(parsed_command, io_context) {
|
|
101
|
+
const output_mode = resolveOutputMode(parsed_command, {
|
|
102
|
+
is_tty: io_context.stdout.isTTY === true,
|
|
103
|
+
no_color: process.env.NO_COLOR !== undefined,
|
|
104
|
+
term: process.env.TERM,
|
|
105
|
+
});
|
|
106
|
+
const resolved_target = await resolveCheckTarget(
|
|
107
|
+
parsed_command.command_arguments[0],
|
|
108
|
+
);
|
|
109
|
+
const project_graph_result = await loadProjectGraph(
|
|
110
|
+
resolved_target.project_directory,
|
|
111
|
+
);
|
|
112
|
+
const repo_file_paths = await listRepoFiles(
|
|
113
|
+
resolved_target.project_directory,
|
|
114
|
+
);
|
|
115
|
+
const selected_source_file_paths = selectCheckTargetSourceFiles(
|
|
116
|
+
project_graph_result.source_file_paths,
|
|
117
|
+
resolved_target,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
if (project_graph_result.diagnostics.length > 0) {
|
|
121
|
+
io_context.stderr.write(
|
|
122
|
+
renderCheckDiagnostics(project_graph_result.diagnostics, output_mode),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return 1;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const diagnostics = checkGraph(project_graph_result.graph, repo_file_paths);
|
|
129
|
+
const selected_diagnostics = selectCheckTargetDiagnostics(
|
|
130
|
+
diagnostics,
|
|
131
|
+
resolved_target,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
if (selected_diagnostics.length > 0) {
|
|
135
|
+
io_context.stderr.write(
|
|
136
|
+
renderCheckDiagnostics(selected_diagnostics, output_mode),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
return 1;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
io_context.stdout.write(
|
|
143
|
+
renderCheckSuccess(selected_source_file_paths.length, output_mode),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @param {ParsedCliArguments} parsed_command
|
|
151
|
+
* @param {{ stderr: { write(chunk: string): boolean }, stdout: { isTTY?: boolean, write(chunk: string): boolean }, write_paged_output?: (output_text: string) => Promise<void> }} io_context
|
|
152
|
+
* @returns {Promise<number>}
|
|
153
|
+
*/
|
|
154
|
+
async function runQueryCommand(parsed_command, io_context) {
|
|
155
|
+
const use_pager = shouldPageCommandOutput(parsed_command, io_context.stdout);
|
|
156
|
+
const project_graph_result = await loadProjectGraph(process.cwd());
|
|
157
|
+
|
|
158
|
+
if (project_graph_result.diagnostics.length > 0) {
|
|
159
|
+
writeDiagnostics(io_context.stderr, project_graph_result.diagnostics);
|
|
160
|
+
|
|
161
|
+
return 1;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const where_clause = resolveWhereClause(
|
|
165
|
+
project_graph_result.config,
|
|
166
|
+
parsed_command.command_arguments,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
if (!where_clause.success) {
|
|
170
|
+
io_context.stderr.write(`${where_clause.message}\n`);
|
|
171
|
+
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const query_result = queryGraph(
|
|
176
|
+
project_graph_result.graph,
|
|
177
|
+
where_clause.value,
|
|
178
|
+
createQueryPaginationOptions(parsed_command, use_pager),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
if (query_result.diagnostics.length > 0) {
|
|
182
|
+
writeDiagnostics(io_context.stderr, query_result.diagnostics);
|
|
183
|
+
|
|
184
|
+
return 1;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await writeCommandOutput(
|
|
188
|
+
io_context,
|
|
189
|
+
parsed_command,
|
|
190
|
+
createOutputView(
|
|
191
|
+
'query',
|
|
192
|
+
query_result.nodes,
|
|
193
|
+
createQueryOutputOptions(parsed_command, query_result, use_pager),
|
|
194
|
+
),
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @param {ParsedCliArguments} parsed_command
|
|
202
|
+
* @param {{ stderr: { write(chunk: string): boolean }, stdout: { isTTY?: boolean, write(chunk: string): boolean }, write_paged_output?: (output_text: string) => Promise<void> }} io_context
|
|
203
|
+
* @returns {Promise<number>}
|
|
204
|
+
*/
|
|
205
|
+
async function runQueriesCommand(parsed_command, io_context) {
|
|
206
|
+
const load_result = await loadPatramConfig(process.cwd());
|
|
207
|
+
|
|
208
|
+
if (load_result.diagnostics.length > 0) {
|
|
209
|
+
writeDiagnostics(io_context.stderr, load_result.diagnostics);
|
|
210
|
+
|
|
211
|
+
return 1;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const repo_config = load_result.config;
|
|
215
|
+
|
|
216
|
+
if (!repo_config) {
|
|
217
|
+
throw new Error('Expected a valid Patram repo config.');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
await writeCommandOutput(
|
|
221
|
+
io_context,
|
|
222
|
+
parsed_command,
|
|
223
|
+
createOutputView('queries', listQueries(repo_config.queries)),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @param {ParsedCliArguments} parsed_command
|
|
231
|
+
* @param {{ stderr: { write(chunk: string): boolean }, stdout: { isTTY?: boolean, write(chunk: string): boolean }, write_paged_output?: (output_text: string) => Promise<void> }} io_context
|
|
232
|
+
* @returns {Promise<number>}
|
|
233
|
+
*/
|
|
234
|
+
async function runShowCommand(parsed_command, io_context) {
|
|
235
|
+
const project_graph_result = await loadProjectGraph(process.cwd());
|
|
236
|
+
|
|
237
|
+
if (project_graph_result.diagnostics.length > 0) {
|
|
238
|
+
writeDiagnostics(io_context.stderr, project_graph_result.diagnostics);
|
|
239
|
+
|
|
240
|
+
return 1;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const show_output = await loadShowOutput(
|
|
244
|
+
parsed_command.command_arguments[0],
|
|
245
|
+
process.cwd(),
|
|
246
|
+
project_graph_result.graph,
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
if (!show_output.success) {
|
|
250
|
+
writeDiagnostics(io_context.stderr, [show_output.diagnostic]);
|
|
251
|
+
|
|
252
|
+
return 1;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
await writeCommandOutput(
|
|
256
|
+
io_context,
|
|
257
|
+
parsed_command,
|
|
258
|
+
createShowOutputView(show_output.value),
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
return 0;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* @param {{ write(chunk: string): boolean }} output_stream
|
|
266
|
+
* @param {import('./load-patram-config.types.ts').PatramDiagnostic[]} diagnostics
|
|
267
|
+
*/
|
|
268
|
+
function writeDiagnostics(output_stream, diagnostics) {
|
|
269
|
+
for (const diagnostic of diagnostics) {
|
|
270
|
+
output_stream.write(formatDiagnostic(diagnostic));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* @param {ParsedCliArguments} parsed_command
|
|
276
|
+
* @param {{ total_count: number, nodes: import('./build-graph.types.ts').GraphNode[] }} query_result
|
|
277
|
+
* @param {boolean} use_pager
|
|
278
|
+
* @returns {{ hints: string[], limit: number, offset: number, total_count: number }}
|
|
279
|
+
*/
|
|
280
|
+
function createQueryOutputOptions(parsed_command, query_result, use_pager) {
|
|
281
|
+
/** @type {string[]} */
|
|
282
|
+
const hints = [];
|
|
283
|
+
const limit =
|
|
284
|
+
parsed_command.query_limit ??
|
|
285
|
+
(use_pager ? query_result.nodes.length : DEFAULT_QUERY_LIMIT);
|
|
286
|
+
const offset = parsed_command.query_offset ?? 0;
|
|
287
|
+
|
|
288
|
+
if (query_result.total_count === 0) {
|
|
289
|
+
hints.push('Try: patram query --where "kind=task"');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (
|
|
293
|
+
!use_pager &&
|
|
294
|
+
parsed_command.query_limit === undefined &&
|
|
295
|
+
parsed_command.query_offset === undefined &&
|
|
296
|
+
query_result.total_count > DEFAULT_QUERY_LIMIT
|
|
297
|
+
) {
|
|
298
|
+
hints.push(
|
|
299
|
+
'Hint: use --offset <n> or --limit <n> to page through more matches.',
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
hints,
|
|
305
|
+
limit,
|
|
306
|
+
offset,
|
|
307
|
+
total_count: query_result.total_count,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* @param {ParsedCliArguments} parsed_command
|
|
313
|
+
* @param {boolean} use_pager
|
|
314
|
+
* @returns {{ limit?: number, offset: number }}
|
|
315
|
+
*/
|
|
316
|
+
function createQueryPaginationOptions(parsed_command, use_pager) {
|
|
317
|
+
/** @type {{ limit?: number, offset: number }} */
|
|
318
|
+
const pagination_options = {
|
|
319
|
+
offset: parsed_command.query_offset ?? 0,
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
if (parsed_command.query_limit !== undefined) {
|
|
323
|
+
pagination_options.limit = parsed_command.query_limit;
|
|
324
|
+
} else if (!use_pager) {
|
|
325
|
+
pagination_options.limit = DEFAULT_QUERY_LIMIT;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return pagination_options;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* @param {import('./load-patram-config.types.ts').PatramDiagnostic} diagnostic
|
|
333
|
+
* @returns {string}
|
|
334
|
+
*/
|
|
335
|
+
function formatDiagnostic(diagnostic) {
|
|
336
|
+
return `${diagnostic.path}:${diagnostic.line}:${diagnostic.column} ${diagnostic.level} ${diagnostic.code} ${diagnostic.message}\n`;
|
|
337
|
+
}
|
package/lib/patram-config.js
CHANGED
|
@@ -8,7 +8,8 @@ import { z } from 'zod';
|
|
|
8
8
|
const KIND_NAME_SCHEMA = z.string().min(1);
|
|
9
9
|
const RELATION_NAME_SCHEMA = z.string().min(1);
|
|
10
10
|
const CLAIM_TYPE_SCHEMA = z.string().min(1);
|
|
11
|
-
const
|
|
11
|
+
const KEY_SOURCE_SCHEMA = z.enum(['path', 'value']);
|
|
12
|
+
const TARGET_SCHEMA = z.enum(['path', 'value']);
|
|
12
13
|
|
|
13
14
|
const kind_definition_schema = z
|
|
14
15
|
.object({
|
|
@@ -28,6 +29,7 @@ const relation_definition_schema = z
|
|
|
28
29
|
const mapping_node_schema = z
|
|
29
30
|
.object({
|
|
30
31
|
field: z.string().min(1),
|
|
32
|
+
key: KEY_SOURCE_SCHEMA.optional(),
|
|
31
33
|
kind: KIND_NAME_SCHEMA,
|
|
32
34
|
})
|
|
33
35
|
.strict();
|
|
@@ -11,12 +11,13 @@ export interface RelationDefinition {
|
|
|
11
11
|
|
|
12
12
|
export interface MappingNodeDefinition {
|
|
13
13
|
field: string;
|
|
14
|
+
key?: 'path' | 'value';
|
|
14
15
|
kind: string;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export interface MappingEmitDefinition {
|
|
18
19
|
relation: string;
|
|
19
|
-
target: 'path';
|
|
20
|
+
target: 'path' | 'value';
|
|
20
21
|
target_kind: string;
|
|
21
22
|
}
|
|
22
23
|
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { BuildGraphResult, GraphNode } from './build-graph.types.ts';
|
|
3
|
+
* @import { PatramDiagnostic } from './load-patram-config.types.ts';
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { parseWhereClause } from './parse-where-clause.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Query graph filtering.
|
|
10
|
+
*
|
|
11
|
+
* Applies the v0 where-clause language to graph nodes and keeps pagination
|
|
12
|
+
* separate from matching.
|
|
13
|
+
*
|
|
14
|
+
* Kind: graph
|
|
15
|
+
* Status: active
|
|
16
|
+
* Uses Term: ../docs/reference/terms/graph.md
|
|
17
|
+
* Uses Term: ../docs/reference/terms/query.md
|
|
18
|
+
* Tracked in: ../docs/plans/v0/source-anchor-dogfooding.md
|
|
19
|
+
* Decided by: ../docs/decisions/query-language.md
|
|
20
|
+
* Implements: ../docs/tasks/v0/query-command.md
|
|
21
|
+
* @patram
|
|
22
|
+
* @see {@link ./load-project-graph.js}
|
|
23
|
+
* @see {@link ../docs/decisions/query-language.md}
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_QUERY_LIMIT = 25;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Filter graph nodes with the v0 query language.
|
|
30
|
+
*
|
|
31
|
+
* @param {BuildGraphResult} graph
|
|
32
|
+
* @param {string} where_clause
|
|
33
|
+
* @param {{ limit?: number, offset?: number }=} pagination_options
|
|
34
|
+
* @returns {{ diagnostics: PatramDiagnostic[], nodes: GraphNode[], total_count: number }}
|
|
35
|
+
*/
|
|
36
|
+
export function queryGraph(graph, where_clause, pagination_options = {}) {
|
|
37
|
+
const parse_result = parseWhereClause(where_clause);
|
|
38
|
+
|
|
39
|
+
if (!parse_result.success) {
|
|
40
|
+
return {
|
|
41
|
+
diagnostics: [parse_result.diagnostic],
|
|
42
|
+
nodes: [],
|
|
43
|
+
total_count: 0,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const predicates = parse_result.clauses.map(createPredicate);
|
|
48
|
+
const relation_index = createRelationIndex(graph.edges);
|
|
49
|
+
const graph_nodes = Object.values(graph.nodes).sort(compareGraphNodes);
|
|
50
|
+
const matching_nodes = graph_nodes.filter((graph_node) =>
|
|
51
|
+
predicates.every((predicate) => predicate(graph_node, relation_index)),
|
|
52
|
+
);
|
|
53
|
+
const paginated_nodes = paginateNodes(matching_nodes, pagination_options);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
diagnostics: [],
|
|
57
|
+
nodes: paginated_nodes,
|
|
58
|
+
total_count: matching_nodes.length,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {{ is_negated: boolean, term: { kind: 'field', field_name: 'id' | 'kind' | 'path' | 'status' | 'title', operator: '=' | '^=' | '~', value: string } | { kind: 'relation', relation_name: string } | { kind: 'relation_target', relation_name: string, target_id: string } }} clause
|
|
64
|
+
* @returns {(graph_node: GraphNode, relation_index: Map<string, Map<string, Set<string>>>) => boolean}
|
|
65
|
+
*/
|
|
66
|
+
function createPredicate(clause) {
|
|
67
|
+
if (clause.term.kind === 'relation') {
|
|
68
|
+
return createRelationPredicate(
|
|
69
|
+
clause.term.relation_name,
|
|
70
|
+
clause.is_negated,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (clause.term.kind === 'relation_target') {
|
|
75
|
+
return createRelationTargetPredicate(
|
|
76
|
+
clause.term.relation_name,
|
|
77
|
+
clause.term.target_id,
|
|
78
|
+
clause.is_negated,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return createFieldPredicateFromTerm(clause.term, clause.is_negated);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string} relation_name
|
|
87
|
+
* @param {boolean} is_negated
|
|
88
|
+
* @returns {(graph_node: GraphNode, relation_index: Map<string, Map<string, Set<string>>>) => boolean}
|
|
89
|
+
*/
|
|
90
|
+
function createRelationPredicate(relation_name, is_negated) {
|
|
91
|
+
return (graph_node, relation_index) => {
|
|
92
|
+
const relation_targets = relation_index.get(graph_node.id);
|
|
93
|
+
const is_match = relation_targets?.has(relation_name) ?? false;
|
|
94
|
+
|
|
95
|
+
return is_negated ? !is_match : is_match;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {string} relation_name
|
|
101
|
+
* @param {string} target_id
|
|
102
|
+
* @param {boolean} is_negated
|
|
103
|
+
* @returns {(graph_node: GraphNode, relation_index: Map<string, Map<string, Set<string>>>) => boolean}
|
|
104
|
+
*/
|
|
105
|
+
function createRelationTargetPredicate(relation_name, target_id, is_negated) {
|
|
106
|
+
return (graph_node, relation_index) => {
|
|
107
|
+
const relation_targets = relation_index.get(graph_node.id);
|
|
108
|
+
const matching_targets = relation_targets?.get(relation_name);
|
|
109
|
+
const is_match = matching_targets?.has(target_id) ?? false;
|
|
110
|
+
|
|
111
|
+
return is_negated ? !is_match : is_match;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @param {BuildGraphResult['edges']} graph_edges
|
|
117
|
+
* @returns {Map<string, Map<string, Set<string>>>}
|
|
118
|
+
*/
|
|
119
|
+
function createRelationIndex(graph_edges) {
|
|
120
|
+
/** @type {Map<string, Map<string, Set<string>>>} */
|
|
121
|
+
const relation_index = new Map();
|
|
122
|
+
|
|
123
|
+
for (const graph_edge of graph_edges) {
|
|
124
|
+
let relation_targets = relation_index.get(graph_edge.from);
|
|
125
|
+
|
|
126
|
+
if (!relation_targets) {
|
|
127
|
+
relation_targets = new Map();
|
|
128
|
+
relation_index.set(graph_edge.from, relation_targets);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let target_ids = relation_targets.get(graph_edge.relation);
|
|
132
|
+
|
|
133
|
+
if (!target_ids) {
|
|
134
|
+
target_ids = new Set();
|
|
135
|
+
relation_targets.set(graph_edge.relation, target_ids);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
target_ids.add(graph_edge.to);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return relation_index;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {string} field_name
|
|
146
|
+
* @param {string} expected_value
|
|
147
|
+
* @param {boolean} is_negated
|
|
148
|
+
* @returns {(graph_node: GraphNode) => boolean}
|
|
149
|
+
*/
|
|
150
|
+
function createFieldPredicate(field_name, expected_value, is_negated) {
|
|
151
|
+
return (graph_node) => {
|
|
152
|
+
const actual_value = graph_node[field_name];
|
|
153
|
+
const is_match = actual_value === expected_value;
|
|
154
|
+
|
|
155
|
+
return is_negated ? !is_match : is_match;
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param {string} id_prefix
|
|
161
|
+
* @param {boolean} is_negated
|
|
162
|
+
* @returns {(graph_node: GraphNode) => boolean}
|
|
163
|
+
*/
|
|
164
|
+
function createIdPrefixPredicate(id_prefix, is_negated) {
|
|
165
|
+
return (graph_node) => {
|
|
166
|
+
const id_value = graph_node.id;
|
|
167
|
+
const is_match = id_value.startsWith(id_prefix);
|
|
168
|
+
|
|
169
|
+
return is_negated ? !is_match : is_match;
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @param {string} path_prefix
|
|
175
|
+
* @param {boolean} is_negated
|
|
176
|
+
* @returns {(graph_node: GraphNode) => boolean}
|
|
177
|
+
*/
|
|
178
|
+
function createPathPrefixPredicate(path_prefix, is_negated) {
|
|
179
|
+
return (graph_node) => {
|
|
180
|
+
const path_value = graph_node.path ?? '';
|
|
181
|
+
const is_match = path_value.startsWith(path_prefix);
|
|
182
|
+
|
|
183
|
+
return is_negated ? !is_match : is_match;
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* @param {string} title_text
|
|
189
|
+
* @param {boolean} is_negated
|
|
190
|
+
* @returns {(graph_node: GraphNode) => boolean}
|
|
191
|
+
*/
|
|
192
|
+
function createTitlePredicate(title_text, is_negated) {
|
|
193
|
+
const normalized_title_text = title_text.toLocaleLowerCase('en');
|
|
194
|
+
|
|
195
|
+
return (graph_node) => {
|
|
196
|
+
const title_value = graph_node.title ?? '';
|
|
197
|
+
const is_match = title_value
|
|
198
|
+
.toLocaleLowerCase('en')
|
|
199
|
+
.includes(normalized_title_text);
|
|
200
|
+
|
|
201
|
+
return is_negated ? !is_match : is_match;
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* @param {{ field_name: 'id' | 'kind' | 'path' | 'status' | 'title', kind: 'field', operator: '=' | '^=' | '~', value: string }} term
|
|
207
|
+
* @param {boolean} is_negated
|
|
208
|
+
* @returns {(graph_node: GraphNode, relation_index: Map<string, Map<string, Set<string>>>) => boolean}
|
|
209
|
+
*/
|
|
210
|
+
function createFieldPredicateFromTerm(term, is_negated) {
|
|
211
|
+
const term_key = `${term.field_name}${term.operator}`;
|
|
212
|
+
|
|
213
|
+
if (
|
|
214
|
+
term_key === 'id=' ||
|
|
215
|
+
term_key === 'kind=' ||
|
|
216
|
+
term_key === 'status=' ||
|
|
217
|
+
term_key === 'path='
|
|
218
|
+
) {
|
|
219
|
+
return createFieldPredicate(term.field_name, term.value, is_negated);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (term_key === 'id^=') {
|
|
223
|
+
return createIdPrefixPredicate(term.value, is_negated);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (term_key === 'path^=') {
|
|
227
|
+
return createPathPrefixPredicate(term.value, is_negated);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (term_key === 'title~') {
|
|
231
|
+
return createTitlePredicate(term.value, is_negated);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
throw new Error('Unsupported parsed where clause.');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* @param {GraphNode} left_node
|
|
239
|
+
* @param {GraphNode} right_node
|
|
240
|
+
* @returns {number}
|
|
241
|
+
*/
|
|
242
|
+
function compareGraphNodes(left_node, right_node) {
|
|
243
|
+
return left_node.id.localeCompare(right_node.id, 'en');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* @param {GraphNode[]} matching_nodes
|
|
248
|
+
* @param {{ limit?: number, offset?: number }} pagination_options
|
|
249
|
+
* @returns {GraphNode[]}
|
|
250
|
+
*/
|
|
251
|
+
function paginateNodes(matching_nodes, pagination_options) {
|
|
252
|
+
const offset = pagination_options.offset ?? 0;
|
|
253
|
+
const limit = pagination_options.limit ?? matching_nodes.length;
|
|
254
|
+
|
|
255
|
+
return matching_nodes.slice(offset, offset + limit);
|
|
256
|
+
}
|