tokenlean 0.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.
- package/README.md +248 -0
- package/bin/tl-api.mjs +515 -0
- package/bin/tl-blame.mjs +345 -0
- package/bin/tl-complexity.mjs +514 -0
- package/bin/tl-component.mjs +274 -0
- package/bin/tl-config.mjs +135 -0
- package/bin/tl-context.mjs +156 -0
- package/bin/tl-coverage.mjs +456 -0
- package/bin/tl-deps.mjs +474 -0
- package/bin/tl-diff.mjs +183 -0
- package/bin/tl-entry.mjs +256 -0
- package/bin/tl-env.mjs +376 -0
- package/bin/tl-exports.mjs +583 -0
- package/bin/tl-flow.mjs +324 -0
- package/bin/tl-history.mjs +289 -0
- package/bin/tl-hotspots.mjs +321 -0
- package/bin/tl-impact.mjs +345 -0
- package/bin/tl-prompt.mjs +175 -0
- package/bin/tl-related.mjs +227 -0
- package/bin/tl-routes.mjs +627 -0
- package/bin/tl-search.mjs +123 -0
- package/bin/tl-structure.mjs +161 -0
- package/bin/tl-symbols.mjs +430 -0
- package/bin/tl-todo.mjs +341 -0
- package/bin/tl-types.mjs +441 -0
- package/bin/tl-unused.mjs +494 -0
- package/package.json +55 -0
- package/src/config.mjs +271 -0
- package/src/output.mjs +251 -0
- package/src/project.mjs +277 -0
package/src/config.mjs
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared configuration system for tokenlean CLI tools
|
|
3
|
+
*
|
|
4
|
+
* Config files (in order of priority, higher overrides lower):
|
|
5
|
+
* 1. .tokenleanrc.json in project root (or parent directories)
|
|
6
|
+
* 2. ~/.tokenleanrc.json (global defaults)
|
|
7
|
+
*
|
|
8
|
+
* Example config:
|
|
9
|
+
* {
|
|
10
|
+
* "output": { "maxLines": 100, "format": "text" },
|
|
11
|
+
* "ignore": ["node_modules", "dist"],
|
|
12
|
+
* "searchPatterns": { ... },
|
|
13
|
+
* "hotspots": { "days": 90 },
|
|
14
|
+
* "structure": { "depth": 3 }
|
|
15
|
+
* }
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync } from 'fs';
|
|
19
|
+
import { dirname, join } from 'path';
|
|
20
|
+
import { homedir } from 'os';
|
|
21
|
+
|
|
22
|
+
// ─────────────────────────────────────────────────────────────
|
|
23
|
+
// Constants
|
|
24
|
+
// ─────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
const CONFIG_FILENAME = '.tokenleanrc.json';
|
|
27
|
+
const GLOBAL_CONFIG_PATH = join(homedir(), CONFIG_FILENAME);
|
|
28
|
+
|
|
29
|
+
// Default configuration values
|
|
30
|
+
const DEFAULT_CONFIG = {
|
|
31
|
+
output: {
|
|
32
|
+
maxLines: null,
|
|
33
|
+
maxTokens: null,
|
|
34
|
+
format: 'text' // 'text' | 'json'
|
|
35
|
+
},
|
|
36
|
+
// Extensions to built-in skip/important lists (always extend, never replace)
|
|
37
|
+
skipDirs: [],
|
|
38
|
+
skipExtensions: [],
|
|
39
|
+
importantDirs: [],
|
|
40
|
+
importantFiles: [],
|
|
41
|
+
searchPatterns: {
|
|
42
|
+
hooks: {
|
|
43
|
+
description: 'React hooks usage',
|
|
44
|
+
pattern: 'use[A-Z]\\w+\\(',
|
|
45
|
+
glob: '**/*.{ts,tsx,js,jsx}'
|
|
46
|
+
},
|
|
47
|
+
errors: {
|
|
48
|
+
description: 'Error handling (throw, catch, Error)',
|
|
49
|
+
pattern: '(throw |catch\\s*\\(|new Error)',
|
|
50
|
+
glob: '**/*.{ts,tsx,js,jsx,mjs}'
|
|
51
|
+
},
|
|
52
|
+
env: {
|
|
53
|
+
description: 'Environment variables',
|
|
54
|
+
pattern: 'process\\.env\\.|import\\.meta\\.env',
|
|
55
|
+
glob: '**/*.{ts,tsx,js,jsx,mjs}'
|
|
56
|
+
},
|
|
57
|
+
routes: {
|
|
58
|
+
description: 'Route definitions',
|
|
59
|
+
pattern: '(app|router)\\.(get|post|put|delete|patch|use)\\(|path:\\s*[\'"]/',
|
|
60
|
+
glob: '**/*.{ts,tsx,js,jsx,mjs}'
|
|
61
|
+
},
|
|
62
|
+
exports: {
|
|
63
|
+
description: 'Exported functions and classes',
|
|
64
|
+
pattern: '^export (function|class|const|default)',
|
|
65
|
+
glob: '**/*.{ts,tsx,js,jsx,mjs}'
|
|
66
|
+
},
|
|
67
|
+
async: {
|
|
68
|
+
description: 'Async patterns (async/await, Promise)',
|
|
69
|
+
pattern: '(async |await |Promise\\.|\\. then\\()',
|
|
70
|
+
glob: '**/*.{ts,tsx,js,jsx,mjs}'
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
hotspots: {
|
|
74
|
+
days: 90
|
|
75
|
+
},
|
|
76
|
+
structure: {
|
|
77
|
+
depth: 3
|
|
78
|
+
},
|
|
79
|
+
symbols: {
|
|
80
|
+
includePrivate: false
|
|
81
|
+
},
|
|
82
|
+
impact: {
|
|
83
|
+
depth: 2
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// ─────────────────────────────────────────────────────────────
|
|
88
|
+
// Config Loading
|
|
89
|
+
// ─────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Deep merge two objects (source into target)
|
|
93
|
+
* Arrays are replaced, not merged
|
|
94
|
+
*/
|
|
95
|
+
function deepMerge(target, source) {
|
|
96
|
+
const result = { ...target };
|
|
97
|
+
|
|
98
|
+
for (const key of Object.keys(source)) {
|
|
99
|
+
if (
|
|
100
|
+
source[key] !== null &&
|
|
101
|
+
typeof source[key] === 'object' &&
|
|
102
|
+
!Array.isArray(source[key]) &&
|
|
103
|
+
target[key] !== null &&
|
|
104
|
+
typeof target[key] === 'object' &&
|
|
105
|
+
!Array.isArray(target[key])
|
|
106
|
+
) {
|
|
107
|
+
result[key] = deepMerge(target[key], source[key]);
|
|
108
|
+
} else {
|
|
109
|
+
result[key] = source[key];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Load and parse a JSON config file
|
|
118
|
+
* Returns null if file doesn't exist or is invalid
|
|
119
|
+
*/
|
|
120
|
+
function loadConfigFile(path) {
|
|
121
|
+
if (!existsSync(path)) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const content = readFileSync(path, 'utf-8');
|
|
127
|
+
return JSON.parse(content);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
// Silently ignore invalid JSON - tools can warn if needed
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Find project config by walking up directory tree
|
|
136
|
+
* Returns { config, path, root } or null if not found
|
|
137
|
+
*/
|
|
138
|
+
function findProjectConfig(startDir = process.cwd()) {
|
|
139
|
+
let dir = startDir;
|
|
140
|
+
|
|
141
|
+
while (dir !== '/') {
|
|
142
|
+
const configPath = join(dir, CONFIG_FILENAME);
|
|
143
|
+
|
|
144
|
+
if (existsSync(configPath)) {
|
|
145
|
+
const config = loadConfigFile(configPath);
|
|
146
|
+
if (config) {
|
|
147
|
+
return { config, path: configPath, root: dir };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
dir = dirname(dir);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ─────────────────────────────────────────────────────────────
|
|
158
|
+
// Main API
|
|
159
|
+
// ─────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
// Cached merged config
|
|
162
|
+
let cachedConfig = null;
|
|
163
|
+
let cachedProjectRoot = null;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Load and merge all config sources
|
|
167
|
+
* Returns the merged configuration object
|
|
168
|
+
*/
|
|
169
|
+
export function loadConfig(options = {}) {
|
|
170
|
+
const { reload = false, startDir = process.cwd() } = options;
|
|
171
|
+
|
|
172
|
+
// Return cached config if available
|
|
173
|
+
if (cachedConfig && !reload) {
|
|
174
|
+
return { config: cachedConfig, projectRoot: cachedProjectRoot };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Start with defaults
|
|
178
|
+
let merged = { ...DEFAULT_CONFIG };
|
|
179
|
+
let projectRoot = startDir;
|
|
180
|
+
|
|
181
|
+
// Load global config
|
|
182
|
+
const globalConfig = loadConfigFile(GLOBAL_CONFIG_PATH);
|
|
183
|
+
if (globalConfig) {
|
|
184
|
+
merged = deepMerge(merged, globalConfig);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Load project config (overrides global)
|
|
188
|
+
const projectResult = findProjectConfig(startDir);
|
|
189
|
+
if (projectResult) {
|
|
190
|
+
merged = deepMerge(merged, projectResult.config);
|
|
191
|
+
projectRoot = projectResult.root;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Cache the result
|
|
195
|
+
cachedConfig = merged;
|
|
196
|
+
cachedProjectRoot = projectRoot;
|
|
197
|
+
|
|
198
|
+
return { config: merged, projectRoot };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Get a specific config section
|
|
203
|
+
*/
|
|
204
|
+
export function getConfig(section) {
|
|
205
|
+
const { config } = loadConfig();
|
|
206
|
+
return section ? config[section] : config;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Get search patterns from config
|
|
211
|
+
*/
|
|
212
|
+
export function getSearchPatterns() {
|
|
213
|
+
const { config } = loadConfig();
|
|
214
|
+
return config.searchPatterns || {};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Get output defaults from config
|
|
219
|
+
*/
|
|
220
|
+
export function getOutputDefaults() {
|
|
221
|
+
const { config } = loadConfig();
|
|
222
|
+
return config.output || {};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Get ignore patterns from config
|
|
227
|
+
*/
|
|
228
|
+
export function getIgnorePatterns() {
|
|
229
|
+
const { config } = loadConfig();
|
|
230
|
+
return config.ignore || [];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Check if config file exists (either global or project)
|
|
235
|
+
*/
|
|
236
|
+
export function hasConfig() {
|
|
237
|
+
if (existsSync(GLOBAL_CONFIG_PATH)) return true;
|
|
238
|
+
return findProjectConfig() !== null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Get paths to config files that would be loaded
|
|
243
|
+
*/
|
|
244
|
+
export function getConfigPaths() {
|
|
245
|
+
const paths = [];
|
|
246
|
+
|
|
247
|
+
if (existsSync(GLOBAL_CONFIG_PATH)) {
|
|
248
|
+
paths.push({ type: 'global', path: GLOBAL_CONFIG_PATH });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const projectResult = findProjectConfig();
|
|
252
|
+
if (projectResult) {
|
|
253
|
+
paths.push({ type: 'project', path: projectResult.path });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return paths;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Clear the config cache (useful for testing)
|
|
261
|
+
*/
|
|
262
|
+
export function clearConfigCache() {
|
|
263
|
+
cachedConfig = null;
|
|
264
|
+
cachedProjectRoot = null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ─────────────────────────────────────────────────────────────
|
|
268
|
+
// Exports for direct access
|
|
269
|
+
// ─────────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
export { CONFIG_FILENAME, GLOBAL_CONFIG_PATH, DEFAULT_CONFIG };
|
package/src/output.mjs
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared output utilities for tokenlean CLI tools
|
|
3
|
+
*
|
|
4
|
+
* Centralizes output formatting, truncation, and common options.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// ─────────────────────────────────────────────────────────────
|
|
8
|
+
// Shell Escaping
|
|
9
|
+
// ─────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Escape a string for safe use in shell double-quoted strings
|
|
13
|
+
* Handles: $ ` \ " !
|
|
14
|
+
*/
|
|
15
|
+
export function shellEscape(str) {
|
|
16
|
+
return str.replace(/[`$"\\!]/g, '\\$&');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Escape a string for use as a ripgrep pattern (regex escaping + shell escaping)
|
|
21
|
+
*/
|
|
22
|
+
export function rgEscape(str) {
|
|
23
|
+
// First escape regex special chars
|
|
24
|
+
const regexEscaped = str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
25
|
+
// Then escape shell special chars
|
|
26
|
+
return shellEscape(regexEscaped);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─────────────────────────────────────────────────────────────
|
|
30
|
+
// Token Estimation
|
|
31
|
+
// ─────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export function estimateTokens(content) {
|
|
34
|
+
if (typeof content !== 'string') return 0;
|
|
35
|
+
return Math.ceil(content.length / 4);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function formatTokens(tokens) {
|
|
39
|
+
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
|
|
40
|
+
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`;
|
|
41
|
+
return String(tokens);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─────────────────────────────────────────────────────────────
|
|
45
|
+
// Argument Parsing
|
|
46
|
+
// ─────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
export function parseCommonArgs(args) {
|
|
49
|
+
const options = {
|
|
50
|
+
maxLines: Infinity,
|
|
51
|
+
maxTokens: Infinity,
|
|
52
|
+
json: false,
|
|
53
|
+
quiet: false,
|
|
54
|
+
help: false,
|
|
55
|
+
remaining: []
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
for (let i = 0; i < args.length; i++) {
|
|
59
|
+
const arg = args[i];
|
|
60
|
+
|
|
61
|
+
if (arg === '--max-lines' || arg === '-l') {
|
|
62
|
+
options.maxLines = parseInt(args[++i], 10) || Infinity;
|
|
63
|
+
} else if (arg === '--max-tokens' || arg === '-t') {
|
|
64
|
+
options.maxTokens = parseInt(args[++i], 10) || Infinity;
|
|
65
|
+
} else if (arg === '--json' || arg === '-j') {
|
|
66
|
+
options.json = true;
|
|
67
|
+
} else if (arg === '--quiet' || arg === '-q') {
|
|
68
|
+
options.quiet = true;
|
|
69
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
70
|
+
options.help = true;
|
|
71
|
+
} else {
|
|
72
|
+
options.remaining.push(arg);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return options;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const COMMON_OPTIONS_HELP = `
|
|
80
|
+
Common options:
|
|
81
|
+
--max-lines N, -l N Limit output to N lines
|
|
82
|
+
--max-tokens N, -t N Limit output to ~N tokens
|
|
83
|
+
--json, -j Output as JSON (for piping)
|
|
84
|
+
--quiet, -q Minimal output (no headers/stats)
|
|
85
|
+
--help, -h Show help`;
|
|
86
|
+
|
|
87
|
+
// ─────────────────────────────────────────────────────────────
|
|
88
|
+
// Output Builder
|
|
89
|
+
// ─────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
export class Output {
|
|
92
|
+
constructor(options = {}) {
|
|
93
|
+
this.options = {
|
|
94
|
+
maxLines: options.maxLines ?? Infinity,
|
|
95
|
+
maxTokens: options.maxTokens ?? Infinity,
|
|
96
|
+
json: options.json ?? false,
|
|
97
|
+
quiet: options.quiet ?? false
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
this.lines = [];
|
|
101
|
+
this.data = {}; // For JSON output
|
|
102
|
+
this.truncated = false;
|
|
103
|
+
this.totalLines = 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Add a header line (skipped in quiet mode)
|
|
107
|
+
header(text) {
|
|
108
|
+
if (!this.options.quiet) {
|
|
109
|
+
this.lines.push(text);
|
|
110
|
+
}
|
|
111
|
+
return this;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Add a blank line (skipped in quiet mode)
|
|
115
|
+
blank() {
|
|
116
|
+
if (!this.options.quiet) {
|
|
117
|
+
this.lines.push('');
|
|
118
|
+
}
|
|
119
|
+
return this;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Add content lines (respects limits)
|
|
123
|
+
add(text) {
|
|
124
|
+
this.totalLines++;
|
|
125
|
+
|
|
126
|
+
if (this.truncated) return this;
|
|
127
|
+
|
|
128
|
+
// Check token limit
|
|
129
|
+
const currentTokens = estimateTokens(this.lines.join('\n'));
|
|
130
|
+
const newTokens = estimateTokens(text);
|
|
131
|
+
|
|
132
|
+
if (currentTokens + newTokens > this.options.maxTokens) {
|
|
133
|
+
this.truncated = true;
|
|
134
|
+
return this;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Check line limit
|
|
138
|
+
if (this.lines.length >= this.options.maxLines) {
|
|
139
|
+
this.truncated = true;
|
|
140
|
+
return this;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
this.lines.push(text);
|
|
144
|
+
return this;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Add multiple lines at once
|
|
148
|
+
addLines(textArray) {
|
|
149
|
+
for (const line of textArray) {
|
|
150
|
+
this.add(line);
|
|
151
|
+
if (this.truncated) break;
|
|
152
|
+
}
|
|
153
|
+
return this;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Add a section with title and items
|
|
157
|
+
section(title, items, formatter = (x) => x) {
|
|
158
|
+
if (items.length === 0) return this;
|
|
159
|
+
|
|
160
|
+
this.add(title);
|
|
161
|
+
for (const item of items) {
|
|
162
|
+
this.add(formatter(item));
|
|
163
|
+
if (this.truncated) break;
|
|
164
|
+
}
|
|
165
|
+
this.blank();
|
|
166
|
+
return this;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Set data for JSON output
|
|
170
|
+
setData(key, value) {
|
|
171
|
+
this.data[key] = value;
|
|
172
|
+
return this;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Add stats footer (skipped in quiet mode)
|
|
176
|
+
stats(text) {
|
|
177
|
+
if (!this.options.quiet && !this.truncated) {
|
|
178
|
+
this.lines.push(text);
|
|
179
|
+
}
|
|
180
|
+
return this;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Render the output
|
|
184
|
+
render() {
|
|
185
|
+
if (this.options.json) {
|
|
186
|
+
return JSON.stringify({
|
|
187
|
+
...this.data,
|
|
188
|
+
truncated: this.truncated,
|
|
189
|
+
totalItems: this.totalLines
|
|
190
|
+
}, null, 2);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let output = this.lines.join('\n');
|
|
194
|
+
|
|
195
|
+
if (this.truncated) {
|
|
196
|
+
const remaining = this.totalLines - this.lines.length;
|
|
197
|
+
if (remaining > 0) {
|
|
198
|
+
output += `\n\n... truncated (${remaining} more lines)`;
|
|
199
|
+
} else {
|
|
200
|
+
output += '\n\n... truncated';
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return output;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Print to stdout
|
|
208
|
+
print() {
|
|
209
|
+
console.log(this.render());
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─────────────────────────────────────────────────────────────
|
|
214
|
+
// Convenience function for simple outputs
|
|
215
|
+
// ─────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
export function createOutput(options) {
|
|
218
|
+
return new Output(options);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ─────────────────────────────────────────────────────────────
|
|
222
|
+
// Table formatting
|
|
223
|
+
// ─────────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
export function formatTable(rows, options = {}) {
|
|
226
|
+
if (rows.length === 0) return [];
|
|
227
|
+
|
|
228
|
+
const { indent = '', separator = ' ' } = options;
|
|
229
|
+
|
|
230
|
+
// Calculate column widths
|
|
231
|
+
const colWidths = [];
|
|
232
|
+
for (const row of rows) {
|
|
233
|
+
row.forEach((cell, i) => {
|
|
234
|
+
const len = String(cell).length;
|
|
235
|
+
colWidths[i] = Math.max(colWidths[i] || 0, len);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Format rows
|
|
240
|
+
return rows.map(row => {
|
|
241
|
+
const cells = row.map((cell, i) => {
|
|
242
|
+
const str = String(cell);
|
|
243
|
+
// Right-align numbers, left-align text
|
|
244
|
+
if (typeof cell === 'number' || /^[\d,.]+[kMG]?$/.test(str)) {
|
|
245
|
+
return str.padStart(colWidths[i]);
|
|
246
|
+
}
|
|
247
|
+
return str.padEnd(colWidths[i]);
|
|
248
|
+
});
|
|
249
|
+
return indent + cells.join(separator);
|
|
250
|
+
});
|
|
251
|
+
}
|