@oh-my-pi/pi-utils 13.1.2 → 13.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-utils",
4
- "version": "13.1.2",
4
+ "version": "13.2.0",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
package/src/indent.ts ADDED
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Shared tab indentation resolution utilities.
3
+ *
4
+ * Resolves tab width from a configurable default and optional per-file `.editorconfig` rules.
5
+ * This module intentionally has no dependency on higher-level settings systems.
6
+ */
7
+
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import { getProjectDir } from "./dirs";
11
+
12
+ const DEFAULT_TAB_WIDTH = 3;
13
+ const MIN_TAB_WIDTH = 1;
14
+ const MAX_TAB_WIDTH = 16;
15
+ const EDITORCONFIG_NAME = ".editorconfig";
16
+
17
+ /** Parsed `.editorconfig` section `[pattern]` with normalized key/value properties */
18
+ interface EditorConfigSection {
19
+ pattern: string;
20
+ properties: Record<string, string>;
21
+ }
22
+
23
+ /** Parsed `.editorconfig` document with top-level `root` flag and ordered sections */
24
+ interface ParsedEditorConfig {
25
+ root: boolean;
26
+ sections: EditorConfigSection[];
27
+ }
28
+
29
+ /** Effective editorconfig indent-related properties merged for one target file */
30
+ interface EditorConfigMatch {
31
+ indentStyle?: "space" | "tab";
32
+ indentSize?: number | "tab";
33
+ tabWidth?: number;
34
+ }
35
+
36
+ const editorConfigCache = new Map<string, ParsedEditorConfig>();
37
+ const editorConfigChainCache = new Map<string, Array<{ dir: string; parsed: ParsedEditorConfig }>>();
38
+ const indentationCache = new Map<string, string>();
39
+ let defaultTabWidth = DEFAULT_TAB_WIDTH;
40
+
41
+ function clampTabWidth(value: number): number {
42
+ if (!Number.isFinite(value)) return DEFAULT_TAB_WIDTH;
43
+ return Math.min(MAX_TAB_WIDTH, Math.max(MIN_TAB_WIDTH, Math.round(value)));
44
+ }
45
+
46
+ function parsePositiveInteger(value: string | undefined): number | undefined {
47
+ if (!value) return undefined;
48
+ if (!/^\d+$/.test(value)) return undefined;
49
+ const parsed = Number.parseInt(value, 10);
50
+ if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
51
+ return clampTabWidth(parsed);
52
+ }
53
+
54
+ function parseEditorConfigFile(content: string): ParsedEditorConfig {
55
+ const parsed: ParsedEditorConfig = { root: false, sections: [] };
56
+ let currentSection: EditorConfigSection | null = null;
57
+
58
+ for (const rawLine of content.split(/\r?\n/)) {
59
+ const line = rawLine.trim();
60
+ if (line.length === 0) continue;
61
+ if (line.startsWith("#") || line.startsWith(";")) continue;
62
+
63
+ const sectionMatch = line.match(/^\[(.+)\]$/);
64
+ if (sectionMatch) {
65
+ const pattern = sectionMatch[1].trim();
66
+ if (pattern.length === 0) {
67
+ currentSection = null;
68
+ continue;
69
+ }
70
+ currentSection = { pattern, properties: {} };
71
+ parsed.sections.push(currentSection);
72
+ continue;
73
+ }
74
+
75
+ const equalsIndex = line.indexOf("=");
76
+ if (equalsIndex === -1) continue;
77
+
78
+ const key = line.slice(0, equalsIndex).trim().toLowerCase();
79
+ const value = line
80
+ .slice(equalsIndex + 1)
81
+ .trim()
82
+ .toLowerCase();
83
+ if (key.length === 0) continue;
84
+
85
+ if (currentSection === null) {
86
+ if (key === "root") parsed.root = value === "true";
87
+ continue;
88
+ }
89
+
90
+ currentSection.properties[key] = value;
91
+ }
92
+
93
+ return parsed;
94
+ }
95
+
96
+ function parseCachedEditorConfig(configPath: string): ParsedEditorConfig | null {
97
+ const cached = editorConfigCache.get(configPath);
98
+ if (cached) return cached;
99
+
100
+ let content: string;
101
+ try {
102
+ content = fs.readFileSync(configPath, "utf8");
103
+ } catch {
104
+ return null;
105
+ }
106
+
107
+ const parsed = parseEditorConfigFile(content);
108
+ editorConfigCache.set(configPath, parsed);
109
+ return parsed;
110
+ }
111
+
112
+ function matchesEditorConfigPattern(pattern: string, relativePath: string): boolean {
113
+ const normalizedPattern = pattern.replace(/^\//, "");
114
+ if (normalizedPattern.length === 0) return false;
115
+
116
+ const candidates = new Set<string>();
117
+ candidates.add(normalizedPattern);
118
+ if (!normalizedPattern.includes("/")) {
119
+ candidates.add(`**/${normalizedPattern}`);
120
+ }
121
+
122
+ for (const candidate of candidates) {
123
+ try {
124
+ if (new Bun.Glob(candidate).match(relativePath)) {
125
+ return true;
126
+ }
127
+ } catch {}
128
+ }
129
+
130
+ return false;
131
+ }
132
+
133
+ function resolveFilePath(file: string): string {
134
+ if (path.isAbsolute(file)) return path.normalize(file);
135
+ return path.normalize(path.resolve(getProjectDir(), file));
136
+ }
137
+
138
+ function collectEditorConfigChain(startDir: string): Array<{ dir: string; parsed: ParsedEditorConfig }> {
139
+ const cached = editorConfigChainCache.get(startDir);
140
+ if (cached) return cached;
141
+
142
+ const chain: Array<{ dir: string; parsed: ParsedEditorConfig }> = [];
143
+ let cursor = path.resolve(startDir);
144
+
145
+ while (true) {
146
+ const configPath = path.join(cursor, EDITORCONFIG_NAME);
147
+ const parsed = parseCachedEditorConfig(configPath);
148
+ if (parsed) {
149
+ chain.push({ dir: cursor, parsed });
150
+ if (parsed.root) break;
151
+ }
152
+
153
+ const parent = path.dirname(cursor);
154
+ if (parent === cursor) break;
155
+ cursor = parent;
156
+ }
157
+
158
+ const result = chain.reverse();
159
+ editorConfigChainCache.set(startDir, result);
160
+ return result;
161
+ }
162
+
163
+ function resolveEditorConfigMatch(absoluteFile: string): EditorConfigMatch | null {
164
+ const fileDir = path.dirname(absoluteFile);
165
+ const chain = collectEditorConfigChain(fileDir);
166
+ if (chain.length === 0) return null;
167
+
168
+ const match: EditorConfigMatch = {};
169
+
170
+ for (const { dir, parsed } of chain) {
171
+ const relativePath = path.relative(dir, absoluteFile).split(path.sep).join("/");
172
+ for (const section of parsed.sections) {
173
+ if (!matchesEditorConfigPattern(section.pattern, relativePath)) continue;
174
+ const indentStyle = section.properties.indent_style;
175
+ if (indentStyle === "space" || indentStyle === "tab") {
176
+ match.indentStyle = indentStyle;
177
+ }
178
+
179
+ const indentSizeRaw = section.properties.indent_size;
180
+ if (indentSizeRaw === "tab") {
181
+ match.indentSize = "tab";
182
+ } else {
183
+ const indentSize = parsePositiveInteger(indentSizeRaw);
184
+ if (indentSize !== undefined) {
185
+ match.indentSize = indentSize;
186
+ }
187
+ }
188
+
189
+ const tabWidth = parsePositiveInteger(section.properties.tab_width);
190
+ if (tabWidth !== undefined) {
191
+ match.tabWidth = tabWidth;
192
+ }
193
+ }
194
+ }
195
+
196
+ if (match.indentStyle || match.indentSize !== undefined || match.tabWidth !== undefined) {
197
+ return match;
198
+ }
199
+ return null;
200
+ }
201
+
202
+ function resolveEditorConfigTabWidth(match: EditorConfigMatch | null, fallbackWidth: number): number | null {
203
+ if (!match) return null;
204
+
205
+ if (typeof match.indentSize === "number") {
206
+ return match.indentSize;
207
+ }
208
+
209
+ if (match.indentSize === "tab") {
210
+ if (typeof match.tabWidth === "number") return match.tabWidth;
211
+ return fallbackWidth;
212
+ }
213
+
214
+ if (typeof match.tabWidth === "number") {
215
+ return match.tabWidth;
216
+ }
217
+
218
+ if (match.indentStyle === "tab") {
219
+ return fallbackWidth;
220
+ }
221
+
222
+ return null;
223
+ }
224
+
225
+ /**
226
+ * Sets the process-wide default tab width used when no file-specific override applies.
227
+ *
228
+ * @param width Desired tab width in spaces. Values are clamped to a safe range.
229
+ */
230
+ export function setDefaultTabWidth(width: number): void {
231
+ defaultTabWidth = clampTabWidth(width);
232
+ indentationCache.clear();
233
+ }
234
+
235
+ /**
236
+ * Gets the current process-wide default tab width.
237
+ */
238
+ export function getDefaultTabWidth(): number {
239
+ return defaultTabWidth;
240
+ }
241
+
242
+ /**
243
+ * Returns indentation used to replace a tab character.
244
+ *
245
+ * If `file` is provided, `.editorconfig` rules are resolved for that file path and applied.
246
+ * Otherwise, the configured default tab width is used.
247
+ *
248
+ * @param file Optional absolute or project-relative file path for editorconfig resolution
249
+ * @returns A string containing N spaces representing one tab
250
+ */
251
+ export function getIndentation(file?: string): string {
252
+ if (!file) return " ".repeat(getDefaultTabWidth());
253
+
254
+ const absoluteFile = resolveFilePath(file);
255
+ const cached = indentationCache.get(absoluteFile);
256
+ if (cached) return cached;
257
+
258
+ const fallbackWidth = getDefaultTabWidth();
259
+ const editorConfigMatch = resolveEditorConfigMatch(absoluteFile);
260
+ const resolvedWidth = resolveEditorConfigTabWidth(editorConfigMatch, fallbackWidth) ?? fallbackWidth;
261
+ const result = " ".repeat(clampTabWidth(resolvedWidth));
262
+ indentationCache.set(absoluteFile, result);
263
+ return result;
264
+ }
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export * from "./env";
5
5
  export * from "./format";
6
6
  export * from "./fs-error";
7
7
  export * from "./glob";
8
+ export * from "./indent";
8
9
  export * as logger from "./logger";
9
10
  export * as postmortem from "./postmortem";
10
11
  export * as procmgr from "./procmgr";