@oh-my-pi/pi-utils 14.0.4 → 14.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/package.json +2 -2
- package/src/abortable.ts +7 -6
- package/src/env.ts +2 -2
- package/src/index.ts +1 -0
- package/src/mermaid-ascii.ts +3 -3
- package/src/tab-spacing.ts +312 -0
- package/src/which.ts +8 -6
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-utils",
|
|
4
|
-
"version": "14.0
|
|
4
|
+
"version": "14.1.0",
|
|
5
5
|
"description": "Shared utilities for pi packages",
|
|
6
6
|
"homepage": "https://github.com/can1357/oh-my-pi",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/bun": "^1.3",
|
|
41
|
-
"@oh-my-pi/pi-natives": "14.0
|
|
41
|
+
"@oh-my-pi/pi-natives": "14.1.0"
|
|
42
42
|
},
|
|
43
43
|
"engines": {
|
|
44
44
|
"bun": ">=1.3.7"
|
package/src/abortable.ts
CHANGED
|
@@ -43,23 +43,24 @@ export function createAbortableStream<T>(stream: ReadableStream<T>, signal?: Abo
|
|
|
43
43
|
* @param pr - Function returning a promise to run
|
|
44
44
|
* @returns Promise resolving as `pr` would, or rejecting on abort
|
|
45
45
|
*/
|
|
46
|
-
export function untilAborted<T>(
|
|
47
|
-
|
|
46
|
+
export function untilAborted<T>(
|
|
47
|
+
signal: AbortSignal | undefined | null,
|
|
48
|
+
pr: Promise<T> | (() => Promise<T>),
|
|
49
|
+
): Promise<T> {
|
|
50
|
+
if (!signal) return typeof pr === "function" ? pr() : pr;
|
|
48
51
|
if (signal.aborted) return Promise.reject(new AbortError(signal));
|
|
49
52
|
|
|
50
53
|
const { promise, resolve, reject } = Promise.withResolvers<T>();
|
|
51
54
|
const onAbort = () => reject(new AbortError(signal));
|
|
52
55
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
53
|
-
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
54
56
|
|
|
55
57
|
void (async () => {
|
|
56
58
|
try {
|
|
57
|
-
|
|
58
|
-
resolve(out);
|
|
59
|
+
resolve(await (typeof pr === "function" ? pr() : pr));
|
|
59
60
|
} catch (err) {
|
|
60
61
|
reject(err);
|
|
61
62
|
} finally {
|
|
62
|
-
|
|
63
|
+
signal.removeEventListener("abort", onAbort);
|
|
63
64
|
}
|
|
64
65
|
})();
|
|
65
66
|
|
package/src/env.ts
CHANGED
|
@@ -100,9 +100,9 @@ export function isBunTestRuntime(): boolean {
|
|
|
100
100
|
return Bun.env.BUN_ENV === "test" || Bun.env.NODE_ENV === "test";
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
const TRUTHY: Dict<boolean> = { "1": true, TRUE: true, YES: true, ON: true };
|
|
103
|
+
const TRUTHY: Dict<boolean> = { "1": true, Y: true, TRUE: true, YES: true, ON: true };
|
|
104
104
|
export function $flag(name: string, def: boolean = false): boolean {
|
|
105
105
|
const value = $env[name];
|
|
106
106
|
if (!value) return def;
|
|
107
|
-
return
|
|
107
|
+
return TRUTHY[value] === true;
|
|
108
108
|
}
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ export * as ptree from "./ptree";
|
|
|
21
21
|
export { AbortError, ChildProcess, Exception, NonZeroExitError } from "./ptree";
|
|
22
22
|
export * from "./snowflake";
|
|
23
23
|
export * from "./stream";
|
|
24
|
+
export * from "./tab-spacing";
|
|
24
25
|
export * from "./temp";
|
|
25
26
|
export * from "./type-guards";
|
|
26
27
|
export * from "./which";
|
package/src/mermaid-ascii.ts
CHANGED
|
@@ -17,13 +17,13 @@ export function renderMermaidAsciiSafe(source: string, options?: AsciiRenderOpti
|
|
|
17
17
|
/**
|
|
18
18
|
* Extract mermaid code blocks from markdown text.
|
|
19
19
|
*/
|
|
20
|
-
export function extractMermaidBlocks(markdown: string): { source: string; hash: bigint }[] {
|
|
21
|
-
const blocks: { source: string; hash: bigint }[] = [];
|
|
20
|
+
export function extractMermaidBlocks(markdown: string): { source: string; hash: bigint | number }[] {
|
|
21
|
+
const blocks: { source: string; hash: bigint | number }[] = [];
|
|
22
22
|
const regex = /```mermaid\s*\n([\s\S]*?)```/g;
|
|
23
23
|
|
|
24
24
|
for (let match = regex.exec(markdown); match !== null; match = regex.exec(markdown)) {
|
|
25
25
|
const source = match[1].trim();
|
|
26
|
-
const hash = Bun.hash
|
|
26
|
+
const hash = Bun.hash(source);
|
|
27
27
|
blocks.push({ source, hash });
|
|
28
28
|
}
|
|
29
29
|
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default tab width (display / tab expansion) and per-file width from `.editorconfig`.
|
|
3
|
+
* Mirrors former `pi-natives` `indent` + `text` default-tab-width behavior (no N-API).
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import { isEnoent } from "./fs-error";
|
|
8
|
+
|
|
9
|
+
export const MIN_TAB_WIDTH = 1;
|
|
10
|
+
export const MAX_TAB_WIDTH = 16;
|
|
11
|
+
export const DEFAULT_TAB_WIDTH = 3;
|
|
12
|
+
|
|
13
|
+
const EDITORCONFIG_NAME = ".editorconfig";
|
|
14
|
+
|
|
15
|
+
let defaultTabWidth = DEFAULT_TAB_WIDTH;
|
|
16
|
+
|
|
17
|
+
const editorConfigCache = new Map<string, ParsedEditorConfig>();
|
|
18
|
+
const editorConfigChainCache = new Map<string, ChainEntry[]>();
|
|
19
|
+
const indentationCache = new Map<string, number>();
|
|
20
|
+
|
|
21
|
+
interface EditorConfigSection {
|
|
22
|
+
pattern: string;
|
|
23
|
+
properties: Map<string, string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ParsedEditorConfig {
|
|
27
|
+
root: boolean;
|
|
28
|
+
sections: EditorConfigSection[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ChainEntry {
|
|
32
|
+
dir: string;
|
|
33
|
+
parsed: ParsedEditorConfig;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const enum IndentStyle {
|
|
37
|
+
Space,
|
|
38
|
+
Tab,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type IndentSize = { kind: "spaces"; n: number } | { kind: "tab" };
|
|
42
|
+
|
|
43
|
+
interface EditorConfigMatch {
|
|
44
|
+
indentStyle?: IndentStyle;
|
|
45
|
+
indentSize?: IndentSize;
|
|
46
|
+
tabWidth?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function clampTabWidth(value: number): number {
|
|
50
|
+
return Math.min(MAX_TAB_WIDTH, Math.max(MIN_TAB_WIDTH, Math.trunc(value)));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parsePositiveInteger(raw: string | undefined): number | undefined {
|
|
54
|
+
if (raw === undefined) return undefined;
|
|
55
|
+
if (!/^\d+$/.test(raw)) return undefined;
|
|
56
|
+
const parsed = Number.parseInt(raw, 10);
|
|
57
|
+
if (parsed === 0) return undefined;
|
|
58
|
+
return clampTabWidth(parsed);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function fixUnclosedBraces(pattern: string): string {
|
|
62
|
+
const opens = [...pattern].filter(c => c === "{").length;
|
|
63
|
+
const closes = [...pattern].filter(c => c === "}").length;
|
|
64
|
+
if (opens > closes) {
|
|
65
|
+
return pattern + "}".repeat(opens - closes);
|
|
66
|
+
}
|
|
67
|
+
return pattern;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Match `crates/pi-natives/src/glob_util.rs` `build_glob_pattern`. */
|
|
71
|
+
function buildGlobPattern(globStr: string, recursive: boolean): string {
|
|
72
|
+
const normalized = globStr.replace(/\\/g, "/");
|
|
73
|
+
const pattern =
|
|
74
|
+
!recursive || normalized.includes("/") || normalized.startsWith("**") ? normalized : `**/${normalized}`;
|
|
75
|
+
return fixUnclosedBraces(pattern);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function globMatches(pattern: string, relativePath: string): boolean {
|
|
79
|
+
try {
|
|
80
|
+
const g = new Bun.Glob(pattern);
|
|
81
|
+
return g.match(relativePath);
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function matchesEditorConfigPattern(pattern: string, relativePath: string): boolean {
|
|
88
|
+
const normalized = pattern.replace(/^\/+/, "");
|
|
89
|
+
if (!normalized) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const candidates = normalized.includes("/")
|
|
94
|
+
? [buildGlobPattern(normalized, false)]
|
|
95
|
+
: [buildGlobPattern(normalized, false), buildGlobPattern(normalized, true)];
|
|
96
|
+
|
|
97
|
+
for (const p of candidates) {
|
|
98
|
+
if (globMatches(p, relativePath)) {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseEditorConfigFile(content: string): ParsedEditorConfig {
|
|
106
|
+
const parsed: ParsedEditorConfig = { root: false, sections: [] };
|
|
107
|
+
let currentSectionIdx: number | undefined;
|
|
108
|
+
|
|
109
|
+
for (const rawLine of content.split(/\n/)) {
|
|
110
|
+
const line = rawLine.trim();
|
|
111
|
+
if (line === "") continue;
|
|
112
|
+
if (line.startsWith("#") || line.startsWith(";")) continue;
|
|
113
|
+
|
|
114
|
+
if (line.startsWith("[") && line.endsWith("]") && line.length >= 2) {
|
|
115
|
+
const secPattern = line.slice(1, -1).trim();
|
|
116
|
+
if (secPattern === "") {
|
|
117
|
+
currentSectionIdx = undefined;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
parsed.sections.push({ pattern: secPattern, properties: new Map() });
|
|
121
|
+
currentSectionIdx = parsed.sections.length - 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const eq = line.indexOf("=");
|
|
126
|
+
if (eq === -1) continue;
|
|
127
|
+
const key = line.slice(0, eq).trim().toLowerCase();
|
|
128
|
+
const value = line
|
|
129
|
+
.slice(eq + 1)
|
|
130
|
+
.trim()
|
|
131
|
+
.toLowerCase();
|
|
132
|
+
if (key === "") continue;
|
|
133
|
+
|
|
134
|
+
if (currentSectionIdx !== undefined) {
|
|
135
|
+
parsed.sections[currentSectionIdx]!.properties.set(key, value);
|
|
136
|
+
} else if (key === "root") {
|
|
137
|
+
parsed.root = value === "true";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return parsed;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseCachedEditorConfig(configPath: string): ParsedEditorConfig | undefined {
|
|
145
|
+
const key = path.resolve(configPath);
|
|
146
|
+
const hit = editorConfigCache.get(key);
|
|
147
|
+
if (hit !== undefined) {
|
|
148
|
+
return hit;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let content: string;
|
|
152
|
+
try {
|
|
153
|
+
content = fs.readFileSync(key, "utf8");
|
|
154
|
+
} catch (err) {
|
|
155
|
+
if (isEnoent(err)) return undefined;
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
const parsed = parseEditorConfigFile(content);
|
|
159
|
+
editorConfigCache.set(key, parsed);
|
|
160
|
+
return parsed;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function resolveFilePath(projectDir: string, file: string): string {
|
|
164
|
+
if (path.isAbsolute(file)) {
|
|
165
|
+
return path.normalize(path.resolve(file));
|
|
166
|
+
}
|
|
167
|
+
return path.normalize(path.resolve(projectDir, file));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Like `pathdiff::diff_paths` + forward slashes (see `indent.rs`). */
|
|
171
|
+
function relativePathUnified(baseDir: string, absoluteFile: string): string {
|
|
172
|
+
const base = path.resolve(baseDir);
|
|
173
|
+
const file = path.resolve(absoluteFile);
|
|
174
|
+
const rel = path.relative(base, file);
|
|
175
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
176
|
+
return ".";
|
|
177
|
+
}
|
|
178
|
+
return rel.replace(/\\/g, "/");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function collectEditorConfigChain(startDir: string): ChainEntry[] {
|
|
182
|
+
const key = path.resolve(startDir);
|
|
183
|
+
const cached = editorConfigChainCache.get(key);
|
|
184
|
+
if (cached !== undefined) {
|
|
185
|
+
return cached;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const chain: ChainEntry[] = [];
|
|
189
|
+
let cursor = key;
|
|
190
|
+
for (;;) {
|
|
191
|
+
const configPath = path.join(cursor, EDITORCONFIG_NAME);
|
|
192
|
+
const parsed = parseCachedEditorConfig(configPath);
|
|
193
|
+
if (parsed !== undefined) {
|
|
194
|
+
chain.push({ dir: cursor, parsed });
|
|
195
|
+
if (parsed.root) {
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const parent = path.dirname(cursor);
|
|
201
|
+
if (parent === cursor) {
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
cursor = parent;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
chain.reverse();
|
|
208
|
+
editorConfigChainCache.set(key, chain);
|
|
209
|
+
return chain;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function resolveEditorConfigMatch(absoluteFile: string): EditorConfigMatch | undefined {
|
|
213
|
+
const fileDir = path.dirname(absoluteFile);
|
|
214
|
+
const chain = collectEditorConfigChain(fileDir);
|
|
215
|
+
if (chain.length === 0) {
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const match: EditorConfigMatch = {};
|
|
220
|
+
for (const { dir, parsed } of chain) {
|
|
221
|
+
const relativePath = relativePathUnified(dir, absoluteFile);
|
|
222
|
+
for (const section of parsed.sections) {
|
|
223
|
+
if (!matchesEditorConfigPattern(section.pattern, relativePath)) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const style = section.properties.get("indent_style");
|
|
228
|
+
if (style === "space") {
|
|
229
|
+
match.indentStyle = IndentStyle.Space;
|
|
230
|
+
} else if (style === "tab") {
|
|
231
|
+
match.indentStyle = IndentStyle.Tab;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const rawSize = section.properties.get("indent_size");
|
|
235
|
+
if (rawSize === "tab") {
|
|
236
|
+
match.indentSize = { kind: "tab" };
|
|
237
|
+
} else if (rawSize !== undefined) {
|
|
238
|
+
const n = parsePositiveInteger(rawSize);
|
|
239
|
+
if (n !== undefined) {
|
|
240
|
+
match.indentSize = { kind: "spaces", n };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const tw = parsePositiveInteger(section.properties.get("tab_width"));
|
|
245
|
+
if (tw !== undefined) {
|
|
246
|
+
match.tabWidth = tw;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (match.indentStyle === undefined && match.indentSize === undefined && match.tabWidth === undefined) {
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
return match;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function resolveEditorConfigTabWidth(match: EditorConfigMatch | undefined, fallback: number): number | undefined {
|
|
258
|
+
if (match === undefined) return undefined;
|
|
259
|
+
|
|
260
|
+
if (match.indentSize?.kind === "spaces") {
|
|
261
|
+
return match.indentSize.n;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (match.indentSize?.kind === "tab") {
|
|
265
|
+
if (match.tabWidth !== undefined) {
|
|
266
|
+
return match.tabWidth;
|
|
267
|
+
}
|
|
268
|
+
return fallback;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (match.tabWidth !== undefined) {
|
|
272
|
+
return match.tabWidth;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (match.indentStyle === IndentStyle.Tab) {
|
|
276
|
+
return fallback;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function getDefaultTabWidth(): number {
|
|
283
|
+
return defaultTabWidth;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function setDefaultTabWidth(width: number): void {
|
|
287
|
+
defaultTabWidth = clampTabWidth(width);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Visible tab width in columns for `file` (from `.editorconfig` + default), or the default when `file` is omitted.
|
|
292
|
+
*/
|
|
293
|
+
export function getIndentation(file?: string | null, projectDir?: string | null): number {
|
|
294
|
+
const fallback = defaultTabWidth;
|
|
295
|
+
if (file === undefined || file === null || file === "") {
|
|
296
|
+
return fallback;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const cwd = projectDir ?? process.cwd();
|
|
300
|
+
const absoluteFile = resolveFilePath(cwd, file);
|
|
301
|
+
const absKey = absoluteFile;
|
|
302
|
+
const cached = indentationCache.get(absKey);
|
|
303
|
+
if (cached !== undefined) {
|
|
304
|
+
return cached;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const editorMatch = resolveEditorConfigMatch(absoluteFile);
|
|
308
|
+
const resolved = resolveEditorConfigTabWidth(editorMatch, fallback) ?? fallback;
|
|
309
|
+
const clamped = clampTabWidth(resolved);
|
|
310
|
+
indentationCache.set(absKey, clamped);
|
|
311
|
+
return clamped;
|
|
312
|
+
}
|
package/src/which.ts
CHANGED
|
@@ -12,6 +12,8 @@ import * as fs from "node:fs";
|
|
|
12
12
|
import * as os from "node:os";
|
|
13
13
|
import * as path from "node:path";
|
|
14
14
|
|
|
15
|
+
type CacheKey = string | bigint | number;
|
|
16
|
+
|
|
15
17
|
// Tools shipped by Xcode / Command Line Tools that callers actually look up.
|
|
16
18
|
// Keeps the set small so darwinWhich can fast-reject non-Xcode commands without
|
|
17
19
|
// touching the filesystem. Only needs entries for binaries that live *exclusively*
|
|
@@ -146,7 +148,7 @@ function getMacosToolPaths(): Map<string, string> {
|
|
|
146
148
|
}
|
|
147
149
|
|
|
148
150
|
// Map: cache key -> resolved binary path or null (not found)
|
|
149
|
-
const toolCache = new Map<
|
|
151
|
+
const toolCache = new Map<CacheKey, string | null>();
|
|
150
152
|
|
|
151
153
|
/**
|
|
152
154
|
* Cache policy for which lookups.
|
|
@@ -194,12 +196,12 @@ function darwinWhich(command: string, _options?: Bun.WhichOptions): string | nul
|
|
|
194
196
|
export const whichFresh = os.platform() === "darwin" ? darwinWhich : Bun.which;
|
|
195
197
|
|
|
196
198
|
// Derive stable cache key from command and lookup options
|
|
197
|
-
function cacheKey(command: string, options?: Bun.WhichOptions):
|
|
199
|
+
function cacheKey(command: string, options?: Bun.WhichOptions): CacheKey {
|
|
198
200
|
if (!options) return command;
|
|
199
201
|
if (!options.cwd && !options.PATH) return command;
|
|
200
|
-
let h = Bun.hash
|
|
201
|
-
if (options.cwd) h = Bun.hash
|
|
202
|
-
if (options.PATH) h = Bun.hash
|
|
202
|
+
let h = Bun.hash(command);
|
|
203
|
+
if (options.cwd) h = Bun.hash(options.cwd, h);
|
|
204
|
+
if (options.PATH) h = Bun.hash(options.PATH, h);
|
|
203
205
|
return h;
|
|
204
206
|
}
|
|
205
207
|
|
|
@@ -212,7 +214,7 @@ function cacheKey(command: string, options?: Bun.WhichOptions): string | bigint
|
|
|
212
214
|
*/
|
|
213
215
|
export function $which(command: string, options?: WhichOptions): string | null {
|
|
214
216
|
const cachePolicy = options?.cache ?? WhichCachePolicy.Cached;
|
|
215
|
-
let key:
|
|
217
|
+
let key: CacheKey | undefined;
|
|
216
218
|
|
|
217
219
|
if (cachePolicy !== WhichCachePolicy.Bypass) {
|
|
218
220
|
key = cacheKey(command, options);
|