patram 0.9.0 → 0.10.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.
@@ -0,0 +1,63 @@
1
+ export type CliCommandName = 'check' | 'fields' | 'query' | 'queries' | 'refs' | 'show';
2
+ export type CliHelpTopicName = 'query-language';
3
+ export type CliHelpTargetKind = 'root' | 'command' | 'topic';
4
+ export type CliUnexpectedArgumentCommandName = CliCommandName | 'help';
5
+ export type CliOutputMode = 'default' | 'plain' | 'json';
6
+ export type CliColorMode = 'auto' | 'always' | 'never';
7
+ export interface ParsedCliCommandRequest {
8
+ kind?: 'command';
9
+ color_mode: CliColorMode;
10
+ command_arguments: string[];
11
+ command_name: CliCommandName;
12
+ output_mode: CliOutputMode;
13
+ query_inspection_mode?: 'explain' | 'lint';
14
+ query_limit?: number;
15
+ query_offset?: number;
16
+ }
17
+ export interface ParsedCliHelpRequest {
18
+ kind: 'help';
19
+ target_kind: CliHelpTargetKind;
20
+ target_name?: CliCommandName | CliHelpTopicName;
21
+ }
22
+ export type ParsedCliArguments = ParsedCliCommandRequest;
23
+ export type ParsedCliRequest = ParsedCliCommandRequest | ParsedCliHelpRequest;
24
+ export type CliParseError = {
25
+ code: 'message';
26
+ message: string;
27
+ } | {
28
+ code: 'missing_required_argument';
29
+ argument_label: string;
30
+ command_name: 'query' | 'refs' | 'show';
31
+ } | {
32
+ code: 'unexpected_argument';
33
+ command_name: CliUnexpectedArgumentCommandName;
34
+ token: string;
35
+ } | {
36
+ code: 'option_not_valid_for_command';
37
+ command_name: CliCommandName;
38
+ token: string;
39
+ } | {
40
+ code: 'unknown_command';
41
+ suggestion?: CliCommandName;
42
+ token: string;
43
+ } | {
44
+ code: 'unknown_help_target';
45
+ suggestion?: CliCommandName | CliHelpTopicName;
46
+ token: string;
47
+ } | {
48
+ code: 'unknown_stored_query';
49
+ name: string;
50
+ suggestion?: string;
51
+ } | {
52
+ code: 'unknown_option';
53
+ command_name?: CliCommandName;
54
+ suggestion?: string;
55
+ token: string;
56
+ };
57
+ export type ParseCliArgumentsResult = {
58
+ success: true;
59
+ value: ParsedCliRequest;
60
+ } | {
61
+ error: CliParseError;
62
+ success: false;
63
+ };
@@ -12,7 +12,10 @@ import {
12
12
  } from '../../output/command-output.js';
13
13
  import { createDerivedSummaryEvaluator } from '../../output/derived-summary.js';
14
14
  import { renderCheckDiagnostics } from '../../output/render-check-output.js';
15
- import { renderInvalidWhereDiagnostic } from '../render-help.js';
15
+ import {
16
+ renderCliParseError,
17
+ renderInvalidWhereDiagnostic,
18
+ } from '../render-help.js';
16
19
  import { createOutputView } from '../../output/render-output-view.js';
17
20
  import { loadPatramConfig } from '../../config/load-patram-config.js';
18
21
  import { loadProjectGraph } from '../../graph/load-project-graph.js';
@@ -60,7 +63,7 @@ export async function runQueryCommand(parsed_command, io_context) {
60
63
  );
61
64
 
62
65
  if (!where_clause.success) {
63
- io_context.stderr.write(`${where_clause.message}\n`);
66
+ io_context.stderr.write(renderCliParseError(where_clause.error));
64
67
 
65
68
  return 1;
66
69
  }
@@ -139,7 +142,7 @@ async function runQueryInspectionCommand(
139
142
  );
140
143
 
141
144
  if (!where_clause.success) {
142
- io_context.stderr.write(`${where_clause.message}\n`);
145
+ io_context.stderr.write(renderCliParseError(where_clause.error));
143
146
 
144
147
  return 1;
145
148
  }
@@ -6,6 +6,8 @@
6
6
  * } from './arguments.types.ts';
7
7
  */
8
8
 
