feat(code): normalize indentation

This commit is contained in:
Elian Doran
2026-04-15 17:34:30 +03:00
parent f68a481edc
commit edb2ec2a6f
3 changed files with 140 additions and 31 deletions

View File

@@ -33,6 +33,7 @@ import SimilarNotesTab from "../ribbon/SimilarNotesTab";
import { useAttachments } from "../type_widgets/Attachment";
import { useProcessedLocales } from "../type_widgets/options/components/LocaleSelector";
import Breadcrumb from "./Breadcrumb";
import { convertIndentation } from "./reindentation";
interface StatusBarContext {
note: FNote;
@@ -440,37 +441,6 @@ function NotePaths({ note, hoistedNoteId, notePath }: StatusBarContext) {
//#region Tab width switcher
const TAB_WIDTH_OPTIONS = [1, 2, 3, 4, 6, 8] as const;
/**
* Converts the leading indentation on each line to a new style. Non-leading whitespace is preserved.
*
* - "spaces" source means leading runs of spaces are grouped by `fromWidth` to compute the indent level.
* - "tabs" source means each leading tab counts as one indent level (leading spaces are preserved as alignment).
*/
function convertIndentation(
content: string,
from: { useTabs: boolean; width: number },
to: { useTabs: boolean; width: number }
): string {
if (from.useTabs === to.useTabs && from.width === to.width) return content;
const toUnit = to.useTabs ? "\t" : " ".repeat(to.width);
return content.replace(/^[ \t]+/gm, (leading) => {
let levels: number;
let remainder = "";
if (from.useTabs) {
const match = leading.match(/^(\t*)(.*)$/s)!;
levels = match[1].length;
remainder = match[2];
} else {
const spaces = leading.length;
levels = from.width > 0 ? Math.round(spaces / from.width) : 0;
const aligned = levels * from.width;
remainder = spaces > aligned ? " ".repeat(spaces - aligned) : "";
}
return toUnit.repeat(levels) + remainder;
});
}
function TabWidthSwitcher({ note, noteContext }: StatusBarContext) {
const [ globalTabWidth ] = useTriliumOptionInt("codeNoteTabWidth");
const [ globalUseTabs ] = useTriliumOptionBool("codeNoteIndentWithTabs");

View File

@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import { convertIndentation } from "./reindentation";
describe("convertIndentation", () => {
it("returns content unchanged when source and target match", () => {
const content = " const x = 1;\n const y = 2;\n";
expect(convertIndentation(content, { useTabs: false, width: 4 }, { useTabs: false, width: 4 })).toBe(content);
});
it("returns content unchanged for zero or negative widths", () => {
const content = " x\n";
expect(convertIndentation(content, { useTabs: false, width: 0 }, { useTabs: false, width: 4 })).toBe(content);
expect(convertIndentation(content, { useTabs: false, width: 4 }, { useTabs: false, width: 0 })).toBe(content);
});
it("converts spaces to a narrower width", () => {
const input = " a\n b\n c\n";
const expected = " a\n b\n c\n";
expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: false, width: 2 })).toBe(expected);
});
it("converts spaces to a wider width", () => {
const input = " a\n b\n";
const expected = " a\n b\n";
expect(convertIndentation(input, { useTabs: false, width: 2 }, { useTabs: false, width: 4 })).toBe(expected);
});
it("converts spaces to tabs", () => {
const input = " a\n b\n";
const expected = "\ta\n\t\tb\n";
expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: true, width: 4 })).toBe(expected);
});
it("converts tabs to spaces", () => {
const input = "\ta\n\t\tb\n";
const expected = " a\n b\n";
expect(convertIndentation(input, { useTabs: true, width: 4 }, { useTabs: false, width: 4 })).toBe(expected);
});
it("converts tabs to a different tab display width", () => {
// When both source and target are tabs, the content doesn't change (tab count is preserved)
// regardless of visual tab width.
const input = "\ta\n\t\tb\n";
expect(convertIndentation(input, { useTabs: true, width: 4 }, { useTabs: true, width: 2 })).toBe(input);
});
it("handles mixed tabs and spaces on the same line (tab then spaces)", () => {
// One tab (→ col 4) + 2 spaces → 6 columns → 1 level + 2 remainder spaces at width=4.
// Target spaces width=4: 1 level (4 spaces) + 2 remainder = 6 spaces.
const input = "\t statement;\n";
expect(convertIndentation(input, { useTabs: true, width: 4 }, { useTabs: false, width: 4 })).toBe(" statement;\n");
});
it("preserves alignment remainder when converting spaces to tabs", () => {
// 6 spaces at width=4 → 1 level + 2 remainder spaces.
const input = " alignedText\n";
const expected = "\t alignedText\n";
expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: true, width: 4 })).toBe(expected);
});
it("preserves alignment remainder when converting spaces to narrower spaces", () => {
// 5 spaces at width=4 → 1 level + 1 remainder → 1 level at width 2 = 2 spaces + 1 remainder = 3 spaces.
const input = " x\n";
expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: false, width: 2 })).toBe(" x\n");
});
it("handles interleaved spaces and tabs (space, then tab)", () => {
// " \t" at tabWidth=4: 2 cols (spaces), then tab advances to next multiple of 4 = col 4.
// Total 4 cols = 1 level.
const input = " \tstmt\n";
expect(convertIndentation(input, { useTabs: true, width: 4 }, { useTabs: false, width: 4 })).toBe(" stmt\n");
});
it("does not touch non-leading whitespace", () => {
const input = "const a = \" \\t \";\n if (x)\n";
const expected = "const a = \" \\t \";\n if (x)\n";
expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: false, width: 2 })).toBe(expected);
});
it("leaves blank lines alone", () => {
const input = " a\n\n b\n";
const expected = " a\n\n b\n";
expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: false, width: 2 })).toBe(expected);
});
it("handles mixed indentation styles across lines", () => {
// Line 1 uses tabs, line 2 uses spaces.
const input = "\t\tnested\n alsoNested\n";
// At from=tabs,width=4: line 1 has 8 cols → 2 levels. Line 2 has 8 spaces → 8 cols → 2 levels.
// Target = spaces width 2 → both lines become " " (4 spaces).
expect(convertIndentation(input, { useTabs: true, width: 4 }, { useTabs: false, width: 2 })).toBe(" nested\n alsoNested\n");
});
it("preserves content without leading whitespace", () => {
const input = "no indent\nalso no indent\n";
expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: true, width: 4 })).toBe(input);
});
});

