oxlint-tui 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +72 -0
  2. package/package.json +27 -0
  3. package/tui.js +389 -0
package/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # oxlint-tui
2
+
3
+ A lightweight, dependency-free Node.js Terminal User Interface (TUI) for browsing and visualizing [oxlint](https://github.com/oxc-project/oxc) rules.
4
+
5
+ It automatically loads your local configuration to show you the status of the rules toggled in your project.
6
+
7
+ ![screenshot](screenshot.png)
8
+
9
+ ## Why?
10
+
11
+ Configuring linters often involves jumping between your editor, a massive JSON file, and web documentation. `oxlint-tui` tries to make the process easier by giving you an **interactive dashboard** right in your terminal.
12
+
13
+ ## Features
14
+
15
+ * **Config Aware**: provides information about the rules used in your project by loading `.oxlintrc.json`.
16
+ * **Details**: View category, scope, fix, default and type-aware rule parameters at a glance.
17
+ * **View Docs**: Press <kbd>ENTER</kbd> on any rule to open its official documentation in your browser.
18
+ * **Zero Dependencies**: Written in pure Node.js without any heavy TUI libraries.
19
+
20
+ ## Usage
21
+
22
+ ### 🚀 Quick Start (via npx)
23
+
24
+ Run it directly in your project folder (where your `.oxlintrc.json` is located):
25
+
26
+ ```bash
27
+ npx oxlint-tui
28
+ ```
29
+
30
+ ### 📂 Custom Config Path
31
+
32
+ If your configuration file is located elsewhere or named differently:
33
+
34
+ ```bash
35
+ npx oxlint-tui ./configs/oxlint.json
36
+ ```
37
+
38
+ ### 📦 Global Install
39
+
40
+ If you use oxlint frequently, you can install it globally:
41
+
42
+ ```bash
43
+ npm install -g oxlint-tui
44
+
45
+ oxlint-tui
46
+ ```
47
+
48
+ ## Keyboard Controls
49
+
50
+ | Key | Action |
51
+ | :--- | :--- |
52
+ | **↑** / **k** | Move selection Up |
53
+ | **↓** / **j** | Move selection Down |
54
+ | **←** / **h** | Move focus Left (Categories <-> Rules) |
55
+ | **→** / **l** | Move focus Right (Categories <-> Rules) |
56
+ | **Enter** | Open Rule Documentation in Browser |
57
+ | **q** / **Esc** | Quit |
58
+
59
+ ## Requirements
60
+
61
+ * Node.js >= 16
62
+ * `oxlint` (The tool runs `npx oxlint --rules --format=json` internally to fetch definitions)
63
+
64
+ ## Roadmap
65
+
66
+ The goal is to build this into a tool that not only reads the information provided by the oxlint CLI and your configuration file - but also allows to create the configuration. Oxlint provides a lot more flexibility than just toggling rules on/off, so making this fully functional is going to require more work.
67
+
68
+ If you're willing and able, please feel free to contribute to this project and help expanding it.
69
+
70
+ ## License
71
+
72
+ MIT
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "oxlint-tui",
3
+ "version": "1.0.0",
4
+ "description": "A Node TUI Oxlint rules and configuration browser",
5
+ "type": "module",
6
+ "bin": {
7
+ "oxlint-rules-tui": "./tui.js"
8
+ },
9
+ "files": [
10
+ "tui.js"
11
+ ],
12
+ "scripts": {
13
+ "start": "node tui.js",
14
+ "test:config": "node tui.js .oxlintrc.example.json"
15
+ },
16
+ "keywords": [
17
+ "oxlint",
18
+ "lint",
19
+ "tui",
20
+ "cli"
21
+ ],
22
+ "author": "holoflash",
23
+ "license": "MIT",
24
+ "engines": {
25
+ "node": ">=16"
26
+ }
27
+ }
package/tui.js ADDED
@@ -0,0 +1,389 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execSync, exec } from 'node:child_process';
4
+ import { stdout, stdin, exit, platform, argv } from 'node:process';
5
+ import readline from 'node:readline';
6
+ import fs from 'node:fs';
7
+
8
+ const COLORS = {
9
+ reset: '\x1b[0m',
10
+ dim: '\x1b[90m',
11
+ highlight: '\x1b[38;5;110m',
12
+ selectedBg: '\x1b[47m\x1b[30m',
13
+ borderActive: '\x1b[36m',
14
+ borderInactive: '\x1b[90m',
15
+ error: '\x1b[31m',
16
+ warn: '\x1b[33m',
17
+ success: '\x1b[32m',
18
+ };
19
+
20
+ const KEY_MAP = {
21
+ 'k': { type: 'MOVE_UP' },
22
+ 'up': { type: 'MOVE_UP' },
23
+ 'down': { type: 'MOVE_DOWN' },
24
+ 'j': { type: 'MOVE_DOWN' },
25
+ 'left': { type: 'MOVE_LEFT' },
26
+ 'h': { type: 'MOVE_LEFT' },
27
+ 'right': { type: 'MOVE_RIGHT' },
28
+ 'l': { type: 'MOVE_RIGHT' },
29
+ 'return': { type: 'OPEN_DOCS' },
30
+ 'enter': { type: 'OPEN_DOCS' },
31
+ 'q': { type: 'EXIT' },
32
+ 'escape': { type: 'EXIT' }
33
+ };
34
+
35
+ function reducer(state, action) {
36
+ const { categories, rulesByCategory, selectedCatIdx, selectedRuleIdx, activePane } = state;
37
+ const currentCat = categories[selectedCatIdx];
38
+ const currentRules = rulesByCategory[currentCat] || [];
39
+ const viewHeight = stdout.rows - 6;
40
+
41
+ const statsHeight = 7;
42
+ const catViewHeight = viewHeight - statsHeight;
43
+
44
+ switch (action.type) {
45
+ case 'MOVE_RIGHT':
46
+ return { ...state, activePane: Math.min(2, activePane + 1) };
47
+
48
+ case 'MOVE_LEFT':
49
+ return { ...state, activePane: Math.max(0, activePane - 1) };
50
+
51
+ case 'MOVE_UP':
52
+ if (activePane === 0) {
53
+ const nextIdx = selectedCatIdx === 0 ? categories.length - 1 : selectedCatIdx - 1;
54
+ return {
55
+ ...state,
56
+ selectedCatIdx: nextIdx,
57
+ selectedRuleIdx: 0,
58
+ scrollRule: 0,
59
+ scrollCat: updateScroll(nextIdx, state.scrollCat, catViewHeight)
60
+ };
61
+ } else if (activePane === 1) {
62
+ const nextIdx = selectedRuleIdx === 0 ? currentRules.length - 1 : selectedRuleIdx - 1;
63
+ return {
64
+ ...state,
65
+ selectedRuleIdx: nextIdx,
66
+ scrollRule: updateScroll(nextIdx, state.scrollRule, viewHeight)
67
+ };
68
+ }
69
+ return state;
70
+
71
+ case 'MOVE_DOWN':
72
+ if (activePane === 0) {
73
+ const nextIdx = selectedCatIdx === categories.length - 1 ? 0 : selectedCatIdx + 1;
74
+ return {
75
+ ...state,
76
+ selectedCatIdx: nextIdx,
77
+ selectedRuleIdx: 0,
78
+ scrollRule: 0,
79
+ scrollCat: updateScroll(nextIdx, state.scrollCat, catViewHeight)
80
+ };
81
+ } else if (activePane === 1) {
82
+ const nextIdx = selectedRuleIdx === currentRules.length - 1 ? 0 : selectedRuleIdx + 1;
83
+ return {
84
+ ...state,
85
+ selectedRuleIdx: nextIdx,
86
+ scrollRule: updateScroll(nextIdx, state.scrollRule, viewHeight)
87
+ };
88
+ }
89
+ return state;
90
+
91
+ default:
92
+ return state;
93
+ }
94
+ }
95
+
96
+ function getRuleStatus(ruleName, category, config) {
97
+ if (config.rules) {
98
+ let val = config.rules[ruleName];
99
+
100
+ // Ignore the prefix to match format in --rules
101
+ if (val === undefined) {
102
+ const foundKey = Object.keys(config.rules).find(key => key.endsWith(`/${ruleName}`));
103
+ if (foundKey) {
104
+ val = config.rules[foundKey];
105
+ }
106
+ }
107
+
108
+ if (val !== undefined) {
109
+ if (Array.isArray(val)) return val[0];
110
+ return val;
111
+ }
112
+ }
113
+
114
+ if (config.categories && config.categories[category]) {
115
+ return config.categories[category];
116
+ }
117
+ return 'off';
118
+ }
119
+
120
+ function loadRules() {
121
+ let rulesData;
122
+ let config = { rules: {}, categories: {} };
123
+
124
+ try {
125
+ const raw = execSync('npx oxlint --rules --format=json', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
126
+ rulesData = JSON.parse(raw);
127
+ } catch (e) {
128
+ console.error(`${COLORS.error}Error: Could not run 'npx oxlint'. Ensure oxlint is installed.${COLORS.reset}`);
129
+ exit(1);
130
+ }
131
+
132
+ const userConfigPath = argv[2];
133
+ let configPathToLoad = null;
134
+
135
+ if (userConfigPath) {
136
+ if (!fs.existsSync(userConfigPath)) {
137
+ console.error(`${COLORS.error}Error: Config file '${userConfigPath}' not found.${COLORS.reset}`);
138
+ exit(1);
139
+ }
140
+ configPathToLoad = userConfigPath;
141
+ } else if (fs.existsSync('.oxlintrc.json')) {
142
+ configPathToLoad = '.oxlintrc.json';
143
+ }
144
+
145
+ if (configPathToLoad) {
146
+ try {
147
+ const configFile = fs.readFileSync(configPathToLoad, 'utf8');
148
+ config = JSON.parse(configFile);
149
+ } catch (e) {
150
+ console.error(`${COLORS.error}Error: Failed to parse '${configPathToLoad}'.${COLORS.reset}`);
151
+ console.error(`${COLORS.warn}${e.message}${COLORS.reset}`);
152
+ exit(1);
153
+ }
154
+ }
155
+
156
+ try {
157
+ const map = {};
158
+
159
+ rulesData.forEach(rule => {
160
+ const cat = rule.category || 'Uncategorized';
161
+ if (!map[cat]) map[cat] = [];
162
+
163
+ const status = getRuleStatus(rule.value, cat, config);
164
+
165
+ map[cat].push({
166
+ ...rule,
167
+ configStatus: status,
168
+ isActive: status === 'error' || status === 'warn'
169
+ });
170
+ });
171
+
172
+ const categories = Object.keys(map).sort();
173
+
174
+ // Sort rules - active first, then Alphabetical
175
+ categories.forEach(c => {
176
+ map[c].sort((a, b) => {
177
+ if (a.isActive && !b.isActive) return -1;
178
+ if (!a.isActive && b.isActive) return 1;
179
+ return a.value.localeCompare(b.value);
180
+ });
181
+ });
182
+
183
+ return { categories, rulesByCategory: map };
184
+ } catch (e) {
185
+ console.error(`${COLORS.error}Error: Something went wrong processing rules.${COLORS.reset}`);
186
+ console.error(e);
187
+ exit(1);
188
+ }
189
+ }
190
+
191
+ function updateScroll(idx, currentScroll, viewHeight) {
192
+ if (idx < currentScroll) return idx;
193
+ if (idx >= currentScroll + viewHeight) return idx - viewHeight + 1;
194
+ return currentScroll;
195
+ }
196
+
197
+ function openUrl(url) {
198
+ if (!url) return;
199
+ const cmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'start' : 'xdg-open';
200
+ exec(`${cmd} "${url}"`);
201
+ }
202
+
203
+ function chunkString(str, len) {
204
+ const size = Math.ceil(str.length / len);
205
+ const r = Array(size);
206
+ for (let i = 0; i < size; i++) {
207
+ r[i] = str.substring(i * len, (i + 1) * len);
208
+ }
209
+ return r;
210
+ }
211
+
212
+ const write = (str) => stdout.write(str);
213
+ const enterAltScreen = () => write('\x1b[?1049h\x1b[?25l');
214
+ const exitAltScreen = () => write('\x1b[?1049l\x1b[?25h');
215
+
216
+ function drawBox(buffer, x, y, width, height, title, items, selectedIdx, scrollOffset, isActive) {
217
+ const borderColor = isActive ? COLORS.borderActive : COLORS.borderInactive;
218
+ const titleClean = title.length > width - 6 ? title.substring(0, width - 7) + '…' : title;
219
+
220
+ const topBorder = `${borderColor}┌─ ${titleClean} `.padEnd(width + borderColor.length - 1, '─');
221
+ buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
222
+
223
+ for (let i = 1; i < height - 1; i++) {
224
+ buffer.push(`\x1b[${y + i};${x}H${borderColor}│${' '.repeat(width - 2)}│${COLORS.reset}`);
225
+ }
226
+ buffer.push(`\x1b[${y + height - 1};${x}H${borderColor}└${'─'.repeat(width - 2)}┘${COLORS.reset}`);
227
+
228
+ const innerHeight = height - 2;
229
+
230
+ items.slice(scrollOffset, scrollOffset + innerHeight).forEach((item, i) => {
231
+ const absIdx = scrollOffset + i;
232
+ const rawText = (item.value || item).toString();
233
+ let display = rawText.length > width - 4 ? rawText.substring(0, width - 5) + '…' : rawText.padEnd(width - 4);
234
+
235
+ let itemColor = COLORS.dim;
236
+ if (item.configStatus === 'error') itemColor = COLORS.error;
237
+ else if (item.configStatus === 'warn') itemColor = COLORS.warn;
238
+ else if (item.isActive) itemColor = COLORS.success;
239
+
240
+ buffer.push(`\x1b[${y + 1 + i};${x + 2}H`);
241
+
242
+ if (absIdx === selectedIdx) {
243
+ buffer.push(isActive
244
+ ? `${COLORS.selectedBg}${display}${COLORS.reset}`
245
+ : `${COLORS.dim}\x1b[7m${display}${COLORS.reset}`);
246
+ } else {
247
+ buffer.push(`${itemColor}${display}${COLORS.reset}`);
248
+ }
249
+ });
250
+ }
251
+
252
+ function drawStats(buffer, x, y, width, height, rules) {
253
+ const borderColor = COLORS.borderInactive;
254
+
255
+ const topBorder = `${borderColor}┌─ STATS `.padEnd(width + borderColor.length - 1, '─');
256
+ buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
257
+
258
+ for (let i = 1; i < height - 1; i++) buffer.push(`\x1b[${y + i};${x}H${borderColor}│${' '.repeat(width - 2)}│${COLORS.reset}`);
259
+ buffer.push(`\x1b[${y + height - 1};${x}H${borderColor}└${'─'.repeat(width - 2)}┘${COLORS.reset}`);
260
+
261
+ let counts = { error: 0, warn: 0, off: 0 };
262
+ rules.forEach(r => {
263
+ if (r.configStatus === 'error') counts.error++;
264
+ else if (r.configStatus === 'warn') counts.warn++;
265
+ else counts.off++;
266
+ });
267
+
268
+ const lines = [
269
+ { label: 'Error', count: counts.error, color: COLORS.error },
270
+ { label: 'Warn', count: counts.warn, color: COLORS.warn },
271
+ { label: 'Off', count: counts.off, color: COLORS.dim }
272
+ ];
273
+
274
+ lines.forEach((line, i) => {
275
+ if (i < height - 2) {
276
+ const numStr = String(line.count).padStart(3);
277
+ const labelStr = line.label.padEnd(width - 8);
278
+ buffer.push(`\x1b[${y + 1 + i};${x + 2}H${line.color}${labelStr}${numStr}${COLORS.reset}`);
279
+ }
280
+ });
281
+ }
282
+
283
+ function drawDetails(buffer, x, y, width, height, rule, isActive) {
284
+ const borderColor = isActive ? COLORS.borderActive : COLORS.borderInactive;
285
+
286
+ const topBorder = `${borderColor}┌─ DETAILS `.padEnd(width + borderColor.length - 1, '─');
287
+ buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
288
+
289
+ for (let i = 1; i < height - 1; i++) buffer.push(`\x1b[${y + i};${x}H${borderColor}│${' '.repeat(width - 2)}│${COLORS.reset}`);
290
+ buffer.push(`\x1b[${y + height - 1};${x}H${borderColor}└${'─'.repeat(width - 2)}┘${COLORS.reset}`);
291
+
292
+ if (!rule) return;
293
+
294
+ let statusDisplay = rule.configStatus.toUpperCase();
295
+ if (rule.configStatus === 'error') statusDisplay = `${COLORS.error}${statusDisplay}${COLORS.reset}`;
296
+ else if (rule.configStatus === 'warn') statusDisplay = `${COLORS.warn}${statusDisplay}${COLORS.reset}`;
297
+ else statusDisplay = `${COLORS.dim}${statusDisplay}${COLORS.reset}`;
298
+
299
+ const labels = [
300
+ ['Name', rule.value],
301
+ ['Status', statusDisplay],
302
+ ['Category', rule.category],
303
+ ['Scope', rule.scope],
304
+ ['Fix', rule.fix],
305
+ ['Default', rule.default ? 'Yes' : 'No'],
306
+ ['Type-aware', rule.type_aware ? 'Yes' : 'No'],
307
+ ['Docs', `Hit ${COLORS.highlight}ENTER${COLORS.reset} to open docs`]
308
+ ];
309
+
310
+ let line = 0;
311
+ labels.forEach(([lbl, val]) => {
312
+ if (lbl === 'Status') {
313
+ if (line < height - 2) {
314
+ buffer.push(`\x1b[${y + 1 + line};${x + 2}H${COLORS.highlight}${lbl.padEnd(10)} ${COLORS.reset}${val}`);
315
+ line++;
316
+ }
317
+ return;
318
+ }
319
+
320
+ const chunks = chunkString(String(val || 'N/A'), width - 15);
321
+ chunks.forEach((chunk, i) => {
322
+ if (line < height - 2) {
323
+ buffer.push(`\x1b[${y + 1 + line};${x + 2}H${i === 0 ? COLORS.highlight + lbl.padEnd(10) : ' '.repeat(10)} ${COLORS.reset}${chunk}`);
324
+ line++;
325
+ }
326
+ });
327
+ });
328
+ }
329
+
330
+ function render() {
331
+ const { columns, rows } = stdout;
332
+ const currentCat = state.categories[state.selectedCatIdx];
333
+ const rules = state.rulesByCategory[currentCat] || [];
334
+ const rule = rules[state.selectedRuleIdx];
335
+
336
+ const boxHeight = rows - 4;
337
+
338
+ const col1W = Math.floor(columns * 0.2);
339
+ const col2W = Math.floor(columns * 0.3);
340
+ const col3W = columns - col1W - col2W - 2;
341
+
342
+ const statsHeight = 6;
343
+ const catListHeight = boxHeight - statsHeight;
344
+
345
+ const buffer = ['\x1b[H\x1b[J'];
346
+
347
+ drawBox(buffer, 1, 1, col1W, catListHeight, 'CATEGORIES', state.categories, state.selectedCatIdx, state.scrollCat, state.activePane === 0);
348
+ drawStats(buffer, 1, 1 + catListHeight, col1W, statsHeight, rules);
349
+ drawBox(buffer, col1W + 1, 1, col2W, boxHeight, `RULES (${rules.length})`, rules, state.selectedRuleIdx, state.scrollRule, state.activePane === 1);
350
+ drawDetails(buffer, col1W + col2W + 1, 1, col3W, boxHeight, rule, state.activePane === 2);
351
+ buffer.push(`\x1b[${rows - 2};2H${COLORS.dim}Nav: Arrows/HJKL | Enter: Docs | Q: Quit${COLORS.reset}`);
352
+ write(buffer.join(''));
353
+ }
354
+
355
+ let state = {
356
+ activePane: 0,
357
+ selectedCatIdx: 0,
358
+ selectedRuleIdx: 0,
359
+ scrollCat: 0,
360
+ scrollRule: 0,
361
+ ...loadRules()
362
+ };
363
+
364
+ readline.emitKeypressEvents(stdin);
365
+ if (stdin.isTTY) stdin.setRawMode(true);
366
+
367
+ stdin.on('keypress', (_, key) => {
368
+ const action = KEY_MAP[key.name] || (key.ctrl && key.name === 'c' ? { type: 'EXIT' } : null);
369
+ if (!action) return;
370
+
371
+ if (action.type === 'EXIT') {
372
+ exitAltScreen();
373
+ exit(0);
374
+ }
375
+
376
+ if (action.type === 'OPEN_DOCS') {
377
+ const currentCat = state.categories[state.selectedCatIdx];
378
+ const rule = state.rulesByCategory[currentCat]?.[state.selectedRuleIdx];
379
+ if (rule) openUrl(rule.docs_url || rule.url);
380
+ return;
381
+ }
382
+
383
+ state = reducer(state, action);
384
+ render();
385
+ });
386
+
387
+ stdout.on('resize', render);
388
+ enterAltScreen();
389
+ render();