9
+ import { findCloseMatch } from '../find-close-match.js';
10
+
9
11
  /**
10
12
  * @typedef {{
11
13
  * description: string,
@@ -532,100 +534,3 @@ function listOptionLabels(command_name) {
532
534
 
533
535
  return [...option_labels];
534
536
  }
535
-
536
- /**
537
- * @param {string} input_text
538
- * @param {readonly string[]} candidates
539
- * @returns {string | undefined}
540
- */
541
- function findCloseMatch(input_text, candidates) {
542
- let best_candidate;
543
- let best_score = 0;
544
-
545
- for (const candidate of candidates) {
546
- const score = scoreCandidate(input_text, candidate);
547
-
548
- if (score > best_score) {
549
- best_candidate = candidate;
550
- best_score = score;
551
- }
552
- }
553
-
554
- if (best_score < 0.6) {
555
- return undefined;
556
- }
557
-
558
- return best_candidate;
559
- }
560
-
561
- /**
562
- * @param {string} input_text
563
- * @param {string} candidate
564
- * @returns {number}
565
- */
566
- function scoreCandidate(input_text, candidate) {
567
- const max_length = Math.max(input_text.length, candidate.length);
568
-
569
- if (max_length === 0) {
570
- return 1;
571
- }
572
-
573
- return (
574
- 1 - calculateDamerauLevenshteinDistance(input_text, candidate) / max_length
575
- );
576
- }
577
-
578
- /**
579
- * @param {string} left_text
580
- * @param {string} right_text
581
- * @returns {number}
582
- */
583
- function calculateDamerauLevenshteinDistance(left_text, right_text) {
584
- /** @type {number[][]} */
585
- const matrix = Array.from({ length: left_text.length + 1 }, () =>
586
- Array.from({ length: right_text.length + 1 }, () => 0),
587
- );
588
-
589
- for (let left_index = 0; left_index <= left_text.length; left_index += 1) {
590
- matrix[left_index][0] = left_index;
591
- }
592
-
593
- for (
594
- let right_index = 0;
595
- right_index <= right_text.length;
596
- right_index += 1
597
- ) {
598
- matrix[0][right_index] = right_index;
599
- }
600
-
601
- for (let left_index = 1; left_index <= left_text.length; left_index += 1) {
602
- for (
603
- let right_index = 1;
604
- right_index <= right_text.length;
605
- right_index += 1
606
- ) {
607
- const substitution_cost =
608
- left_text[left_index - 1] === right_text[right_index - 1] ? 0 : 1;
609
-
610
- matrix[left_index][right_index] = Math.min(
611
- matrix[left_index - 1][right_index] + 1,
612
- matrix[left_index][right_index - 1] + 1,
613
- matrix[left_index - 1][right_index - 1] + substitution_cost,
614
- );
615
-
616
- if (
617
- left_index > 1 &&
618
- right_index > 1 &&
619
- left_text[left_index - 1] === right_text[right_index - 2] &&
620
- left_text[left_index - 2] === right_text[right_index - 1]
621
- ) {
622
- matrix[left_index][right_index] = Math.min(
623
- matrix[left_index][right_index],
624
- matrix[left_index - 2][right_index - 2] + 1,
625
- );
626
- }
627
- }
628
- }
629
-
630
- return matrix[left_text.length][right_text.length];
631
- }
@@ -67,9 +67,7 @@ export function validateHelpCommandLine(command_line) {
67
67
  }
68
68
 
69
69
  if (command_line.positionals.length > 2) {
70
- return createMessageParseError(
71
- 'Help accepts at most one topic or command.',
72
- );
70
+ return createUnexpectedArgumentError('help', command_line.positionals[2]);
73
71
  }
74
72
 
75
73
  return null;
@@ -422,8 +420,9 @@ function validateCommandPositionals(command_name, command_positionals) {
422
420
  }
423
421
 
424
422
  if (command_positionals.length > command_definition.max_positionals) {
425
- return createMessageParseError(
426
- command_definition.extra_positionals_message,
423
+ return createUnexpectedArgumentError(
424
+ command_name,
425
+ command_positionals[command_definition.max_positionals],
427
426
  );
428
427
  }
429
428
 
@@ -451,3 +450,16 @@ function isKnownCommandOptionName(option_name) {
451
450
  option_name === 'where'
452
451
  );
453
452
  }
453
+
454
+ /**
455
+ * @param {'help' | CliCommandName} command_name
456
+ * @param {string | undefined} token
457
+ * @returns {CliParseError}
458
+ */
459
+ function createUnexpectedArgumentError(command_name, token) {
460
+ return {
461
+ code: 'unexpected_argument',
462
+ command_name,
463
+ token: token ?? '',
464
+ };
465
+ }
@@ -75,6 +75,20 @@ export function renderCliParseError(parse_error) {
75
75
  );
76
76
  }
77
77
 
78
+ if (parse_error.code === 'unexpected_argument') {
79
+ return renderUnexpectedArgumentError(
80
+ parse_error.command_name,
81
+ parse_error.token,
82
+ );
83
+ }
84
+
85
+ if (parse_error.code === 'unknown_stored_query') {
86
+ return renderUnknownStoredQueryError(
87
+ parse_error.name,
88
+ parse_error.suggestion,
89
+ );
90
+ }
91
+
78
92
  return `${parse_error.message}\n`;
79
93
  }
80
94
 
@@ -326,6 +340,49 @@ function renderMissingRequiredArgumentError(command_name, argument_label) {
326
340
  ]);
327
341
  }
328
342
 