View File

@@ -0,0 +1,41 @@
export interface IndentStyle {
useTabs: boolean;
width: number;
}
/**
* Computes the visual column span of a leading-whitespace run. Tabs advance to the next
* multiple of `tabWidth`; spaces advance by 1.
*/
function measureLeadingColumns(leading: string, tabWidth: number): number {
let cols = 0;
for (const ch of leading) {
if (ch === "\t") {
cols += tabWidth - (cols % tabWidth);
} else {
cols += 1;
}
}
return cols;
}
/**
* Rewrites the leading whitespace on every line, converting it from the `from` style to the `to`
* style. Non-leading whitespace is preserved.
*
* Handles lines with mixed tabs and spaces in the leading whitespace by measuring the total visual
* column span (using `from.width` as the tab stop) and then dividing into `to.width`-sized levels.
* Any leftover columns that don't fit a whole level are emitted as spaces (alignment preserved).
*/
export function convertIndentation(content: string, from: IndentStyle, to: IndentStyle): string {
if (from.useTabs === to.useTabs && from.width === to.width) return content;
if (from.width <= 0 || to.width <= 0) return content;
const toUnit = to.useTabs ? "\t" : " ".repeat(to.width);
return content.replace(/^[ \t]+/gm, (leading) => {
const cols = measureLeadingColumns(leading, from.width);
const levels = Math.floor(cols / from.width);
const remainder = cols % from.width;
return toUnit.repeat(levels) + " ".repeat(remainder);
});
}