feat: standardized tab expansion to fixed width

- Removed configurable tab width support and the `display.tabWidth` setting across all packages.
- Deleted obsolete utility functions `getIndentation`, `getIndentationNoescape`, and `setDefaultTabWidth`.
- Standardized tab expansion logic to use a fixed `DEFAULT_TAB_WIDTH` globally.
- Cleaned up related configuration schemas, test suites, and internal API signatures to remove path-dependency.
This commit is contained in:
can1357
2026-06-19 04:48:31 +02:00
parent 9d2728a455
commit 3998d95088
17 changed files with 73 additions and 324 deletions
+4 -1
View File
@@ -1,7 +1,6 @@
# Changelog
## [Unreleased]
### Added
- Added `__advisor.jsonl` transcript persistence for advisor model usage attribution and visibility in the Agent Hub
@@ -22,6 +21,10 @@
- Refined `/compact` argument parsing to reject focus instructions for modes that do not support them (e.g., `snapcompact`)
- Protocol hosts (RPC/`rpc-ui`/ACP) now host-default the full advisor settings group — `advisor.syncBacklog` and `advisor.immuneTurns` in addition to `advisor.enabled`/`advisor.subagents` — so a host that opts the advisor in gets the default tuning instead of inheriting the user's local advisor preferences.
### Removed
- Removed `display.tabWidth` setting and configurable tab width support
### Fixed
- Fixed memory-leaking stale transcripts in the agent viewer when underlying files are deleted
@@ -809,11 +809,6 @@ export const SETTINGS_SCHEMA = {
description: "Remove the 1-character horizontal padding from the left and right of the terminal output",
},
},
// Display rendering
"display.tabWidth": {
type: "number",
default: 3,
},
"display.shimmer": {
type: "enum",
@@ -22,7 +22,6 @@ import {
isEnoent,
logger,
procmgr,
setDefaultTabWidth,
} from "@oh-my-pi/pi-utils";
import { JSONC, YAML } from "bun";
import { type Settings as SettingsCapabilityItem, settingsCapability } from "../capability/settings";
@@ -1103,11 +1102,6 @@ const SETTING_HOOKS: Partial<Record<SettingPath, SettingHook<any>>> = {
});
}
},
"display.tabWidth": value => {
if (typeof value === "number") {
setDefaultTabWidth(value);
}
},
"provider.appendOnlyContext": value => {
if (typeof value === "string") {
appendOnlyModeSignal.fire(value);
@@ -102,7 +102,7 @@ const HASHLINE_LINE_PREFIX = /^[ *]?(\d+)(?:-(\d+))?:/;
/**
* The 1-indexed file lines a hashline-formatted body actually displayed.
* Single `NN:` rows contribute that line; a collapsed summary `NN-MM:` row
* (a `{ .. }` brace pair) contributes only its boundary lines `NN` and `MM` —
* (a `{ … }` brace pair) contributes only its boundary lines `NN` and `MM` —
* the elided interior was never shown, so editing inside it must be rejected.
*/
export function parseSeenLinesFromHashlineBody(body: string): number[] {
+7 -7
View File
@@ -230,7 +230,7 @@ function truncateEditTitlePath(displayPath: string, maxWidth: number | undefined
}
function formatEditTitlePath(pathValue: string, maxWidth?: number): string {
return truncateEditTitlePath(replaceTabs(shortenPath(pathValue), pathValue), maxWidth);
return truncateEditTitlePath(replaceTabs(shortenPath(pathValue)), maxWidth);
}
function formatEditPathDisplay(
@@ -326,11 +326,11 @@ function renderEditHeader(
return buildHeader(fitted.description);
}
function renderPlainTextPreview(text: string, uiTheme: Theme, filePath?: string): string {
function renderPlainTextPreview(text: string, uiTheme: Theme, _filePath?: string): string {
const previewLines = sanitizeText(text).split("\n");
let preview = "\n\n";
for (const line of previewLines.slice(0, CALL_TEXT_PREVIEW_LINES)) {
preview += `${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line, filePath), CALL_TEXT_PREVIEW_WIDTH))}\n`;
preview += `${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), CALL_TEXT_PREVIEW_WIDTH))}\n`;
}
if (previewLines.length > CALL_TEXT_PREVIEW_LINES) {
preview += uiTheme.fg("dim", `… ${previewLines.length - CALL_TEXT_PREVIEW_LINES} more lines`);
@@ -395,7 +395,7 @@ function formatMultiFileStreamingDiff(
if (!preview.diff && !preview.error) continue;
const header = uiTheme.fg("dim", `\n\n── ${shortenPath(preview.path)} ──`);
if (preview.error) {
parts.push(`${header}\n${uiTheme.fg("error", replaceTabs(preview.error, preview.path))}`);
parts.push(`${header}\n${uiTheme.fg("error", replaceTabs(preview.error))}`);
continue;
}
if (preview.diff) {
@@ -637,7 +637,7 @@ export const editToolRenderer = {
callPreviewCaches,
);
if (applyPatchSummary?.error) {
body += `\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error, rawPath), Math.max(1, width - 2)))}`;
body += `\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error), Math.max(1, width - 2)))}`;
}
const bodyLines = body ? body.split("\n") : [];
while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
@@ -733,11 +733,11 @@ function renderSingleFileResult(
let body = "";
if (isError) {
if (errorText) body = uiTheme.fg("error", replaceTabs(errorText, rawPath));
if (errorText) body = uiTheme.fg("error", replaceTabs(errorText));
} else if (details?.diff) {
body = renderDiffSection(details.diff, rawPath, expanded, uiTheme, renderDiffFn, diffSectionCache);
} else if (editDiffPreview) {
if ("error" in editDiffPreview) body = uiTheme.fg("error", replaceTabs(editDiffPreview.error, rawPath));
if ("error" in editDiffPreview) body = uiTheme.fg("error", replaceTabs(editDiffPreview.error));
else if (editDiffPreview.diff)
body = renderDiffSection(editDiffPreview.diff, rawPath, expanded, uiTheme, renderDiffFn, diffSectionCache);
}
@@ -1,4 +1,4 @@
import { getIndentation, sanitizeText } from "@oh-my-pi/pi-utils";
import { DEFAULT_TAB_WIDTH, sanitizeText } from "@oh-my-pi/pi-utils";
import * as Diff from "diff";
import { getLanguageFromPath, highlightCode, theme } from "../../modes/theme/theme";
import { type CodeFrameMarker, formatCodeFrameLine, replaceTabs } from "../../tools/render-utils";
@@ -13,12 +13,12 @@ const DIM_OFF = "\x1b[22m";
* before the first non-whitespace character; remaining tabs in code
* content are replaced with spaces (like replaceTabs).
*/
function visualizeIndent(text: string, filePath?: string): string {
function visualizeIndent(text: string): string {
const match = text.match(/^([ \t]+)/);
if (!match) return replaceTabs(text, filePath);
if (!match) return replaceTabs(text);
const indent = match[1];
const rest = text.slice(indent.length);
const tabWidth = getIndentation(filePath);
const tabWidth = DEFAULT_TAB_WIDTH;
const leftPadding = Math.floor(tabWidth / 2);
const rightPadding = Math.max(0, tabWidth - leftPadding - 1);
const tabMarker = `${DIM}${" ".repeat(leftPadding)}→${" ".repeat(rightPadding)}${DIM_OFF}`;
@@ -30,7 +30,7 @@ function visualizeIndent(text: string, filePath?: string): string {
visible += `${DIM}·${DIM_OFF}`;
}
}
return `${visible}${replaceTabs(rest, filePath)}`;
return `${visible}${replaceTabs(rest)}`;
}
/**
@@ -153,7 +153,7 @@ export function renderDiff(diffText: string, options: RenderDiffOptions = {}): s
// unicode ellipsis.
const trimmed = line.trim();
const isGapRow = trimmed.length === 0 || trimmed === "..." || trimmed === "…";
result.push(theme.fg("toolDiffContext", isGapRow ? "…" : replaceTabs(line, options.filePath)));
result.push(theme.fg("toolDiffContext", isGapRow ? "…" : replaceTabs(line)));
i++;
continue;
}
@@ -184,47 +184,24 @@ export function renderDiff(diffText: string, options: RenderDiffOptions = {}): s
replaceTabs(added.content),
);
result.push(
theme.fg(
"toolDiffRemoved",
formatLine("-", removed.lineNum, visualizeIndent(removedLine, options.filePath)),
),
);
result.push(
theme.fg("toolDiffAdded", formatLine("+", added.lineNum, visualizeIndent(addedLine, options.filePath))),
);
result.push(theme.fg("toolDiffRemoved", formatLine("-", removed.lineNum, visualizeIndent(removedLine))));
result.push(theme.fg("toolDiffAdded", formatLine("+", added.lineNum, visualizeIndent(addedLine))));
} else {
for (const removed of removedLines) {
result.push(
theme.fg(
"toolDiffRemoved",
formatLine("-", removed.lineNum, visualizeIndent(removed.content, options.filePath)),
),
theme.fg("toolDiffRemoved", formatLine("-", removed.lineNum, visualizeIndent(removed.content))),
);
}
for (const added of addedLines) {
result.push(
theme.fg(
"toolDiffAdded",
formatLine("+", added.lineNum, visualizeIndent(added.content, options.filePath)),
),
);
result.push(theme.fg("toolDiffAdded", formatLine("+", added.lineNum, visualizeIndent(added.content))));
}
}
} else if (parsed.prefix === "+") {
result.push(
theme.fg(
"toolDiffAdded",
formatLine("+", parsed.lineNum, visualizeIndent(parsed.content, options.filePath)),
),
);
result.push(theme.fg("toolDiffAdded", formatLine("+", parsed.lineNum, visualizeIndent(parsed.content))));
i++;
} else {
const highlighted = contextHighlights.get(i);
const content =
highlighted !== undefined
? replaceTabs(highlighted, options.filePath)
: visualizeIndent(parsed.content, options.filePath);
const content = highlighted !== undefined ? replaceTabs(highlighted) : visualizeIndent(parsed.content);
result.push(theme.fg("toolDiffContext", formatLine(" ", parsed.lineNum, content)));
i++;
}
@@ -1,59 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { getDefaultTabWidth, getIndentation, Snowflake, setDefaultTabWidth } from "@oh-my-pi/pi-utils";
import { beginSettingsTest, restoreSettingsTestState, type SettingsTestState } from "./helpers/settings-test-state";
describe("indentation resolver", () => {
let settingsState: SettingsTestState | undefined;
let tempDir = "";
beforeEach(async () => {
settingsState = beginSettingsTest();
setDefaultTabWidth(3);
tempDir = path.join(os.tmpdir(), "pi-spacing", Snowflake.next());
await fs.mkdir(tempDir, { recursive: true });
});
afterEach(async () => {
restoreSettingsTestState(settingsState);
settingsState = undefined;
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
}
tempDir = "";
});
it("applies current display tab width during initial settings load", async () => {
await Settings.init({ inMemory: true, cwd: tempDir, overrides: { "display.tabWidth": 7 } });
expect(getDefaultTabWidth()).toBe(7);
expect(getIndentation()).toBe(7);
});
it("applies nearest editorconfig rules for the target file", async () => {
const filePath = path.join(tempDir, "src", "feature.ts");
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, "\tconst x = 1;\n");
await fs.writeFile(
path.join(tempDir, ".editorconfig"),
["root = true", "", "[*]", "indent_size = 2", "", "[*.md]", "indent_size = 4"].join("\n"),
);
expect(getIndentation(filePath)).toBe(2);
expect(getIndentation(path.join(tempDir, "README.md"))).toBe(4);
});
it("merges editorconfig files from root to leaf", async () => {
const nestedDir = path.join(tempDir, "packages", "feature");
const filePath = path.join(nestedDir, "index.ts");
await fs.mkdir(nestedDir, { recursive: true });
await fs.writeFile(filePath, "\tconst y = 2;\n");
await fs.writeFile(path.join(tempDir, ".editorconfig"), ["root = true", "", "[*]", "indent_size = 2"].join("\n"));
await fs.writeFile(path.join(tempDir, "packages", ".editorconfig"), ["[*.ts]", "indent_size = 6"].join("\n"));
expect(getIndentation(filePath)).toBe(6);
});
});
@@ -1,20 +1,12 @@
import { vi } from "bun:test";
import { resetSettingsForTest } from "@oh-my-pi/pi-coding-agent/config/settings";
import { isTuiTight, setTuiTight } from "@oh-my-pi/pi-tui";
import {
getAgentDir,
getDefaultTabWidth,
getProjectDir,
setAgentDir,
setDefaultTabWidth,
setProjectDir,
} from "@oh-my-pi/pi-utils";
import { getAgentDir, getProjectDir, setAgentDir, setProjectDir } from "@oh-my-pi/pi-utils";
export interface SettingsTestState {
agentDir: string;
env: Record<string, string | undefined>;
projectDir: string;
tabWidth: number;
tuiTight: boolean;
}
@@ -30,7 +22,6 @@ export function beginSettingsTest(): SettingsTestState {
agentDir: getAgentDir(),
env,
projectDir: getProjectDir(),
tabWidth: getDefaultTabWidth(),
tuiTight: isTuiTight(),
};
resetSettingsForTest();
@@ -43,7 +34,6 @@ export function restoreSettingsTestState(state: SettingsTestState | undefined):
if (!state) return;
restoreEnv(state.env);
setDefaultTabWidth(state.tabWidth);
setProjectDir(state.projectDir);
setAgentDir(state.agentDir);
setTuiTight(state.tuiTight);
+11
View File
@@ -1,6 +1,17 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Removed `getIndentation` and `getIndentationNoescape` exported utilities
- Tab-related operations no longer respect per-file or globally configured indentation settings
### Changed
- Standardized tab expansion to use a fixed display width instead of configurable settings
- Removed support for custom tab width configuration in text rendering and input handling
### Fixed
- Corrected logic in string truncation to prevent improper truncation of short strings
+12 -19
View File
@@ -7,11 +7,11 @@ import {
wrapTextWithAnsi as nativeWrapTextWithAnsi,
type SliceResult,
} from "@oh-my-pi/pi-natives";
import { getDefaultTabWidth, getIndentation } from "@oh-my-pi/pi-utils";
import { DEFAULT_TAB_WIDTH } from "@oh-my-pi/pi-utils";
export { Ellipsis } from "@oh-my-pi/pi-natives";
export { getDefaultTabWidth, getIndentation } from "@oh-my-pi/pi-utils";
export { DEFAULT_TAB_WIDTH } from "@oh-my-pi/pi-utils";
export type TextSizingScale = 1 | 2 | 3;
export type TextSizingVerticalAlign = "top" | "bottom" | "center";
@@ -74,7 +74,7 @@ export function encodeTextSized(text: string, options: TextSizingOptions = {}):
}
export function sliceWithWidth(line: string, startCol: number, length: number, strict?: boolean | null): SliceResult {
return nativeSliceWithWidth(line, startCol, length, strict ?? null, getDefaultTabWidth());
return nativeSliceWithWidth(line, startCol, length, strict ?? null, DEFAULT_TAB_WIDTH);
}
export function truncateToWidth(
@@ -94,12 +94,12 @@ export function truncateToWidth(
maxWidth,
(ellipsisKind === "" ? Ellipsis.Omit : ellipsisKind) ?? Ellipsis.Unicode,
pad ?? false,
getDefaultTabWidth(),
DEFAULT_TAB_WIDTH,
);
}
export function wrapTextWithAnsi(text: string, width: number): string[] {
return nativeWrapTextWithAnsi(text, width, getDefaultTabWidth());
return nativeWrapTextWithAnsi(text, width, DEFAULT_TAB_WIDTH);
}
export function extractSegments(
@@ -109,24 +109,17 @@ export function extractSegments(
afterLen: number,
strictAfter: boolean,
): ExtractSegmentsResult {
return nativeExtractSegments(line, beforeEnd, afterStart, afterLen, strictAfter, getDefaultTabWidth());
return nativeExtractSegments(line, beforeEnd, afterStart, afterLen, strictAfter, DEFAULT_TAB_WIDTH);
}
// Pre-allocated space buffer for padding
const SPACE_BUFFER = " ".repeat(512);
/**
* Tab width in columns for `file`, using `process.cwd()` as the project root for relative paths.
*/
export function getIndentationNoescape(file?: string): number {
return getIndentation(file, process.cwd());
}
/*
* Replace tabs with configured spacing for consistent rendering.
* Replace tabs with the fixed display tab width for consistent rendering.
*/
export function replaceTabs(text: string, file?: string): string {
return text.replaceAll("\t", " ".repeat(getIndentation(file)));
export function replaceTabs(text: string): string {
return text.replaceAll("\t", " ".repeat(DEFAULT_TAB_WIDTH));
}
/**
@@ -183,7 +176,7 @@ export function visibleWidth(str: string): number {
for (let tabIndex = str.indexOf(TAB); tabIndex !== -1; tabIndex = str.indexOf(TAB, tabIndex + 1)) {
tabCount++;
}
if (tabCount > 0) width += tabCount * getDefaultTabWidth();
if (tabCount > 0) width += tabCount * DEFAULT_TAB_WIDTH;
return width;
}
@@ -200,7 +193,7 @@ export function visibleWidth(str: string): number {
}
}
if (i === str.length) {
return tabCount === 0 ? str.length : str.length + tabCount * (getDefaultTabWidth() - 1);
return tabCount === 0 ? str.length : str.length + tabCount * (DEFAULT_TAB_WIDTH - 1);
}
if (tabCount === 0) {
@@ -221,7 +214,7 @@ export function visibleWidth(str: string): number {
// the native scanner that traps under Bun 1.3.x GC/N-API load). It strips
// CSI/OSC to zero cells and shares the native engine's UAX#11 width tables.
let width = Bun.stringWidth(str, STRING_WIDTH_OPTS);
if (tabCount > 0) width += tabCount * getDefaultTabWidth();
if (tabCount > 0) width += tabCount * DEFAULT_TAB_WIDTH;
// OSC 66: add back each stripped span as `scale * (explicit w ?? payload
// width)`. Matched rather than replaced to avoid reallocating the string.
+3 -10
View File
@@ -6,7 +6,6 @@ import { Editor } from "@oh-my-pi/pi-tui/components/editor";
import { KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "@oh-my-pi/pi-tui/keybindings";
import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys";
import { visibleWidth } from "@oh-my-pi/pi-tui/utils";
import { setDefaultTabWidth } from "@oh-my-pi/pi-utils";
import { defaultEditorTheme } from "./test-themes";
describe("Editor component", () => {
@@ -545,16 +544,10 @@ describe("Editor component", () => {
expect(text).toBe("Hällö Wörld! 😀 äöüÄÖÜß");
});
it("uses the configured tab width when loading text programmatically", () => {
it("expands tabs to the fixed display width when loading text programmatically", () => {
const editor = new Editor(defaultEditorTheme);
try {
setDefaultTabWidth(5);
editor.setText("foo\tbar");
expect(editor.getText()).toBe("foo bar");
} finally {
setDefaultTabWidth(3);
}
editor.setText("foo\tbar");
expect(editor.getText()).toBe("foo bar");
});
it("strips control characters from programmatically loaded text before render", () => {
+3 -3
View File
@@ -3,7 +3,7 @@ import { CURSOR_MARKER } from "@oh-my-pi/pi-tui";
import { Input } from "@oh-my-pi/pi-tui/components/input";
import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys";
import { visibleWidth } from "@oh-my-pi/pi-tui/utils";
import { getIndentation } from "@oh-my-pi/pi-utils";
import { DEFAULT_TAB_WIDTH } from "@oh-my-pi/pi-utils";
function renderedWidth(input: Input, width: number): number {
const [line] = input.render(width);
@@ -155,7 +155,7 @@ describe("Input component", () => {
setKittyProtocolActive(false);
});
it("normalizes tabs in buffered bracketed paste using configured indentation", () => {
it("normalizes tabs in buffered bracketed paste using the fixed display width", () => {
const input = setupAtEnd("");
input.handleInput("\x1b[200~a\t");
@@ -165,7 +165,7 @@ describe("Input component", () => {
expect(input.getValue()).toBe("");
input.handleInput("c\x1b[201~");
expect(input.getValue()).toBe(`a${" ".repeat(getIndentation())}bc`);
expect(input.getValue()).toBe(`a${" ".repeat(DEFAULT_TAB_WIDTH)}bc`);
});
it("decodes tmux re-encoded control bytes in bracketed paste without leaking tails or storing raw C0", () => {
+2 -2
View File
@@ -12,12 +12,12 @@
*/
import { describe, expect, it } from "bun:test";
import { visibleWidth as nativeVisibleWidth } from "@oh-my-pi/pi-natives";
import { getDefaultTabWidth, visibleWidth } from "@oh-my-pi/pi-tui/utils";
import { DEFAULT_TAB_WIDTH, visibleWidth } from "@oh-my-pi/pi-tui/utils";
const ESC = "\x1b";
const ST = "\x1b\\";
const BEL = "\x07";
const TAB = getDefaultTabWidth();
const TAB = DEFAULT_TAB_WIDTH;
describe("visibleWidth — parity with the native width engine", () => {
const corpus: [string, string][] = [
+3
View File
@@ -1,6 +1,9 @@
# Changelog
## [Unreleased]
### Removed
- Removed `getIndentation`, `setDefaultTabWidth`, and `getDefaultTabWidth` helpers
## [16.0.8] - 2026-06-18
+4 -73
View File
@@ -20,11 +20,8 @@ const NAME_MAX_BYTES = 255;
const EDITORCONFIG_NAME = ".editorconfig";
let defaultTabWidth = DEFAULT_TAB_WIDTH;
const editorConfigCache = new Map<string, ParsedEditorConfig | null>();
const editorConfigChainCache = new Map<string, ChainEntry[]>();
const indentationCache = new Map<string, number>();
interface EditorConfigSection {
pattern: string;
@@ -266,31 +263,6 @@ function resolveEditorConfigMatch(absoluteFile: string): EditorConfigMatch | und
return match;
}
function resolveEditorConfigTabWidth(match: EditorConfigMatch | undefined, fallback: number): number | undefined {
if (match === undefined) return undefined;
if (match.indentSize?.kind === "spaces") {
return match.indentSize.n;
}
if (match.indentSize?.kind === "tab") {
if (match.tabWidth !== undefined) {
return match.tabWidth;
}
return fallback;
}
if (match.tabWidth !== undefined) {
return match.tabWidth;
}
if (match.indentStyle === IndentStyle.Tab) {
return fallback;
}
return undefined;
}
function hasOverlongPathComponent(filePath: string): boolean {
for (const part of filePath.split(/[\\/]/)) {
if (part.length > 0 && Buffer.byteLength(part) > NAME_MAX_BYTES) {
@@ -300,53 +272,12 @@ function hasOverlongPathComponent(filePath: string): boolean {
return false;
}
export function getDefaultTabWidth(): number {
return defaultTabWidth;
}
export function setDefaultTabWidth(width: number): void {
defaultTabWidth = clampTabWidth(width);
}
/**
* Visible tab width in columns for `file` (from `.editorconfig` + default), or the default when `file` is omitted.
*/
export function getIndentation(file?: string | null, projectDir?: string | null): number {
const fallback = defaultTabWidth;
if (file === undefined || file === null || file === "") {
return fallback;
}
const cwd = projectDir ?? process.cwd();
const absoluteFile = resolveFilePath(cwd, file);
// Renderers can hand us arbitrary strings (e.g. a malformed edit tool
// call whose `file_path` is gibberish). Reject paths whose normalized
// absolute form still has any component longer than `NAME_MAX_BYTES` —
// the editorconfig chain would only trip `ENAMETOOLONG` from
// `readFileSync` and escape.
if (hasOverlongPathComponent(absoluteFile)) {
return fallback;
}
const absKey = absoluteFile;
const cached = indentationCache.get(absKey);
if (cached !== undefined) {
return cached;
}
const editorMatch = resolveEditorConfigMatch(absoluteFile);
const resolved = resolveEditorConfigTabWidth(editorMatch, fallback) ?? fallback;
const clamped = clampTabWidth(resolved);
indentationCache.set(absKey, clamped);
return clamped;
}
/**
* `.editorconfig`-derived formatting options for an LSP `textDocument/formatting` request.
*
* Both fields are absent when the resolved `.editorconfig` chain does not pin them, so callers
* can layer their own fallbacks (content sniffing, project defaults) underneath. Returned values
* are clamped to {@link MIN_TAB_WIDTH}..{@link MAX_TAB_WIDTH} for parity with {@link getIndentation}.
* are clamped to {@link MIN_TAB_WIDTH}..{@link MAX_TAB_WIDTH}.
*/
export interface EditorConfigFormatting {
/** Effective indent width in columns, from `indent_size` or `tab_width`. */
@@ -359,8 +290,8 @@ export interface EditorConfigFormatting {
* Resolve `.editorconfig` formatting hints for `file` without falling back to any default.
*
* Used by the LSP format-on-write path so a missing `.editorconfig` declaration falls through
* to caller-provided defaults instead of clobbering the file with the renderer's display
* `defaultTabWidth` (issue #2329).
* to caller-provided defaults instead of clobbering the file with the renderer's
* display tab width (issue #2329).
*/
export function getEditorConfigFormatting(file?: string | null, projectDir?: string | null): EditorConfigFormatting {
if (file === undefined || file === null || file === "") {
@@ -370,7 +301,7 @@ export function getEditorConfigFormatting(file?: string | null, projectDir?: str
const cwd = projectDir ?? process.cwd();
const absoluteFile = resolveFilePath(cwd, file);
// Same NAME_MAX guard as `getIndentation`: editorconfig discovery is
// NAME_MAX guard: editorconfig discovery is
// best-effort and must never escape as `ENAMETOOLONG` from a renderer's
// stray gibberish path.
if (hasOverlongPathComponent(absoluteFile)) {
+9 -90
View File
@@ -4,96 +4,7 @@ import * as os from "node:os";
import * as path from "node:path";
import { getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { Snowflake } from "@oh-my-pi/pi-utils/snowflake";
import { getEditorConfigFormatting, getIndentation, setDefaultTabWidth } from "@oh-my-pi/pi-utils/tab-spacing";
describe("spacing", () => {
let tempDir = "";
let previousProjectDir = "";
beforeEach(async () => {
previousProjectDir = getProjectDir();
tempDir = path.join(os.tmpdir(), "pi-utils-spacing", Snowflake.next());
await fs.mkdir(tempDir, { recursive: true });
setProjectDir(tempDir);
setDefaultTabWidth(3);
});
afterEach(async () => {
setDefaultTabWidth(3);
setProjectDir(previousProjectDir);
await fs.rm(tempDir, { recursive: true, force: true });
});
it("resolves editorconfig rules for file path and falls back to default", async () => {
const filePath = path.join(tempDir, "src", "feature.ts");
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(
path.join(tempDir, ".editorconfig"),
["root = true", "", "[*]", "indent_size = 2", "", "[*.md]", "indent_size = 4"].join("\n"),
);
expect(" ".repeat(getIndentation(filePath))).toBe(" ");
expect(" ".repeat(getIndentation(path.join(tempDir, "README.md")))).toBe(" ");
expect(" ".repeat(getIndentation(path.join(tempDir, "missing.txt")))).toBe(" ");
});
it("merges nested editorconfig files from root to leaf", async () => {
const nestedDir = path.join(tempDir, "packages", "feature");
const filePath = path.join(nestedDir, "index.ts");
await fs.mkdir(nestedDir, { recursive: true });
await fs.writeFile(path.join(tempDir, ".editorconfig"), ["root = true", "", "[*]", "indent_size = 2"].join("\n"));
await fs.writeFile(path.join(tempDir, "packages", ".editorconfig"), ["[*.ts]", "indent_size = 6"].join("\n"));
expect(" ".repeat(getIndentation(filePath))).toBe(" ");
});
it("does not throw when the path's segment exceeds NAME_MAX (#1871)", () => {
// A garbage path segment (e.g. 2KiB of garbage Unicode produced by a
// hallucinating model) makes `fs.readFileSync` reject with
// ENAMETOOLONG. The editorconfig probe MUST swallow it and fall back to
// the default tab width — anything else crashes the TUI mid-render.
const huge = "a".repeat(2048);
const phonyPath = path.join(tempDir, huge, "leaf.ts");
expect(() => getIndentation(phonyPath)).not.toThrow();
expect(getIndentation(phonyPath)).toBe(3);
});
it("returns the default tab width for paths with an overlong component (no syscall)", () => {
// Repro of #1872: a malformed edit tool call lands a long gibberish
// string in `file_path`, the renderer routes it through `replaceTabs ->
// getIndentation`, and `readFileSync` of `<dir>/.editorconfig` would
// throw `ENAMETOOLONG`. The path gate must short-circuit before any
// syscall so renderers never see the exception.
const longSegment = "amálpthgadasJennzier".repeat(40);
const overlong = `${longSegment}/inner.ts`;
expect(Buffer.byteLength(longSegment)).toBeGreaterThan(255);
expect(() => getIndentation(overlong)).not.toThrow();
expect(getIndentation(overlong)).toBe(3);
});
it("normalizes paths before rejecting overlong components", async () => {
const longSegment = "amálpthgadasJennzier".repeat(40);
const noisyPath = path.join(tempDir, longSegment, "..", "src", "feature.ts");
await fs.writeFile(path.join(tempDir, ".editorconfig"), ["root = true", "", "[*]", "indent_size = 2"].join("\n"));
expect(Buffer.byteLength(longSegment)).toBeGreaterThan(255);
expect(" ".repeat(getIndentation(noisyPath))).toBe(" ");
});
it("tolerates filesystem errors while walking the editorconfig chain (ENOTDIR)", async () => {
// Defense in depth: when a non-directory sits where a directory is
// expected, `parseCachedEditorConfig` previously caught only `ENOENT`
// and let `ENOTDIR` (and `ENAMETOOLONG`, `EACCES`, `ELOOP`, …)
// escape. Editorconfig discovery is best-effort and must absorb any
// `FsError`.
const notADir = path.join(tempDir, "not-a-dir");
await fs.writeFile(notADir, "");
const fakeChild = path.join(notADir, "inner.ts");
expect(() => getIndentation(fakeChild)).not.toThrow();
expect(getIndentation(fakeChild)).toBe(3);
});
});
import { getEditorConfigFormatting } from "@oh-my-pi/pi-utils/tab-spacing";
describe("getEditorConfigFormatting", () => {
let tempDir = "";
@@ -176,4 +87,12 @@ describe("getEditorConfigFormatting", () => {
expect(() => getEditorConfigFormatting(overlong)).not.toThrow();
expect(getEditorConfigFormatting(overlong)).toEqual({});
});
it("tolerates filesystem errors while walking the editorconfig chain (ENOTDIR)", async () => {
const notADir = path.join(tempDir, "not-a-dir");
await fs.writeFile(notADir, "");
const fakeChild = path.join(notADir, "inner.ts");
expect(() => getEditorConfigFormatting(fakeChild)).not.toThrow();
expect(getEditorConfigFormatting(fakeChild)).toEqual({});
});
});
-1
View File
@@ -136,7 +136,6 @@ const codingAgentSingletonContentMarkers = [
"Settings.instance",
"resetSettingsForTest",
"setAgentDir(",
"setDefaultTabWidth(",
"vi.useFakeTimers(",
"vi.useRealTimers(",
"vi.stubEnv(",