343
+ /**
344
+ * @param {'help' | CliCommandName} command_name
345
+ * @param {string} invalid_token
346
+ * @returns {string}
347
+ */
348
+ function renderUnexpectedArgumentError(command_name, invalid_token) {
349
+ return joinOutputLines([
350
+ `Unexpected argument: ${invalid_token}`,
351
+ '',
352
+ 'Usage:',
353
+ ...indentLines(getUnexpectedArgumentUsageLines(command_name)),
354
+ '',
355
+ 'Next:',
356
+ ` ${renderUnexpectedArgumentNext(command_name)}`,
357
+ ]);
358
+ }
359
+
360
+ /**
361
+ * @param {string} stored_query_name
362
+ * @param {string | undefined} suggestion
363
+ * @returns {string}
364
+ */
365
+ function renderUnknownStoredQueryError(stored_query_name, suggestion) {
366
+ if (suggestion) {
367
+ return joinOutputLines([
368
+ `Unknown stored query: ${stored_query_name}`,
369
+ '',
370
+ 'Did you mean:',
371
+ ` ${suggestion}`,
372
+ '',
373
+ 'Next:',
374
+ ` patram query ${suggestion}`,
375
+ ]);
376
+ }
377
+
378
+ return joinOutputLines([
379
+ `Unknown stored query: ${stored_query_name}`,
380
+ '',
381
+ 'Next:',
382
+ ' patram queries',
383
+ ]);
384
+ }
385
+
329
386
  /**
330
387
  * @param {string} invalid_token
331
388
  * @param {CliCommandName | CliHelpTopicName | undefined} suggestion
@@ -378,6 +435,30 @@ function renderCommandHelpPath(command_name) {
378
435
  return `patram help ${command_name}`;
379
436
  }
380
437
 
438
+ /**
439
+ * @param {'help' | CliCommandName} command_name
440
+ * @returns {string}
441
+ */
442
+ function renderUnexpectedArgumentNext(command_name) {
443
+ if (command_name === 'help') {
444
+ return 'patram --help';
445
+ }
446
+
447
+ return renderCommandHelpPath(command_name);
448
+ }
449
+
450
+ /**
451
+ * @param {'help' | CliCommandName} command_name
452
+ * @returns {string[]}
453
+ */
454
+ function getUnexpectedArgumentUsageLines(command_name) {
455
+ if (command_name === 'help') {
456
+ return ['patram help [command]'];
457
+ }
458
+
459
+ return getCommandDefinition(command_name).usage_lines;
460
+ }
461
+
381
462
  /**
382
463
  * @param {string[]} lines
383
464
  * @returns {string[]}
@@ -141,6 +141,7 @@ export const patram_repo_config_schema: z.ZodObject<{
141
141
  prefixes: z.ZodArray<z.ZodString>;
142
142
  }, z.core.$strict>>>;
143
143
  queries: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
144
+ description: z.ZodOptional<z.ZodString>;
144
145
  where: z.ZodString;
145
146
  }, z.core.$strict>>>;
146
147
  relations: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
@@ -167,6 +168,7 @@ import { z } from 'zod';
167
168
  * @typedef {z.output<typeof stored_query_schema>} StoredQueryConfig
168
169
  */
169
170
  declare const stored_query_schema: z.ZodObject<{
171
+ description: z.ZodOptional<z.ZodString>;
170
172
  where: z.ZodString;
171
173
  }, z.core.$strict>;
172
174
  /**
@@ -27,6 +27,10 @@ const MIXED_STYLE_VALUES = new Set(['ignore', 'error']);
27
27
  */
28
28
  const stored_query_schema = z
29
29
  .object({
30
+ description: z
31
+ .string()
32
+ .min(1, 'Stored query "description" must not be empty.')
33
+ .optional(),
30
34
  where: z.string().min(1, 'Stored query "where" must not be empty.'),
31
35
  })
32
36
  .strict();
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Find the closest candidate above the shared suggestion threshold.
3
+ *
4
+ * @param {string} input_text
5
+ * @param {readonly string[]} candidates
6
+ * @returns {string | undefined}
7
+ */
8
+ export function findCloseMatch(input_text: string, candidates: readonly string[]): string | undefined;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Find the closest candidate above the shared suggestion threshold.
3
+ *
4
+ * @param {string} input_text
5
+ * @param {readonly string[]} candidates
6
+ * @returns {string | undefined}
7
+ */
8
+ export function findCloseMatch(input_text, candidates) {
9
+ let best_candidate;
10
+ let best_score = 0;
11
+
12
+ for (const candidate of candidates) {
13
+ const score = scoreCandidate(input_text, candidate);
14
+
15
+ if (score > best_score) {
16
+ best_candidate = candidate;
17
+ best_score = score;
18
+ }
19
+ }
20
+
21
+ if (best_score < 0.6) {
22
+ return undefined;
23
+ }
24
+
25
+ return best_candidate;
26
+ }
27
+
28
+ /**
29
+ * @param {string} input_text
30
+ * @param {string} candidate
31
+ * @returns {number}
32
+ */
33
+ function scoreCandidate(input_text, candidate) {
34
+ const max_length = Math.max(input_text.length, candidate.length);
35
+
36
+ if (max_length === 0) {
37
+ return 1;
38
+ }
39
+
40
+ return (
41
+ 1 - calculateDamerauLevenshteinDistance(input_text, candidate) / max_length
42
+ );
43
+ }
44
+
45
+ /**
46
+ * @param {string} left_text
47
+ * @param {string} right_text
48
+ * @returns {number}
49
+ */
50
+ function calculateDamerauLevenshteinDistance(left_text, right_text) {
51
+ /** @type {number[][]} */
52
+ const matrix = Array.from({ length: left_text.length + 1 }, () =>
53
+ Array.from({ length: right_text.length + 1 }, () => 0),
54
+ );
55
+
56
+ for (let left_index = 0; left_index <= left_text.length; left_index += 1) {
57
+ matrix[left_index][0] = left_index;
58
+ }
59
+
60
+ for (
61
+ let right_index = 0;
62
+ right_index <= right_text.length;
63
+ right_index += 1
64
+ ) {
65
+ matrix[0][right_index] = right_index;
66
+ }
67
+
68
+ for (let left_index = 1; left_index <= left_text.length; left_index += 1) {
69
+ for (
70
+ let right_index = 1;
71
+ right_index <= right_text.length;
72
+ right_index += 1
73
+ ) {
74
+ const substitution_cost =
75
+ left_text[left_index - 1] === right_text[right_index - 1] ? 0 : 1;
76
+
77
+ matrix[left_index][right_index] = Math.min(
78
+ matrix[left_index - 1][right_index] + 1,
79
+ matrix[left_index][right_index - 1] + 1,
80
+ matrix[left_index - 1][right_index - 1] + substitution_cost,
81
+ );
82
+
83
+ if (
84
+ left_index > 1 &&
85
+ right_index > 1 &&
86
+ left_text[left_index - 1] === right_text[right_index - 2] &&
87
+ left_text[left_index - 2] === right_text[right_index - 1]
88
+ ) {
89
+ matrix[left_index][right_index] = Math.min(
90
+ matrix[left_index][right_index],
91
+ matrix[left_index - 2][right_index - 2] + substitution_cost,
92
+ );
93
+ }
94
+ }
95
+ }
96
+
97
+ return matrix[left_text.length][right_text.length];
98
+ }
@@ -1,6 +1,3 @@
1
- /**
2
- * @import { PatramRepoConfig } from '../../config/load-patram-config.types.d.ts';
3
- */
4
1
  /**
5
2
  * @typedef {{ kind: 'ad_hoc' } | { kind: 'stored_query', name: string }} QuerySource
6
3
  */
