mirror of
https://github.com/zadam/trilium.git
synced 2026-05-06 17:17:37 +02:00
feat(code): normalize indentation
This commit is contained in:
@@ -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");
|
||||
|
||||
98
apps/client/src/widgets/layout/reindentation.spec.ts
Normal file
98
apps/client/src/widgets/layout/reindentation.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
41
apps/client/src/widgets/layout/reindentation.ts
Normal file
41
apps/client/src/widgets/layout/reindentation.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user