@oh-my-pi/pi-utils 14.0.4 → 14.0.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-utils",
4
- "version": "14.0.4",
4
+ "version": "14.0.5",
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.4"
41
+ "@oh-my-pi/pi-natives": "14.0.5"
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>(signal: AbortSignal | undefined | null, pr: () => Promise<T>): Promise<T> {
47
- if (!signal) return pr();
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
- const out = await pr();
58
- resolve(out);
59
+ resolve(await (typeof pr === "function" ? pr() : pr));
59
60
  } catch (err) {
60
61
  reject(err);
61
62
  } finally {
62
- cleanup();
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 !!TRUTHY[value];
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";
@@ -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.xxHash64(source);
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<string | bigint, string | null>();
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): string | bigint {
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.xxHash64(command);
201
- if (options.cwd) h = Bun.hash.xxHash64(options.cwd, h);
202
- if (options.PATH) h = Bun.hash.xxHash64(options.PATH, h);
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: string | bigint | undefined;
217
+ let key: CacheKey | undefined;
216
218
 
217
219
  if (cachePolicy !== WhichCachePolicy.Bypass) {
218
220
  key = cacheKey(command, options);