@@ -9,7 +6,7 @@
9
6
  *
10
7
  * @param {PatramRepoConfig} repo_config
11
8
  * @param {string[]} command_arguments
12
- * @returns {{ success: true, value: { query_source: QuerySource, where_clause: string } } | { success: false, message: string }}
9
+ * @returns {{ success: true, value: { query_source: QuerySource, where_clause: string } } | { error: CliParseError, success: false }}
13
10
  */
14
11
  export function resolveWhereClause(repo_config: PatramRepoConfig, command_arguments: string[]): {
15
12
  success: true;
@@ -18,8 +15,8 @@ export function resolveWhereClause(repo_config: PatramRepoConfig, command_argume
18
15
  where_clause: string;
19
16
  };
20
17
  } | {
18
+ error: CliParseError;
21
19
  success: false;
22
- message: string;
23
20
  };
24
21
  export type QuerySource = {
25
22
  kind: "ad_hoc";
@@ -28,3 +25,4 @@ export type QuerySource = {
28
25
  name: string;
29
26
  };
30
27
  import type { PatramRepoConfig } from '../../config/load-patram-config.types.d.ts';
28
+ import type { CliParseError } from '../../cli/arguments.types.d.ts';
@@ -1,7 +1,10 @@
1
1
  /**
2
+ * @import { CliParseError } from '../../cli/arguments.types.ts';
2
3
  * @import { PatramRepoConfig } from '../../config/load-patram-config.types.ts';
3
4
  */
4
5
 
6
+ import { findCloseMatch } from '../../find-close-match.js';
7
+
5
8
  /**
6
9
  * @typedef {{ kind: 'ad_hoc' } | { kind: 'stored_query', name: string }} QuerySource
7
10
  */
@@ -11,7 +14,7 @@
11
14
  *
12
15
  * @param {PatramRepoConfig} repo_config
13
16
  * @param {string[]} command_arguments
14
- * @returns {{ success: true, value: { query_source: QuerySource, where_clause: string } } | { success: false, message: string }}
17
+ * @returns {{ success: true, value: { query_source: QuerySource, where_clause: string } } | { error: CliParseError, success: false }}
15
18
  */
16
19
  export function resolveWhereClause(repo_config, command_arguments) {
17
20
  if (command_arguments[0] === '--where') {
@@ -19,7 +22,10 @@ export function resolveWhereClause(repo_config, command_arguments) {
19
22
 
20
23
  if (where_clause.length === 0) {
21
24
  return {
22
- message: 'Query requires a where clause.',
25
+ error: {
26
+ code: 'message',
27
+ message: 'Query requires a where clause.',
28
+ },
23
29
  success: false,
24
30
  };
25
31
  }
@@ -39,7 +45,10 @@ export function resolveWhereClause(repo_config, command_arguments) {
39
45
 
40
46
  if (!stored_query_name) {
41
47
  return {
42
- message: 'Query requires "--where" or a stored query name.',
48
+ error: {
49
+ code: 'message',
50
+ message: 'Query requires "--where" or a stored query name.',
51
+ },
43
52
  success: false,
44
53
  };
45
54
  }
@@ -48,7 +57,10 @@ export function resolveWhereClause(repo_config, command_arguments) {
48
57
 
49
58
  if (!stored_query) {
50
59
  return {
51
- message: `Stored query "${stored_query_name}" was not found.`,
60
+ error: createUnknownStoredQueryError(
61
+ stored_query_name,
62
+ Object.keys(repo_config.queries),
63
+ ),
52
64
  success: false,
53
65
  };
54
66
  }
@@ -64,3 +76,25 @@ export function resolveWhereClause(repo_config, command_arguments) {
64
76
  },
65
77
  };
66
78
  }
79
+
80
+ /**
81
+ * @param {string} stored_query_name
82
+ * @param {string[]} stored_query_names
83
+ * @returns {CliParseError}
84
+ */
85
+ function createUnknownStoredQueryError(stored_query_name, stored_query_names) {
86
+ const suggestion = findCloseMatch(stored_query_name, stored_query_names);
87
+
88
+ if (!suggestion) {
89
+ return {
90
+ code: 'unknown_stored_query',
91
+ name: stored_query_name,
92
+ };
93
+ }
94
+
95
+ return {
96
+ code: 'unknown_stored_query',
97
+ name: stored_query_name,
98
+ suggestion,
99
+ };
100
+ }
@@ -10,7 +10,7 @@ const MIN_TERM_COLUMN_WIDTH = 20;
10
10
  const STORED_QUERY_COLUMN_GAP = 2;
11
11
 
12
12
  /**
13
- * @typedef {'field_name' | 'keyword' | 'literal' | 'name' | 'operator' | 'plain'} StoredQuerySegmentKind
13
+ * @typedef {'description' | 'field_name' | 'keyword' | 'literal' | 'name' | 'operator' | 'plain'} StoredQuerySegmentKind
14
14
  */
15
15
 
16
16
  /**
@@ -56,7 +56,8 @@ function layoutStoredQuery(output_item, name_column_width, term_column_width) {
56
56
  name_column_width + STORED_QUERY_COLUMN_GAP,
57
57
  );
58
58
 
59
- return term_lines.map((line_segments, line_index) => {
59
+ /** @type {StoredQuerySegment[][]} */
60
+ const output_lines = term_lines.map((line_segments, line_index) => {
60
61
  if (line_index === 0) {
61
62
  return [
62
63
  {
@@ -79,6 +80,21 @@ function layoutStoredQuery(output_item, name_column_width, term_column_width) {
79
80
  ...line_segments,
80
81
  ];
81
82
  });
83
+
84
+ if (output_item.description) {
85
+ for (const description_line of output_item.description.split('\n')) {
86
+ output_lines.push([
87
+ {
88
+ kind: 'description',
89
+ text: `${' '.repeat(name_column_width + STORED_QUERY_COLUMN_GAP)}${description_line}`,
90
+ },
91
+ ]);
92
+ }
93
+ }
94
+
95
+ output_lines.push([]);
96
+
97
+ return output_lines;
82
98
  }
83
99
 
84
100
  /**
@@ -6,12 +6,13 @@
6
6
  * List stored queries in stable name order.
7
7
  *
8
8
  * @param {Record<string, StoredQueryConfig>} stored_queries
9
- * @returns {{ name: string, where: string }[]}
9
+ * @returns {{ name: string, where: string, description?: string }[]}
10
10
  */
11
11
  export function listQueries(stored_queries) {
12
12
  return Object.entries(stored_queries)
13
13
  .sort(([left_name], [right_name]) => left_name.localeCompare(right_name))
14
14
  .map(([name, stored_query]) => ({
15
+ description: stored_query.description,
15
16
  name,
16
17
  where: stored_query.where,
17
18
  }));
@@ -92,9 +92,6 @@ function renderJsonRefsOutput(output_view) {
92
92
  function renderJsonShowOutput(output_view) {
93
93
  return `${JSON.stringify(
94
94
  {
95
- document: output_view.document
96
- ? formatJsonNodeItem(output_view.document)
97
- : undefined,
98
95
  incoming_summary: output_view.incoming_summary,
99
96
  source: output_view.source,
100
97
  resolved_links: output_view.items.map(formatJsonResolvedLink),
@@ -144,13 +141,20 @@ function formatJsonNodeItem(output_item) {
144
141
 
145
142
  /**
146
143
  * @param {OutputStoredQueryItem} output_item
147
- * @returns {{ name: string, where: string }}
144
+ * @returns {{ name: string, where: string, description?: string }}
148
145
  */
149
146
  function formatJsonStoredQuery(output_item) {
150
- return {
147
+ /** @type {{ description?: string, name: string, where: string }} */
148
+ const stored_query = {
151
149
  name: output_item.name,
152
150
  where: output_item.where,
153
151
  };
152
+
153
+ if (output_item.description) {
154
+ stored_query.description = output_item.description;
155
+ }
156
+
157
+ return stored_query;
154
158
  }
155
159
 
156
160
  /**
@@ -24,7 +24,7 @@ export function renderPlainOutput(output_view) {
24
24
  }
25
25
 
26
26
  if (output_view.command === 'queries') {
27
- return renderPlainStoredQueries(output_view.items);
27
+ return renderPlainStoredQueries(output_view);
28
28
  }
29
29
 
30
30
  if (output_view.command === 'refs') {
@@ -69,17 +69,23 @@ function renderPlainEmptyQuery(footer) {
69
69
  }
70
70
 
71
71
  /**
72
- * @param {OutputStoredQueryItem[]} output_items
72
+ * @param {Extract<OutputView, { command: 'queries' }>} output_view
73
73
  * @returns {string}
74
74
  */
75
- function renderPlainStoredQueries(output_items) {
76
- if (output_items.length === 0) {
75
+ function renderPlainStoredQueries(output_view) {
76
+ if (output_view.items.length === 0) {
77
77
  return '';
78
78
  }
79
79
 
80
- return `${layoutStoredQueries(output_items)
81
- .map(formatPlainStoredQueryLine)
82
- .join('\n')}\n`;
80
+ const output_lines = layoutStoredQueries(output_view.items).map(
81
+ formatPlainStoredQueryLine,
82
+ );
83
+
84
+ if (output_view.hints.length === 0) {
85
+ return `${output_lines.join('\n')}\n`;
86
+ }
87
+
88
+ return `${output_lines.join('\n')}\n${output_view.hints.join('\n')}\n`;
83
89
  }
84
90
 
85
91
  /**
@@ -88,29 +94,12 @@ function renderPlainStoredQueries(output_items) {
88
94
  */
89
95
  function renderPlainShowOutput(output_view) {
90
96
  const rendered_source = trimTrailingLineBreaks(output_view.rendered_source);
91
- const document_summary = output_view.document
92
- ? formatPlainNodeItem(output_view.document)
93
- : '';
94
97
  const incoming_summary = renderPlainIncomingSummary(output_view);
95
98
 
96
- if (
97
- document_summary.length === 0 &&
98
- output_view.items.length === 0 &&
99
- incoming_summary.length === 0
100
- ) {
99
+ if (output_view.items.length === 0 && incoming_summary.length === 0) {
101
100
  return `${rendered_source}\n`;
102
101
  }
103
-
104
- /** @type {string[]} */
105
- const summary_items = [];
106
-
107
- if (document_summary.length > 0) {
108
- summary_items.push(document_summary);
109
- }
110
-
111
- summary_items.push(...output_view.items.map(formatPlainResolvedLinkItem));
112
-
113
- const summary_blocks = summary_items.filter((summary_item) => summary_item);
102
+ const summary_blocks = output_view.items.map(formatPlainResolvedLinkItem);
114
103
 
115
104
  if (incoming_summary.length > 0) {
116
105
  summary_blocks.push(incoming_summary);
@@ -33,7 +33,7 @@ export async function renderRichOutput(output_view, render_options) {
33
33
  }
34
34
 
35
35
  if (output_view.command === 'queries') {
36
- return renderRichStoredQueries(output_view.items, ansi);
36
+ return renderRichStoredQueries(output_view, ansi);
37
37
  }
38
38
 
39
39
  if (output_view.command === 'refs') {
@@ -83,18 +83,24 @@ function renderRichEmptyQuery(footer, ansi) {
83
83
  }
84
84
 
85
85
  /**
86
- * @param {OutputStoredQueryItem[]} output_items
86
+ * @param {Extract<OutputView, { command: 'queries' }>} output_view
87
87
  * @param {Ansis} ansi
88
88
  * @returns {string}
89
89
  */
90
- function renderRichStoredQueries(output_items, ansi) {
91
- if (output_items.length === 0) {
90
+ function renderRichStoredQueries(output_view, ansi) {
91
+ if (output_view.items.length === 0) {
92
92
  return '';
93
93
  }
94
94
 
95
- return `${layoutStoredQueries(output_items)
96
- .map((line_segments) => formatRichStoredQueryLine(line_segments, ansi))
97
- .join('\n')}\n`;
95
+ const output_lines = layoutStoredQueries(output_view.items).map(
96
+ (line_segments) => formatRichStoredQueryLine(line_segments, ansi),
97
+ );
98
+
99
+ if (output_view.hints.length === 0) {
100
+ return `${output_lines.join('\n')}\n`;
101
+ }
102
+
103
+ return `${output_lines.join('\n')}\n${output_view.hints.map((hint) => ansi.gray(hint)).join('\n')}\n`;
98
104
  }
99
105
 
100
106
  /**
@@ -107,28 +113,14 @@ async function renderRichShowOutput(output_view, render_options, ansi) {
107
113
  const rendered_source = trimTrailingLineBreaks(
108
114
  await renderRichSource(output_view, render_options),
109
115
  );
110
- const document_summary = output_view.document
111
- ? formatRichNodeItem(output_view.document, ansi)
112
- : '';
113
116
  const incoming_summary = renderRichIncomingSummary(output_view, ansi);
114
117
 
115
- if (
116
- document_summary.length === 0 &&
117
- output_view.items.length === 0 &&
118
- incoming_summary.length === 0
119
- ) {
118
+ if (output_view.items.length === 0 && incoming_summary.length === 0) {
120
119
  return `${rendered_source}\n`;
121
120
  }
122
121
 
123
- /** @type {string[]} */
124
- const summary_items = [];
125
-
126
- if (document_summary.length > 0) {
127
- summary_items.push(document_summary);
128
- }
129
-
130
- summary_items.push(
131
- ...output_view.items.map((item) => formatRichResolvedLinkItem(item, ansi)),
122
+ const summary_items = output_view.items.map((item) =>
123
+ formatRichResolvedLinkItem(item, ansi),
132
124
  );
133
125
 
134
126
  if (incoming_summary.length > 0) {
@@ -173,7 +165,7 @@ function formatRichNodeItem(output_item, ansi) {
173
165
  }
174
166
 
175
167
  /**
176
- * @param {{ kind: 'field_name' | 'keyword' | 'literal' | 'name' | 'operator' | 'plain', text: string }[]} line_segments
168
+ * @param {{ kind: 'description' | 'field_name' | 'keyword' | 'literal' | 'name' | 'operator' | 'plain', text: string }[]} line_segments
177
169
  * @param {Ansis} ansi
178
170
  * @returns {string}
179
171
  */
@@ -232,7 +224,7 @@ function createAnsi(color_enabled) {
232
224
  }
233
225
 
234
226
  /**
235
- * @param {{ kind: 'field_name' | 'keyword' | 'literal' | 'name' | 'operator' | 'plain', text: string }} line_segment
227
+ * @param {{ kind: 'description' | 'field_name' | 'keyword' | 'literal' | 'name' | 'operator' | 'plain', text: string }} line_segment
236
228
  * @param {Ansis} ansi
237
229
  * @returns {string}
238
230
  */
@@ -241,6 +233,10 @@ function styleStoredQuerySegment(line_segment, ansi) {
241
233
  return ansi.green(line_segment.text);
242
234
  }
243
235
 
236
+ if (line_segment.kind === 'description') {
237
+ return ansi.gray(line_segment.text);
238
+ }
239
+
244
240
  if (line_segment.kind === 'operator') {
245
241
  return ansi.gray(line_segment.text);
246
242
  }
@@ -12,7 +12,7 @@ import { resolveDocumentNodeId } from '../../graph/build-graph-identity.js';
12
12
  * Create a shared output view from one command result.
13
13
  *
14
14
  * @param {'query' | 'queries'} command_name
15
- * @param {GraphNode[] | { name: string, where: string }[]} command_items
15
+ * @param {GraphNode[] | { name: string, where: string, description?: string }[]} command_items
16
16
  * @param {{ derived_summary_evaluator?: DerivedSummaryEvaluator, hints?: string[], limit?: number, offset?: number, repo_config?: PatramRepoConfig, total_count?: number }=} command_options
17
17
  * @returns {OutputView}
18
18
  */
@@ -41,23 +41,8 @@ export function createOutputView(command_name, command_items, command_options) {
41
41
  * @returns {ShowOutputView}
42
42
  */
43
43
  export function createShowOutputView(show_output, command_options = {}) {
44
- const shown_document_node = resolveDocumentGraphNode(
45
- command_options.graph_nodes,
46
- command_options.document_node_ids,
47
- show_output.path,
48
- );
49
-
50
44
  return {
51
45
  command: 'show',
52
- document: shown_document_node
53
- ? createOutputNodeItem(
54
- shown_document_node,
55
- command_options.derived_summary_evaluator?.evaluate(
56
- shown_document_node,
57
- ) ?? null,
58
- command_options.repo_config?.fields ?? {},
59
- )
60
- : undefined,
61
46
  hints: [],
62
47
  incoming_summary: show_output.incoming_summary,
63
48
  items: show_output.resolved_links.map((resolved_link) =>
@@ -144,17 +129,19 @@ function createQueryOutputView(graph_nodes, command_options = {}) {
144
129
  }
145
130
 
146
131
  /**
147
- * @param {{ name: string, where: string }[]} stored_queries
132
+ * @param {{ name: string, where: string, description?: string }[]} stored_queries
148
133
  * @returns {OutputView}
149
134
  */
150
135
  function createStoredQueriesOutputView(stored_queries) {
151
136
  return {
152
137
  command: 'queries',
153
- hints: [],
138
+ hints:
139
+ stored_queries.length === 0 ? [] : ['Hint: patram help query-language'],
154
140
  items: stored_queries.map((stored_query) => ({
155
141
  kind: 'stored_query',
156
142
  name: stored_query.name,
157
143
  where: stored_query.where,
144
+ description: stored_query.description,
158
145
  })),
159
146
  summary: {
160
147
  count: stored_queries.length,
@@ -20,6 +20,11 @@ import { resolve } from 'node:path';
20
20
  import { DEFAULT_INCLUDE_PATTERNS } from '../config/source-file-defaults.js';
21
21
  import { listSourceFiles } from './list-source-files.js';
22
22
  import { parseSourceFile } from '../parse/parse-claims.js';
23
+ import {
24
+ matchHiddenDirectiveFields,
25
+ matchVisibleDirectiveFields,
26
+ } from '../parse/markdown/parse-markdown-directives.js';
27
+ import { isPathLikeTarget } from '../parse/claim-helpers.js';
23
28
 
24
29
  /**
25
30
  * Field discovery from source claims.
@@ -71,10 +76,9 @@ export async function discoverFields(
71
76
  options,
72
77
  ) {
73
78
  const defined_field_names = options?.defined_field_names ?? new Set();
74
- const source_file_paths = await listSourceFiles(
75
- DEFAULT_INCLUDE_PATTERNS,
76
- project_directory,
77
- );
79
+ const source_file_paths = (
80
+ await listSourceFiles(DEFAULT_INCLUDE_PATTERNS, project_directory)
81
+ ).filter((source_file_path) => source_file_path.includes('/'));
78
82
  const parse_results = await Promise.all(
79
83
  source_file_paths.map(async (source_file_path) => {
80
84
  const source_text = await readFile(
@@ -88,6 +92,7 @@ export async function discoverFields(
88
92
  source: source_text,
89
93
  }).claims,
90
94
  path: source_file_path,
95
+ source_text,
91
96
  };
92
97
  }),
93
98
  );
@@ -107,13 +112,20 @@ export async function discoverFields(
107
112
  }
108
113
  }
109
114
 
115
+ const allowed_markdown_lines = collectAllowedMarkdownDirectiveLines(
116
+ parse_result.path,
117
+ parse_result.source_text,
118
+ parse_result.claims,
119
+ );
120
+
110
121
  return parse_result.claims.flatMap((claim) => {
111
122
  if (
112
123
  claim.type !== 'directive' ||
113
124
  !claim.name ||
114
125
  claim.name.startsWith('$') ||
115
126
  typeof claim.value !== 'string' ||
116
- claim.value.length === 0
127
+ claim.value.length === 0 ||
128
+ !shouldIncludeDiscoveryClaim(claim, allowed_markdown_lines)
117
129
  ) {
118
130
  return [];
119
131
  }
@@ -123,6 +135,7 @@ export async function discoverFields(
123
135
  class_names: new Set(document_classes),
124
136
  document_id: claim.document_id,
125
137
  name: claim.name,
138
+ normalized_value: normalizeDiscoveryValue(claim.value),
126
139
  origin: claim.origin,
127
140
  value: claim.value,
128
141
  },
@@ -147,7 +160,9 @@ export async function discoverFields(
147
160
  const fields = [...field_buckets.values()]
148
161
  .map(buildFieldSuggestion)
149
162
  .filter(
150
- (field_suggestion) => !defined_field_names.has(field_suggestion.name),
163
+ (field_suggestion) =>
164
+ !defined_field_names.has(field_suggestion.name) &&
165
+ isPlausibleFieldName(field_suggestion.name),
151
166
  )
152
167
  .sort((left_suggestion, right_suggestion) =>
153
168
  left_suggestion.confidence !== right_suggestion.confidence
@@ -182,7 +197,10 @@ function buildFieldSuggestion(field_bucket) {
182
197
  const conflicting_evidence = buildEvidenceReferences(
183
198
  field_bucket.observations.filter(
184
199
  (field_observation) =>
185
- scoreFieldValue(field_observation.value, type_result.name) === 0,
200
+ scoreFieldValue(
201
+ field_observation.normalized_value,
202
+ type_result.name,
203
+ ) === 0,
186
204
  ),
187
205
  );
188
206
 
@@ -228,12 +246,13 @@ function buildEvidenceReferences(observations) {
228
246
  function inferFieldMultiplicity(observations) {
229
247
  /** @type {Map<string, Set<string>>} */
230
248
  const values_by_document = observations.reduce((values, observation) => {
249
+ const normalized_value = observation.normalized_value;
231
250
  const current_values = values.get(observation.document_id);
232
251
 
233
252
  if (current_values) {
234
- current_values.add(observation.value);
253
+ current_values.add(normalized_value);
235
254
  } else {
236
- values.set(observation.document_id, new Set([observation.value]));
255
+ values.set(observation.document_id, new Set([normalized_value]));
237
256
  }
238
257
 
239
258
  return values;
@@ -369,7 +388,7 @@ function scoreFieldType(observations, field_type_name) {
369
388
 
370
389
  const total_score = observations.reduce(
371
390
  (sum, observation) =>
372
- sum + scoreFieldValue(observation.value, field_type_name),
391
+ sum + scoreFieldValue(observation.normalized_value, field_type_name),
373
392
  0,
374
393
  );
375
394
 
@@ -423,6 +442,7 @@ const FIELD_TYPE_SCORERS = {
423
442
  * class_names: Set<string>,
424
443
  * document_id: string,
425
444
  * name: string,
445
+ * normalized_value: string,
426
446
  * origin: ClaimOrigin,
427
447
  * value: string,
428
448
  * }} FieldObservation
@@ -434,3 +454,109 @@ const FIELD_TYPE_SCORERS = {
434
454
  * observations: FieldObservation[],
435
455
  * }} FieldBucket
436
456
  */
457
+
458
+ /**
459
+ * @param {PatramClaim} claim
460
+ * @param {Set<number> | null} allowed_markdown_lines
461
+ * @returns {boolean}
462
+ */
463
+ function shouldIncludeDiscoveryClaim(claim, allowed_markdown_lines) {
464
+ if (claim.parser !== 'markdown') {
465
+ return true;
466
+ }
467
+
468
+ if (claim.markdown_style === 'front_matter') {
469
+ return true;
470
+ }
471
+
472
+ return allowed_markdown_lines?.has(claim.origin.line) ?? false;
473
+ }
474
+
475
+ /**
476
+ * @param {string} file_path
477
+ * @param {string} source_text
478
+ * @param {PatramClaim[]} claims
479
+ * @returns {Set<number> | null}
480
+ */
481
+ function collectAllowedMarkdownDirectiveLines(file_path, source_text, claims) {
482
+ if (!file_path.endsWith('.md')) {
483
+ return null;
484
+ }
485
+
486
+ const title_claim = claims.find((claim) => claim.type === 'document.title');
487
+
488
+ if (!title_claim) {
489
+ return new Set();
490
+ }
491
+
492
+ const lines = source_text.split('\n');
493
+ /** @type {Set<number>} */
494
+ const allowed_lines = new Set();
495
+
496
+ for (
497
+ let line_index = title_claim.origin.line;
498
+ line_index < lines.length;
499
+ line_index += 1
500
+ ) {
501
+ const line = lines[line_index];
502
+
503
+ if (line.trim().length === 0) {
504
+ continue;
505
+ }
506
+
507
+ if (isMarkdownDiscoveryDirective(file_path, line, line_index + 1)) {
508
+ allowed_lines.add(line_index + 1);
509
+ continue;
510
+ }
511
+
512
+ break;
513
+ }
514
+
515
+ return allowed_lines;
516
+ }
517
+
518
+ /**
519
+ * @param {string} file_path
520
+ * @param {string} line
521
+ * @param {number} line_number
522
+ * @returns {boolean}
523
+ */
524
+ function isMarkdownDiscoveryDirective(file_path, line, line_number) {
525
+ return (
526
+ matchVisibleDirectiveFields(file_path, line, line_number) !== null ||
527
+ matchHiddenDirectiveFields(file_path, line, line_number) !== null
528
+ );
529
+ }
530
+
531
+ /**
532
+ * @param {string} value
533
+ * @returns {string}
534
+ */
535
+ function normalizeDiscoveryValue(value) {
536
+ const trimmed_value = value.trim();
537
+ const markdown_link_match = trimmed_value.match(
538
+ /^\[([^\]]+)\]\(([^)]+)\)$/du,
539
+ );
540
+
541
+ if (markdown_link_match && isPathLikeTarget(markdown_link_match[2])) {
542
+ return markdown_link_match[2];
543
+ }
544
+
545
+ const code_span_match = trimmed_value.match(/^`([^`]+)`[.,;:]?$/du);
546
+
547
+ if (code_span_match) {
548
+ return code_span_match[1];
549
+ }
550
+
551
+ return trimmed_value;
552
+ }
553
+
554
+ /**
555
+ * @param {string} field_name
556
+ * @returns {boolean}
557
+ */
558
+ function isPlausibleFieldName(field_name) {
559
+ const field_name_tokens = field_name.split('_');
560
+
561
+ return field_name.length <= 32 && field_name_tokens.length <= 4;
562
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patram",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "main": "./lib/patram.js",
6
6
  "types": "./lib/patram.d.ts",