@aprimediet/codewalker 1.0.0 → 1.1.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,169 @@
1
+ /**
2
+ * Extract leading doc comments from source code.
3
+ *
4
+ * Given a source file and the line number of a symbol, walk upward collecting
5
+ * adjacent comment lines. PURE module — no I/O.
6
+ */
7
+
8
+ /**
9
+ * Extract the doc comment block immediately above a given line in source.
10
+ * Returns the concatenated text of the comment(s), or "" if none.
11
+ */
12
+ export function extractDocComment(source: string, symbolLine: number): string {
13
+ if (symbolLine <= 1) return "";
14
+
15
+ const lines = source.split("\n");
16
+ const parts: string[] = [];
17
+
18
+ // Walk upward from the line just above the symbol
19
+ let i = symbolLine - 2; // 0-based
20
+ if (i < 0) return "";
21
+
22
+ while (i >= 0) {
23
+ const rawLine = lines[i] as string;
24
+ const trimmed = rawLine.trim();
25
+
26
+ // Blank line stops collection
27
+ if (!trimmed) break;
28
+
29
+ // Line comment (//)
30
+ if (trimmed.startsWith("//")) {
31
+ parts.unshift(trimmed.replace(/^\/\/\s*/, ""));
32
+ i--;
33
+ continue;
34
+ }
35
+
36
+ // Block comment handling
37
+ const isBlockOpen = trimmed.startsWith("/*") || trimmed.startsWith("/**");
38
+ const isBlockClose = trimmed.endsWith("*/");
39
+
40
+ // Block comment continuation line (starts with *)
41
+ // These appear between /* and */, typically "* text" or "*"
42
+ const isBlockContent = /^\*\s?/.test(trimmed) && !isBlockOpen && !isBlockClose;
43
+
44
+ if (isBlockClose) {
45
+ // Line contains `*/`
46
+ const content = extractStarContent(trimmed);
47
+ if (content) parts.unshift(content);
48
+ if (isBlockOpen) {
49
+ // Single line: /** ... */
50
+ i--;
51
+ continue;
52
+ }
53
+ // Multi-line: enter block content mode
54
+ // Continue walking up
55
+ i--;
56
+ while (i >= 0) {
57
+ const aboveTrimmed = (lines[i] as string).trim();
58
+ if (!aboveTrimmed) break;
59
+ if (aboveTrimmed.startsWith("//")) {
60
+ // Line comment ends the block, insert it and stop
61
+ parts.unshift(aboveTrimmed.replace(/^\/\/\s*/, ""));
62
+ i--;
63
+ continue;
64
+ }
65
+ if (aboveTrimmed.startsWith("/*") || aboveTrimmed.startsWith("/**")) {
66
+ // Found the opening
67
+ const openContent = extractStarContent(aboveTrimmed);
68
+ if (openContent) parts.unshift(openContent);
69
+ i--;
70
+ break;
71
+ }
72
+ // Block content line: * text or *
73
+ if (/^\*\s?/.test(aboveTrimmed)) {
74
+ const c = aboveTrimmed.replace(/^\*\s?/, "").trim();
75
+ if (c) parts.unshift(c);
76
+ i--;
77
+ continue;
78
+ }
79
+ // Not block content — stop
80
+ break;
81
+ }
82
+ continue;
83
+ }
84
+
85
+ if (isBlockContent) {
86
+ // Encountered a * continuation line without having seen */
87
+ // This means the */ is below us (closer to the symbol)
88
+ // Walk DOWN to find it, then walk back UP
89
+ // Actually, simpler: just treat * lines as content and walk up
90
+ const content = trimmed.replace(/^\*\s?/, "").trim();
91
+ if (content) parts.unshift(content);
92
+ i--;
93
+ continue;
94
+ }
95
+
96
+ if (isBlockOpen) {
97
+ // Found opening without a close marker — extract and continue
98
+ const content = extractStarContent(trimmed);
99
+ if (content) parts.unshift(content);
100
+ i--;
101
+ continue;
102
+ }
103
+
104
+ // Python docstring (""" or ''')
105
+ if (trimmed.startsWith('"""') || trimmed.startsWith("'''")) {
106
+ const quote = trimmed.startsWith('"""') ? '"""' : "'''";
107
+
108
+ // Single-line: """text"""
109
+ if (trimmed.startsWith(quote) && trimmed.endsWith(quote) && trimmed.length > quote.length) {
110
+ const inner = trimmed.slice(quote.length, -quote.length).trim();
111
+ if (inner) parts.unshift(inner);
112
+ i--;
113
+ continue;
114
+ }
115
+
116
+ // Multi-line opening
117
+ if (trimmed.startsWith(quote) && !trimmed.endsWith(quote)) {
118
+ const afterQuote = trimmed.slice(quote.length).trim();
119
+ if (afterQuote) parts.unshift(afterQuote);
120
+ i--;
121
+ // Walk up to find closing quote
122
+ while (i >= 0) {
123
+ const aboveTrimmed = (lines[i] as string).trim();
124
+ if (aboveTrimmed.endsWith(quote)) {
125
+ const beforeQuote = aboveTrimmed.slice(0, -quote.length).trim();
126
+ if (beforeQuote) parts.unshift(beforeQuote);
127
+ i--;
128
+ break;
129
+ }
130
+ parts.unshift(aboveTrimmed);
131
+ i--;
132
+ }
133
+ continue;
134
+ }
135
+
136
+ // Just """ on its own line (closing or opening a multi-line)
137
+ if (trimmed === quote) {
138
+ // This is the closing """ — walk up to find the opening
139
+ i--;
140
+ while (i >= 0) {
141
+ const aboveTrimmed = (lines[i] as string).trim();
142
+ if (aboveTrimmed.startsWith(quote)) {
143
+ const afterQuote = aboveTrimmed.slice(quote.length).trim();
144
+ if (afterQuote) parts.unshift(afterQuote);
145
+ i--;
146
+ break;
147
+ }
148
+ parts.unshift(aboveTrimmed);
149
+ i--;
150
+ }
151
+ continue;
152
+ }
153
+ }
154
+
155
+ // Not a comment — stop
156
+ break;
157
+ }
158
+
159
+ return parts.join("\n").trim();
160
+ }
161
+
162
+ /** Extract visible content from a line inside a /* or /** block comment. */
163
+ function extractStarContent(line: string): string {
164
+ return line
165
+ .replace(/^\/\**\s*/, "") // strip /* or /**
166
+ .replace(/\s*\*\/$/, "") // strip */
167
+ .replace(/^\*\s?/, "") // strip leading *
168
+ .trim();
169
+ }
@@ -0,0 +1,202 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { extractRegex, extractTsJs, extractPython, extractGo } from './regex.ts';
3
+
4
+ describe('extractTsJs', () => {
5
+ it('extracts function declarations', () => {
6
+ const source = `
7
+ function hello() {
8
+ return 1;
9
+ }
10
+
11
+ function greet(name: string) {
12
+ return "hi";
13
+ }
14
+ `;
15
+ const symbols = extractTsJs(source, 'src/test.ts');
16
+ expect(symbols).toHaveLength(2);
17
+ expect(symbols[0]!.name).toBe('hello');
18
+ expect(symbols[0]!.kind).toBe('function');
19
+ expect(symbols[0]!.line_start).toBe(2);
20
+ expect(symbols[1]!.name).toBe('greet');
21
+ expect(symbols[1]!.line_start).toBe(6);
22
+ });
23
+
24
+ it('extracts export const (not plain const)', () => {
25
+ const source = `
26
+ export const PI = 3.14;
27
+ export const getName = () => "hello";
28
+ const local = "secret";
29
+ `;
30
+ const symbols = extractTsJs(source, 'src/consts.ts');
31
+ expect(symbols).toHaveLength(2);
32
+ expect(symbols[0]!.name).toBe('PI');
33
+ expect(symbols[0]!.kind).toBe('const');
34
+ expect(symbols[1]!.name).toBe('getName');
35
+ expect(symbols[1]!.kind).toBe('const');
36
+ });
37
+
38
+ it('extracts class declarations', () => {
39
+ const source = `
40
+ class MyClass {
41
+ method() {}
42
+ }
43
+
44
+ export class ExportedClass {
45
+ foo() {}
46
+ }
47
+ `;
48
+ const symbols = extractTsJs(source, 'src/classes.ts');
49
+ expect(symbols).toHaveLength(2);
50
+ expect(symbols[0]!.name).toBe('MyClass');
51
+ expect(symbols[0]!.kind).toBe('class');
52
+ expect(symbols[1]!.name).toBe('ExportedClass');
53
+ expect(symbols[1]!.kind).toBe('class');
54
+ });
55
+
56
+ it('extracts type and interface declarations', () => {
57
+ const source = `
58
+ type UserId = string;
59
+ interface User {
60
+ name: string;
61
+ }
62
+ export type Status = "active" | "inactive";
63
+ `;
64
+ const symbols = extractTsJs(source, 'src/types.ts');
65
+ expect(symbols).toHaveLength(3);
66
+ expect(symbols[0]!.name).toBe('UserId');
67
+ expect(symbols[0]!.kind).toBe('type');
68
+ expect(symbols[1]!.name).toBe('User');
69
+ expect(symbols[1]!.kind).toBe('interface');
70
+ expect(symbols[2]!.name).toBe('Status');
71
+ expect(symbols[2]!.kind).toBe('type');
72
+ });
73
+
74
+ it('extracts async functions and generators', () => {
75
+ const source = `
76
+ async function fetchData() {}
77
+ function* generate() {}
78
+ async function* stream() {}
79
+ `;
80
+ const symbols = extractTsJs(source, 'src/async.ts');
81
+ expect(symbols).toHaveLength(3);
82
+ expect(symbols[0]!.name).toBe('fetchData');
83
+ expect(symbols[1]!.name).toBe('generate');
84
+ expect(symbols[2]!.name).toBe('stream');
85
+ });
86
+
87
+ it('extracts export function', () => {
88
+ const source = 'export function doSomething(arg: string): void {}';
89
+ const symbols = extractTsJs(source, 'src/exports.ts');
90
+ expect(symbols).toHaveLength(1);
91
+ expect(symbols[0]!.name).toBe('doSomething');
92
+ expect(symbols[0]!.kind).toBe('function');
93
+ });
94
+ });
95
+
96
+ describe('extractPython', () => {
97
+ it('extracts function definitions', () => {
98
+ const source = `
99
+ def hello():
100
+ return 1
101
+
102
+ def greet(name):
103
+ return "hi"
104
+ `;
105
+ const symbols = extractPython(source, 'src/test.py');
106
+ expect(symbols).toHaveLength(2);
107
+ expect(symbols[0]!.name).toBe('hello');
108
+ expect(symbols[0]!.kind).toBe('function');
109
+ expect(symbols[0]!.line_start).toBe(2);
110
+ expect(symbols[1]!.name).toBe('greet');
111
+ expect(symbols[1]!.line_start).toBe(5);
112
+ });
113
+
114
+ it('extracts class definitions and methods', () => {
115
+ const source = `
116
+ class MyClass:
117
+ pass
118
+
119
+ class AnotherClass:
120
+ def method(self):
121
+ pass
122
+ `;
123
+ const symbols = extractPython(source, 'src/classes.py');
124
+ expect(symbols).toHaveLength(3);
125
+ expect(symbols[0]!.name).toBe('MyClass');
126
+ expect(symbols[0]!.kind).toBe('class');
127
+ expect(symbols[1]!.name).toBe('AnotherClass');
128
+ expect(symbols[1]!.kind).toBe('class');
129
+ expect(symbols[2]!.name).toBe('method');
130
+ expect(symbols[2]!.kind).toBe('function');
131
+ });
132
+
133
+ it('extracts async def', () => {
134
+ const source = 'async def fetch_data(): pass';
135
+ const symbols = extractPython(source, 'src/async.py');
136
+ expect(symbols).toHaveLength(1);
137
+ expect(symbols[0]!.name).toBe('fetch_data');
138
+ expect(symbols[0]!.kind).toBe('function');
139
+ });
140
+ });
141
+
142
+ describe('extractGo', () => {
143
+ it('extracts function declarations', () => {
144
+ const source = `
145
+ func hello() {}
146
+
147
+ func greet(name string) string {
148
+ return name
149
+ }
150
+ `;
151
+ const symbols = extractGo(source, 'src/main.go');
152
+ expect(symbols).toHaveLength(2);
153
+ expect(symbols[0]!.name).toBe('hello');
154
+ expect(symbols[0]!.kind).toBe('function');
155
+ expect(symbols[0]!.line_start).toBe(2);
156
+ expect(symbols[1]!.name).toBe('greet');
157
+ expect(symbols[1]!.line_start).toBe(4);
158
+ });
159
+
160
+ it('extracts methods with receivers', () => {
161
+ const source = `
162
+ func (u *User) GetName() string {
163
+ return u.name
164
+ }
165
+
166
+ func (s *Service) Serve() error {
167
+ return nil
168
+ }
169
+ `;
170
+ const symbols = extractGo(source, 'src/methods.go');
171
+ expect(symbols).toHaveLength(2);
172
+ expect(symbols[0]!.name).toBe('GetName');
173
+ expect(symbols[0]!.kind).toBe('method');
174
+ expect(symbols[1]!.name).toBe('Serve');
175
+ expect(symbols[1]!.kind).toBe('method');
176
+ });
177
+ });
178
+
179
+ describe('extractRegex', () => {
180
+ it('dispatches to the correct language extractor for .ts', () => {
181
+ const symbols = extractRegex('function foo() {}', 'src/test.ts');
182
+ expect(symbols).toHaveLength(1);
183
+ expect(symbols[0]!.name).toBe('foo');
184
+ });
185
+
186
+ it('dispatches to the correct language extractor for .py', () => {
187
+ const symbols = extractRegex('def foo(): pass', 'src/test.py');
188
+ expect(symbols).toHaveLength(1);
189
+ expect(symbols[0]!.name).toBe('foo');
190
+ });
191
+
192
+ it('dispatches to the correct language extractor for .go', () => {
193
+ const symbols = extractRegex('func foo() {}', 'src/test.go');
194
+ expect(symbols).toHaveLength(1);
195
+ expect(symbols[0]!.name).toBe('foo');
196
+ });
197
+
198
+ it('returns empty array for unsupported file extensions', () => {
199
+ const symbols = extractRegex('fn foo() {}', 'src/test.rs');
200
+ expect(symbols).toEqual([]);
201
+ });
202
+ });
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Regex-based fallback symbol extraction for TS/JS/Py/Go.
3
+ *
4
+ * Used when ctags is not available on PATH. PURE module — no I/O.
5
+ *
6
+ * This is best-effort: it uses line-oriented regex patterns and does a simple
7
+ * comment/string skip that handles most common cases but is not an AST parser.
8
+ */
9
+
10
+ import type { Symbol, SymbolKind } from "../types.ts";
11
+
12
+ /** Extract symbols from TypeScript/JavaScript source. */
13
+ export function extractTsJs(source: string, filePath: string): Symbol[] {
14
+ const lines = source.split("\n");
15
+ const symbols: Symbol[] = [];
16
+
17
+ // Track multi-line comments
18
+ let inBlockComment = false;
19
+
20
+ // Patterns for TS/JS declarations
21
+ const patterns: Array<{ regex: RegExp; kind: SymbolKind }> = [
22
+ { regex: /^(?:export\s+)?(?:async\s+)?function\s+(?:<[^>]+>\s+)?(\w+)/, kind: "function" },
23
+ { regex: /^(?:export\s+)?(?:async\s+)?function\s*\*?\s*(\w+)/, kind: "function" },
24
+ { regex: /^(?:export\s+)?class\s+(\w+)/, kind: "class" },
25
+ { regex: /^(?:export\s+)?interface\s+(\w+)/, kind: "interface" },
26
+ { regex: /^(?:export\s+)?type\s+(\w+)\s*=/, kind: "type" },
27
+ { regex: /^export\s+(?:const|let|var)\s+(\w+)\s*(?::\s*[^=]+)?\s*=/, kind: "const" },
28
+ { regex: /^export\s+(?:const|let|var)\s+(\w+)\s*(?::\s*[^;{]+)?;/, kind: "const" },
29
+ { regex: /^(?:export\s+)?enum\s+(\w+)/, kind: "enum" },
30
+ { regex: /^(?:export\s+)?abstract\s+class\s+(\w+)/, kind: "class" },
31
+ { regex: /^(?:export\s+)?default\s+(?:async\s+)?function\s+(\w+)/, kind: "function" },
32
+ ];
33
+
34
+ for (let i = 0; i < lines.length; i++) {
35
+ const line = lines[i] as string;
36
+ const trimmed = line.trim();
37
+
38
+ // Skip empty lines
39
+ if (!trimmed) continue;
40
+
41
+ // Track block comments
42
+ if (inBlockComment) {
43
+ if (trimmed.includes("*/")) {
44
+ inBlockComment = false;
45
+ }
46
+ continue;
47
+ }
48
+ if (trimmed.startsWith("/*") || trimmed.startsWith("/**")) {
49
+ if (trimmed.includes("*/") && !trimmed.startsWith("*/")) {
50
+ // Single-line block comment, skip
51
+ continue;
52
+ }
53
+ if (!trimmed.includes("*/")) {
54
+ inBlockComment = true;
55
+ }
56
+ continue;
57
+ }
58
+
59
+ // Skip single-line comments
60
+ if (trimmed.startsWith("//") || trimmed.startsWith("#")) continue;
61
+
62
+ // Apply patterns
63
+ for (const { regex, kind } of patterns) {
64
+ const match = trimmed.match(regex);
65
+ if (match && match[1]) {
66
+ symbols.push({
67
+ name: match[1],
68
+ kind,
69
+ file_path: filePath,
70
+ line_start: i + 1, // 1-based
71
+ line_end: i + 1,
72
+ signature: "",
73
+ doc: "",
74
+ summary: "",
75
+ card_path: "",
76
+ });
77
+ break; // one symbol per line
78
+ }
79
+ }
80
+ }
81
+
82
+ return symbols;
83
+ }
84
+
85
+ /** Extract symbols from Python source. */
86
+ export function extractPython(source: string, filePath: string): Symbol[] {
87
+ const lines = source.split("\n");
88
+ const symbols: Symbol[] = [];
89
+
90
+ const patterns: Array<{ regex: RegExp; kind: SymbolKind }> = [
91
+ { regex: /^(?:async\s+)?def\s+(\w+)\s*\(/, kind: "function" },
92
+ { regex: /^class\s+(\w+)/, kind: "class" },
93
+ ];
94
+
95
+ for (let i = 0; i < lines.length; i++) {
96
+ const trimmed = (lines[i] as string).trim();
97
+ if (!trimmed || trimmed.startsWith("#")) continue;
98
+
99
+ for (const { regex, kind } of patterns) {
100
+ const match = trimmed.match(regex);
101
+ if (match && match[1]) {
102
+ symbols.push({
103
+ name: match[1],
104
+ kind,
105
+ file_path: filePath,
106
+ line_start: i + 1,
107
+ line_end: i + 1,
108
+ signature: "",
109
+ doc: "",
110
+ summary: "",
111
+ card_path: "",
112
+ });
113
+ break;
114
+ }
115
+ }
116
+ }
117
+
118
+ return symbols;
119
+ }
120
+
121
+ /** Extract symbols from Go source. */
122
+ export function extractGo(source: string, filePath: string): Symbol[] {
123
+ const lines = source.split("\n");
124
+ const symbols: Symbol[] = [];
125
+
126
+ const funcPattern = /^func\s+(?:\([^)]*\)\s+)?(\w+)\s*\(/;
127
+ const methodPattern = /^func\s+\([^)]*\)\s+(\w+)\s*\(/;
128
+
129
+ for (let i = 0; i < lines.length; i++) {
130
+ const trimmed = (lines[i] as string).trim();
131
+ if (!trimmed || trimmed.startsWith("//") || trimmed.startsWith("/*")) continue;
132
+
133
+ // Check for method first (has receiver)
134
+ const methodMatch = trimmed.match(methodPattern);
135
+ if (methodMatch && methodMatch[1]) {
136
+ symbols.push({
137
+ name: methodMatch[1],
138
+ kind: "method",
139
+ file_path: filePath,
140
+ line_start: i + 1,
141
+ line_end: i + 1,
142
+ signature: "",
143
+ doc: "",
144
+ summary: "",
145
+ card_path: "",
146
+ });
147
+ continue;
148
+ }
149
+
150
+ // Then check for regular function
151
+ const funcMatch = trimmed.match(funcPattern);
152
+ if (funcMatch && funcMatch[1]) {
153
+ symbols.push({
154
+ name: funcMatch[1],
155
+ kind: "function",
156
+ file_path: filePath,
157
+ line_start: i + 1,
158
+ line_end: i + 1,
159
+ signature: "",
160
+ doc: "",
161
+ summary: "",
162
+ card_path: "",
163
+ });
164
+ }
165
+ }
166
+
167
+ return symbols;
168
+ }
169
+
170
+ /**
171
+ * Dispatch to the correct language extractor based on file extension.
172
+ * Returns an empty array for unsupported languages.
173
+ */
174
+ export function extractRegex(source: string, filePath: string): Symbol[] {
175
+ const ext = filePath.toLowerCase().split(".").pop() ?? "";
176
+
177
+ switch (ext) {
178
+ case "ts":
179
+ case "tsx":
180
+ case "js":
181
+ case "jsx":
182
+ case "mjs":
183
+ case "cjs":
184
+ return extractTsJs(source, filePath);
185
+ case "py":
186
+ return extractPython(source, filePath);
187
+ case "go":
188
+ return extractGo(source, filePath);
189
+ default:
190
+ return [];
191
+ }
192
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { formatCompact, formatCardBody } from './format.ts';
3
+ import type { QueryResultRow, StalenessInfo } from './types.ts';
4
+
5
+ function makeRow(overrides: Partial<QueryResultRow> = {}): QueryResultRow {
6
+ return {
7
+ id: 1,
8
+ name: 'myFunc',
9
+ kind: 'function',
10
+ file_path: 'src/util/helper.ts',
11
+ line_start: 10,
12
+ line_end: 20,
13
+ signature: '(x: number) => string',
14
+ summary: 'Does something useful with the input',
15
+ score: 0.5,
16
+ ...overrides,
17
+ };
18
+ }
19
+
20
+ describe('formatCompact', () => {
21
+ it('formats N rows into N compact lines', () => {
22
+ const rows = [makeRow({ name: 'foo' }), makeRow({ name: 'bar', kind: 'class' })];
23
+ const result = formatCompact(rows, null);
24
+ expect(result).toContain('foo');
25
+ expect(result).toContain('bar');
26
+ expect(result).toContain('function');
27
+ expect(result).toContain('class');
28
+ expect(result).toContain('helper.ts:10-20');
29
+ });
30
+
31
+ it('truncates long summaries', () => {
32
+ const longSummary = 'A'.repeat(200);
33
+ const rows = [makeRow({ summary: longSummary })];
34
+ const result = formatCompact(rows, null);
35
+ // Summary should be truncated
36
+ expect(result.length).toBeLessThan(400);
37
+ });
38
+
39
+ it('outputs capped at limit', () => {
40
+ const rows = Array.from({ length: 20 }, (_, i) => makeRow({ name: `func${i}` }));
41
+ const result = formatCompact(rows.slice(0, 5), null);
42
+ expect(result).toContain('func0');
43
+ expect(result).not.toContain('func10');
44
+ });
45
+
46
+ it('returns a friendly message for empty rows', () => {
47
+ const result = formatCompact([], null);
48
+ expect(result).toContain('No matches');
49
+ });
50
+
51
+ it('appends staleness note when present', () => {
52
+ const staleness: StalenessInfo = {
53
+ indexedCommit: 'abc123',
54
+ headCommit: 'def456',
55
+ changedFiles: 3,
56
+ message: 'index stale',
57
+ };
58
+ const rows = [makeRow()];
59
+ const result = formatCompact(rows, staleness);
60
+ expect(result).toContain('index stale');
61
+ expect(result).toContain('abc123');
62
+ expect(result).toContain('def456');
63
+ });
64
+ });
65
+
66
+ describe('formatCardBody', () => {
67
+ it('returns the card body text', () => {
68
+ const body = '# myFunc\n\nDoes something.\n';
69
+ expect(formatCardBody(body)).toContain('Does something.');
70
+ });
71
+ });
package/src/format.ts ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Compact result formatter for codewalker queries.
3
+ *
4
+ * PURE module — no I/O. Produces token-bounded, one-line-per-hit output.
5
+ */
6
+
7
+ import type { QueryResultRow, StalenessInfo } from "./types.ts";
8
+
9
+ const SUMMARY_MAX = 60;
10
+
11
+ /**
12
+ * Format query results into compact, token-efficient output.
13
+ *
14
+ * Each row becomes one line: `name · kind · file:line · summary`
15
+ * The total output is bounded by the number of rows.
16
+ */
17
+ export function formatCompact(
18
+ rows: QueryResultRow[],
19
+ staleness: StalenessInfo | null,
20
+ ): string {
21
+ if (rows.length === 0) {
22
+ let msg = "No matches found.";
23
+ if (staleness) {
24
+ msg += ` (${staleness.message})`;
25
+ }
26
+ return msg;
27
+ }
28
+
29
+ const lines = rows.map((row) => {
30
+ const loc = `${basename(row.file_path)}:${row.line_start}-${row.line_end}`;
31
+ const summary = truncate(row.summary || "", SUMMARY_MAX);
32
+ return `${row.name} · ${row.kind} · ${loc} · ${summary}`;
33
+ });
34
+
35
+ if (staleness) {
36
+ lines.push(
37
+ `---\n⚠ ${staleness.message}: indexed @${shortSha(staleness.indexedCommit)}, HEAD @${shortSha(staleness.headCommit)} (${staleness.changedFiles} file(s) changed)`,
38
+ );
39
+ }
40
+
41
+ return lines.join("\n");
42
+ }
43
+
44
+ /**
45
+ * Format a card body for display.
46
+ */
47
+ export function formatCardBody(body: string): string {
48
+ return body.trim();
49
+ }
50
+
51
+ function basename(filePath: string): string {
52
+ const idx = filePath.lastIndexOf("/");
53
+ return idx >= 0 ? filePath.slice(idx + 1) : filePath;
54
+ }
55
+
56
+ function truncate(s: string, max: number): string {
57
+ if (s.length <= max) return s;
58
+ return s.slice(0, max - 1) + "…";
59
+ }
60
+
61
+ function shortSha(sha: string): string {
62
+ return sha.slice(0, 7);
63
+ }