@prabhat-singh/docdrift 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +174 -2
- package/bin/docdrift.js +232 -0
- package/package.json +42 -4
- package/src/banner.js +60 -0
- package/src/check.js +228 -0
- package/src/config.js +75 -0
- package/src/docs.js +99 -0
- package/src/fix.js +299 -0
- package/src/llm.js +42 -0
- package/src/match.js +73 -0
- package/src/scan.js +343 -0
- package/src/style.js +96 -0
package/src/llm.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Send a chat completion request to an OpenAI-compatible endpoint.
|
|
3
|
+
*
|
|
4
|
+
* @param {{ baseUrl: string, apiKey?: string, model: string }} config
|
|
5
|
+
* @param {{ role: string, content: string }[]} messages
|
|
6
|
+
* @returns {Promise<string>}
|
|
7
|
+
*/
|
|
8
|
+
export async function chat(config, messages) {
|
|
9
|
+
const baseUrl = String(config.baseUrl ?? '').replace(/\/+$/, '');
|
|
10
|
+
|
|
11
|
+
const headers = { 'content-type': 'application/json' };
|
|
12
|
+
if (config.apiKey) headers.authorization = `Bearer ${config.apiKey}`;
|
|
13
|
+
|
|
14
|
+
const response = await fetch(`${baseUrl}/chat/completions`, {
|
|
15
|
+
method: 'POST',
|
|
16
|
+
headers,
|
|
17
|
+
body: JSON.stringify({
|
|
18
|
+
model: config.model,
|
|
19
|
+
messages,
|
|
20
|
+
temperature: 0,
|
|
21
|
+
}),
|
|
22
|
+
signal: AbortSignal.timeout(60_000),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
if (!response.ok) {
|
|
26
|
+
const status = response.status;
|
|
27
|
+
if ([429, 502, 503, 504].includes(status)) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`The model is rate-limited or busy right now (HTTP ${status}). Try again in a minute, or set another model in DOCDRIFT_MODEL.`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
if (status === 401 || status === 403) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`The API key was rejected (HTTP ${status}). Check DOCDRIFT_API_KEY.`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
throw new Error(`The model request failed (HTTP ${status}).`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const data = await response.json();
|
|
41
|
+
return data?.choices?.[0]?.message?.content ?? '';
|
|
42
|
+
}
|
package/src/match.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalise a path for comparison: drop the query string, turn {name} and
|
|
3
|
+
* :name parameters into {}, collapse repeated slashes and drop a trailing
|
|
4
|
+
* slash. Paths stay case-sensitive.
|
|
5
|
+
*/
|
|
6
|
+
export function normalizePath(path) {
|
|
7
|
+
let normalized = String(path ?? '').split('?')[0];
|
|
8
|
+
|
|
9
|
+
normalized = normalized.replace(/\{[^}]*\}/g, '{}');
|
|
10
|
+
normalized = normalized.replace(/:[A-Za-z_$][\w$]*/g, '{}');
|
|
11
|
+
normalized = normalized.replace(/\/{2,}/g, '/');
|
|
12
|
+
|
|
13
|
+
if (!normalized.startsWith('/')) normalized = `/${normalized}`;
|
|
14
|
+
if (normalized.length > 1) normalized = normalized.replace(/\/+$/, '');
|
|
15
|
+
|
|
16
|
+
return normalized || '/';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function methodsOf(endpoint) {
|
|
20
|
+
return String(endpoint.method ?? '')
|
|
21
|
+
.split('|')
|
|
22
|
+
.map((method) => method.trim())
|
|
23
|
+
.filter(Boolean);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function methodsMatch(codeMethod, docMethod) {
|
|
27
|
+
return (
|
|
28
|
+
codeMethod === 'ANY' ||
|
|
29
|
+
docMethod === 'ANY' ||
|
|
30
|
+
codeMethod === docMethod
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Compare code endpoints with documented endpoints.
|
|
36
|
+
*
|
|
37
|
+
* @param {{ method: string, path: string, file: string, line: number }[]} codeEndpoints
|
|
38
|
+
* @param {{ method: string, path: string, file: string, line: number }[]} docEndpoints
|
|
39
|
+
* @returns {{ matched: number, stale: object[], undocumented: object[] }}
|
|
40
|
+
*/
|
|
41
|
+
export function compare(codeEndpoints, docEndpoints) {
|
|
42
|
+
const code = Array.isArray(codeEndpoints) ? codeEndpoints : [];
|
|
43
|
+
const docs = Array.isArray(docEndpoints) ? docEndpoints : [];
|
|
44
|
+
|
|
45
|
+
const docKeys = docs.map((endpoint) => normalizePath(endpoint.path));
|
|
46
|
+
const matchedDocs = new Set();
|
|
47
|
+
const undocumented = [];
|
|
48
|
+
let matched = 0;
|
|
49
|
+
|
|
50
|
+
for (const endpoint of code) {
|
|
51
|
+
const codePath = normalizePath(endpoint.path);
|
|
52
|
+
let found = false;
|
|
53
|
+
|
|
54
|
+
for (const codeMethod of methodsOf(endpoint)) {
|
|
55
|
+
for (let i = 0; i < docs.length; i += 1) {
|
|
56
|
+
if (docKeys[i] !== codePath) continue;
|
|
57
|
+
if (!methodsMatch(codeMethod, docs[i].method)) continue;
|
|
58
|
+
|
|
59
|
+
found = true;
|
|
60
|
+
if (!matchedDocs.has(i)) {
|
|
61
|
+
matchedDocs.add(i);
|
|
62
|
+
matched += 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!found) undocumented.push(endpoint);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const stale = docs.filter((_, index) => !matchedDocs.has(index));
|
|
71
|
+
|
|
72
|
+
return { matched, stale, undocumented };
|
|
73
|
+
}
|
package/src/scan.js
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const SKIP_DIRS = new Set(['target', 'build', '.git', 'node_modules']);
|
|
5
|
+
|
|
6
|
+
const METHOD_BY_ANNOTATION = new Map([
|
|
7
|
+
['GetMapping', 'GET'],
|
|
8
|
+
['PostMapping', 'POST'],
|
|
9
|
+
['PutMapping', 'PUT'],
|
|
10
|
+
['DeleteMapping', 'DELETE'],
|
|
11
|
+
['PatchMapping', 'PATCH'],
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
const ANNOTATION_RE =
|
|
15
|
+
/@(GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping|RequestMapping)\b/g;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Scan a directory for Spring Boot REST endpoints.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} dir directory to walk recursively
|
|
21
|
+
* @returns {{ method: string, path: string, file: string, line: number }[]}
|
|
22
|
+
*/
|
|
23
|
+
export function scanJava(dir) {
|
|
24
|
+
const endpoints = [];
|
|
25
|
+
|
|
26
|
+
for (const file of collectJavaFiles(dir)) {
|
|
27
|
+
const code = stripComments(readFileSync(file, 'utf8'));
|
|
28
|
+
const annotations = findAnnotations(code);
|
|
29
|
+
const classIndex = findClassIndex(code);
|
|
30
|
+
const prefix = readClassPrefix(
|
|
31
|
+
code,
|
|
32
|
+
annotations.filter((a) => classIndex !== -1 && a.index < classIndex),
|
|
33
|
+
file,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
for (const annotation of annotations) {
|
|
37
|
+
if (classIndex !== -1 && annotation.index < classIndex) continue;
|
|
38
|
+
|
|
39
|
+
const mapping = parseMappingArgs(annotation.args);
|
|
40
|
+
const line = lineOf(code, annotation.index);
|
|
41
|
+
|
|
42
|
+
if (!mapping.literal) {
|
|
43
|
+
console.error(
|
|
44
|
+
`docdrift: warning: ${file}:${line}: skipping @${annotation.name} with a non-literal path`,
|
|
45
|
+
);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const method =
|
|
50
|
+
annotation.name === 'RequestMapping'
|
|
51
|
+
? (mapping.method ?? 'ANY')
|
|
52
|
+
: METHOD_BY_ANNOTATION.get(annotation.name);
|
|
53
|
+
|
|
54
|
+
for (const path of mapping.paths) {
|
|
55
|
+
endpoints.push({ method, path: joinPaths(prefix, path), file, line });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return endpoints.sort(
|
|
61
|
+
(a, b) => compare(a.file, b.file) || a.line - b.line,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function collectJavaFiles(dir) {
|
|
66
|
+
const files = [];
|
|
67
|
+
|
|
68
|
+
const walk = (current) => {
|
|
69
|
+
const entries = readdirSync(current, { withFileTypes: true });
|
|
70
|
+
for (const entry of entries.sort((a, b) => compare(a.name, b.name))) {
|
|
71
|
+
const full = join(current, entry.name);
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
if (!SKIP_DIRS.has(entry.name)) walk(full);
|
|
74
|
+
} else if (entry.isFile() && entry.name.endsWith('.java')) {
|
|
75
|
+
files.push(full);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
walk(dir);
|
|
81
|
+
return files;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function compare(a, b) {
|
|
85
|
+
if (a < b) return -1;
|
|
86
|
+
if (a > b) return 1;
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Replace comments with spaces while keeping newlines, so offsets and line
|
|
92
|
+
* numbers still match the original source. String literals are left alone.
|
|
93
|
+
*/
|
|
94
|
+
function stripComments(source) {
|
|
95
|
+
let out = '';
|
|
96
|
+
let state = 'code';
|
|
97
|
+
let i = 0;
|
|
98
|
+
|
|
99
|
+
while (i < source.length) {
|
|
100
|
+
const ch = source[i];
|
|
101
|
+
const next = source[i + 1];
|
|
102
|
+
|
|
103
|
+
if (state === 'code') {
|
|
104
|
+
if (ch === '/' && next === '/') {
|
|
105
|
+
state = 'line';
|
|
106
|
+
out += ' ';
|
|
107
|
+
i += 2;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (ch === '/' && next === '*') {
|
|
111
|
+
state = 'block';
|
|
112
|
+
out += ' ';
|
|
113
|
+
i += 2;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (ch === '"') state = 'string';
|
|
117
|
+
else if (ch === "'") state = 'char';
|
|
118
|
+
out += ch;
|
|
119
|
+
i += 1;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (state === 'line') {
|
|
124
|
+
if (ch === '\n') {
|
|
125
|
+
state = 'code';
|
|
126
|
+
out += ch;
|
|
127
|
+
} else {
|
|
128
|
+
out += ch === '\r' ? '\r' : ' ';
|
|
129
|
+
}
|
|
130
|
+
i += 1;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (state === 'block') {
|
|
135
|
+
if (ch === '*' && next === '/') {
|
|
136
|
+
state = 'code';
|
|
137
|
+
out += ' ';
|
|
138
|
+
i += 2;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
out += ch === '\n' || ch === '\r' ? ch : ' ';
|
|
142
|
+
i += 1;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Inside a string or char literal: copy verbatim, honour escapes.
|
|
147
|
+
if (ch === '\\') {
|
|
148
|
+
out += ch + (next ?? '');
|
|
149
|
+
i += 2;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if ((state === 'string' && ch === '"') || (state === 'char' && ch === "'")) {
|
|
153
|
+
state = 'code';
|
|
154
|
+
}
|
|
155
|
+
out += ch;
|
|
156
|
+
i += 1;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function findAnnotations(code) {
|
|
163
|
+
const annotations = [];
|
|
164
|
+
|
|
165
|
+
ANNOTATION_RE.lastIndex = 0;
|
|
166
|
+
let match;
|
|
167
|
+
while ((match = ANNOTATION_RE.exec(code)) !== null) {
|
|
168
|
+
let i = ANNOTATION_RE.lastIndex;
|
|
169
|
+
while (i < code.length && /\s/.test(code[i])) i += 1;
|
|
170
|
+
|
|
171
|
+
let args = '';
|
|
172
|
+
if (code[i] === '(') {
|
|
173
|
+
const close = findClosingParen(code, i);
|
|
174
|
+
if (close === -1) continue;
|
|
175
|
+
args = code.slice(i + 1, close);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
annotations.push({ name: match[1], index: match.index, args });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return annotations;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function findClosingParen(code, openIndex) {
|
|
185
|
+
let depth = 0;
|
|
186
|
+
for (let i = openIndex; i < code.length; i += 1) {
|
|
187
|
+
const ch = code[i];
|
|
188
|
+
if (ch === '"' || ch === "'") {
|
|
189
|
+
i = skipLiteral(code, i);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (ch === '(') depth += 1;
|
|
193
|
+
else if (ch === ')') {
|
|
194
|
+
depth -= 1;
|
|
195
|
+
if (depth === 0) return i;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return -1;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function skipLiteral(code, start) {
|
|
202
|
+
const quote = code[start];
|
|
203
|
+
let i = start + 1;
|
|
204
|
+
while (i < code.length) {
|
|
205
|
+
if (code[i] === '\\') {
|
|
206
|
+
i += 2;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (code[i] === quote) return i;
|
|
210
|
+
i += 1;
|
|
211
|
+
}
|
|
212
|
+
return code.length;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function findClassIndex(code) {
|
|
216
|
+
const match = /\b(?:class|interface|enum|record)\s+[A-Za-z_$][\w$]*/.exec(code);
|
|
217
|
+
return match ? match.index : -1;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function readClassPrefix(code, classAnnotations, file) {
|
|
221
|
+
const classMapping = classAnnotations
|
|
222
|
+
.filter((annotation) => annotation.name === 'RequestMapping')
|
|
223
|
+
.pop();
|
|
224
|
+
|
|
225
|
+
if (!classMapping) return '';
|
|
226
|
+
|
|
227
|
+
const mapping = parseMappingArgs(classMapping.args);
|
|
228
|
+
if (!mapping.literal) {
|
|
229
|
+
console.error(
|
|
230
|
+
`docdrift: warning: ${file}:${lineOf(code, classMapping.index)}: class-level @RequestMapping has a non-literal path, using no prefix`,
|
|
231
|
+
);
|
|
232
|
+
return '';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return mapping.paths[0] ?? '';
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function parseMappingArgs(argsText) {
|
|
239
|
+
const text = argsText.trim();
|
|
240
|
+
if (text === '') return { paths: [''], method: null, literal: true };
|
|
241
|
+
|
|
242
|
+
const parts = splitTopLevel(text)
|
|
243
|
+
.map((part) => part.trim())
|
|
244
|
+
.filter(Boolean);
|
|
245
|
+
|
|
246
|
+
let pathExpr = null;
|
|
247
|
+
for (const part of parts) {
|
|
248
|
+
const explicit = part.match(/^(?:value|path)\s*=\s*([\s\S]*)$/);
|
|
249
|
+
if (explicit) {
|
|
250
|
+
pathExpr = explicit[1].trim();
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (pathExpr === null) {
|
|
256
|
+
const positional = parts.find(
|
|
257
|
+
(part) => !/^[A-Za-z_$][\w$]*\s*=/.test(part),
|
|
258
|
+
);
|
|
259
|
+
if (positional !== undefined) pathExpr = positional;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const method = extractMethod(text);
|
|
263
|
+
|
|
264
|
+
if (pathExpr === null) return { paths: [''], method, literal: true };
|
|
265
|
+
|
|
266
|
+
const paths = parseStringList(pathExpr);
|
|
267
|
+
if (paths === null) return { paths: [], method, literal: false };
|
|
268
|
+
|
|
269
|
+
return { paths, method, literal: true };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function extractMethod(text) {
|
|
273
|
+
const attributes = text.match(/\bmethod\s*=\s*([\s\S]*)$/);
|
|
274
|
+
if (!attributes) return null;
|
|
275
|
+
|
|
276
|
+
const names = [];
|
|
277
|
+
for (const match of attributes[1].matchAll(/RequestMethod\.([A-Za-z]+)/g)) {
|
|
278
|
+
names.push(match[1].toUpperCase());
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return names.length > 0 ? names.join('|') : null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Split on a separator that sits outside strings, braces, brackets and parens.
|
|
286
|
+
*/
|
|
287
|
+
function splitTopLevel(text, separator = ',') {
|
|
288
|
+
const parts = [];
|
|
289
|
+
let depth = 0;
|
|
290
|
+
let start = 0;
|
|
291
|
+
|
|
292
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
293
|
+
const ch = text[i];
|
|
294
|
+
if (ch === '"' || ch === "'") {
|
|
295
|
+
i = skipLiteral(text, i);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (ch === '(' || ch === '{' || ch === '[') depth += 1;
|
|
299
|
+
else if (ch === ')' || ch === '}' || ch === ']') depth -= 1;
|
|
300
|
+
else if (ch === separator && depth === 0) {
|
|
301
|
+
parts.push(text.slice(start, i));
|
|
302
|
+
start = i + 1;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
parts.push(text.slice(start));
|
|
307
|
+
return parts;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Turn a plain string literal or an array of literals into a list of paths.
|
|
312
|
+
* Returns null when the expression needs constant resolution.
|
|
313
|
+
*/
|
|
314
|
+
function parseStringList(expr) {
|
|
315
|
+
const literals = [];
|
|
316
|
+
for (const match of expr.matchAll(/"((?:[^"\\]|\\.)*)"/g)) {
|
|
317
|
+
literals.push(unescapeLiteral(match[1]));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const rest = expr.replace(/"(?:[^"\\]|\\.)*"/g, '').replace(/[{},\s]/g, '');
|
|
321
|
+
if (rest !== '' || literals.length === 0) return null;
|
|
322
|
+
|
|
323
|
+
return literals;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function unescapeLiteral(value) {
|
|
327
|
+
return value.replace(/\\(["'\\])/g, '$1');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function joinPaths(prefix, path) {
|
|
331
|
+
let joined = `${prefix ?? ''}/${path ?? ''}`.replace(/\/{2,}/g, '/');
|
|
332
|
+
if (!joined.startsWith('/')) joined = `/${joined}`;
|
|
333
|
+
if (joined.length > 1) joined = joined.replace(/\/+$/, '');
|
|
334
|
+
return joined || '/';
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function lineOf(code, index) {
|
|
338
|
+
let line = 1;
|
|
339
|
+
for (let i = 0; i < index; i += 1) {
|
|
340
|
+
if (code[i] === '\n') line += 1;
|
|
341
|
+
}
|
|
342
|
+
return line;
|
|
343
|
+
}
|
package/src/style.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const RESET_FG = '\x1b[39m';
|
|
2
|
+
|
|
3
|
+
const PALETTE = {
|
|
4
|
+
cyan: [34, 211, 238],
|
|
5
|
+
violet: [167, 139, 250],
|
|
6
|
+
amber: [251, 191, 36],
|
|
7
|
+
pink: [251, 113, 133],
|
|
8
|
+
gray: [156, 163, 175],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const FALLBACK = {
|
|
12
|
+
cyan: '\x1b[96m',
|
|
13
|
+
violet: '\x1b[95m',
|
|
14
|
+
amber: '\x1b[93m',
|
|
15
|
+
pink: '\x1b[91m',
|
|
16
|
+
gray: '\x1b[90m',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Decide whether ANSI colors should be used for a stream.
|
|
21
|
+
* FORCE_COLOR (non-empty, not "0") wins over everything else.
|
|
22
|
+
*/
|
|
23
|
+
export function shouldUseColor(stream, env = process.env, argv = []) {
|
|
24
|
+
if (env?.FORCE_COLOR && env.FORCE_COLOR !== '0') return true;
|
|
25
|
+
if (!stream || !stream.isTTY) return false;
|
|
26
|
+
if (env?.NO_COLOR !== undefined) return false;
|
|
27
|
+
if (Array.isArray(argv) && argv.includes('--no-color')) return false;
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function supportsTrueColor(env = process.env) {
|
|
32
|
+
return (
|
|
33
|
+
env?.COLORTERM === 'truecolor' ||
|
|
34
|
+
env?.COLORTERM === '24bit' ||
|
|
35
|
+
Boolean(env?.WT_SESSION)
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build a tiny ANSI style kit. When disabled every helper is the identity.
|
|
41
|
+
*/
|
|
42
|
+
export function createStyle(enabled, trueColor) {
|
|
43
|
+
const wrap = (open, close, text) =>
|
|
44
|
+
enabled ? `${open}${String(text)}${close}` : String(text);
|
|
45
|
+
|
|
46
|
+
const cyan = (text) => wrap('\x1b[36m', RESET_FG, text);
|
|
47
|
+
|
|
48
|
+
const paint = (rgb, fallbackOpen) => (text) => {
|
|
49
|
+
if (!enabled) return String(text);
|
|
50
|
+
if (!trueColor) return `${fallbackOpen}${String(text)}${RESET_FG}`;
|
|
51
|
+
const [r, g, b] = rgb;
|
|
52
|
+
return `\x1b[38;2;${r};${g};${b}m${String(text)}${RESET_FG}`;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
enabled: Boolean(enabled),
|
|
57
|
+
trueColor: Boolean(trueColor),
|
|
58
|
+
|
|
59
|
+
bold: (text) => wrap('\x1b[1m', '\x1b[22m', text),
|
|
60
|
+
dim: (text) => wrap('\x1b[2m', '\x1b[22m', text),
|
|
61
|
+
red: (text) => wrap('\x1b[31m', RESET_FG, text),
|
|
62
|
+
green: (text) => wrap('\x1b[32m', RESET_FG, text),
|
|
63
|
+
yellow: (text) => wrap('\x1b[33m', RESET_FG, text),
|
|
64
|
+
cyan,
|
|
65
|
+
magenta: (text) => wrap('\x1b[35m', RESET_FG, text),
|
|
66
|
+
|
|
67
|
+
success: paint(PALETTE.cyan, FALLBACK.cyan),
|
|
68
|
+
accent: paint(PALETTE.violet, FALLBACK.violet),
|
|
69
|
+
warning: paint(PALETTE.amber, FALLBACK.amber),
|
|
70
|
+
danger: paint(PALETTE.pink, FALLBACK.pink),
|
|
71
|
+
muted: paint(PALETTE.gray, FALLBACK.gray),
|
|
72
|
+
|
|
73
|
+
rgb(r, g, b, text) {
|
|
74
|
+
if (!enabled) return String(text);
|
|
75
|
+
if (!trueColor) return cyan(text);
|
|
76
|
+
return `\x1b[38;2;${r};${g};${b}m${String(text)}${RESET_FG}`;
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
gradient(text, from, to) {
|
|
80
|
+
const value = String(text);
|
|
81
|
+
if (!enabled) return value;
|
|
82
|
+
if (!trueColor) return cyan(value);
|
|
83
|
+
if (value.length === 0) return value;
|
|
84
|
+
|
|
85
|
+
let painted = '';
|
|
86
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
87
|
+
const ratio = value.length === 1 ? 0 : i / (value.length - 1);
|
|
88
|
+
const r = Math.round(from[0] + (to[0] - from[0]) * ratio);
|
|
89
|
+
const g = Math.round(from[1] + (to[1] - from[1]) * ratio);
|
|
90
|
+
const b = Math.round(from[2] + (to[2] - from[2]) * ratio);
|
|
91
|
+
painted += `\x1b[38;2;${r};${g};${b}m${value[i]}`;
|
|
92
|
+
}
|
|
93
|
+
return `${painted}${RESET_FG}`;
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|