Skip to content

Commit 80f7aee

Browse files
authored
fix(text/unstable): dedent() correct blank line handling (#6738)
1 parent 2ff7074 commit 80f7aee

File tree

2 files changed

+123
-21
lines changed

2 files changed

+123
-21
lines changed

text/unstable_dedent.ts

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
// Copyright 2018-2025 the Deno authors. MIT license.
22
// This module is browser compatible.
3+
import { longestCommonPrefix } from "./unstable_longest_common_prefix.ts";
4+
5+
const WHITE_SPACE = String.raw`\t\v\f\ufeff\p{Space_Separator}`;
6+
const INDENT_REGEXP = new RegExp(
7+
String.raw`^[${WHITE_SPACE}]+`,
8+
"u",
9+
);
10+
const WHITE_SPACE_ONLY_LINE_REGEXP = new RegExp(
11+
String.raw`^[${WHITE_SPACE}]+$`,
12+
"mu",
13+
);
314

415
/**
516
* Removes indentation from multiline strings.
@@ -68,36 +79,30 @@ export function dedent(
6879
const trimmedTemplate = joinedTemplate.replace(/^\n/, "").trimEnd();
6980
const lines = trimmedTemplate.split("\n");
7081

71-
let minIndentWidth: number | undefined = undefined;
72-
for (let i = 0; i < lines.length; i++) {
73-
const indentMatch = lines[i]!.match(/^(\s*)\S/);
74-
75-
// Skip empty lines
76-
if (indentMatch === null) {
77-
continue;
78-
}
82+
const linesToCheck = lines.slice(
83+
ignoreFirstUnindented && !INDENT_REGEXP.test(lines[0] ?? "") ? 1 : 0,
84+
)
85+
.filter((l) => l.length > 0 && !WHITE_SPACE_ONLY_LINE_REGEXP.test(l));
7986

80-
const indentWidth = indentMatch[1]!.length;
81-
if (ignoreFirstUnindented && i === 0 && indentWidth === 0) {
82-
continue;
83-
}
84-
if (minIndentWidth === undefined || indentWidth < minIndentWidth) {
85-
minIndentWidth = indentWidth;
86-
}
87-
}
87+
const commonPrefix = longestCommonPrefix(linesToCheck);
88+
const indent = commonPrefix.match(INDENT_REGEXP)?.[0];
8889

8990
const inputString = typeof input === "string"
9091
? input
9192
: String.raw({ raw: input }, ...values);
9293
const trimmedInput = inputString.replace(/^\n/, "").trimEnd();
9394

9495
// No lines to indent
95-
if (minIndentWidth === undefined || minIndentWidth === 0) {
96-
return trimmedInput;
97-
}
96+
if (!indent) return trimmedInput;
9897

99-
const minIndentRegex = new RegExp(`^\\s{${minIndentWidth}}`, "gm");
98+
const minIndentRegex = new RegExp(String.raw`^${indent}`, "gmu");
10099
return trimmedInput
101100
.replaceAll(minIndentRegex, "")
102-
.replaceAll(/^\s+$/gm, "");
101+
.replaceAll(
102+
new RegExp(
103+
WHITE_SPACE_ONLY_LINE_REGEXP,
104+
WHITE_SPACE_ONLY_LINE_REGEXP.flags + "g",
105+
),
106+
"",
107+
);
103108
}

text/unstable_dedent_test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright 2018-2025 the Deno authors. MIT license.
22
import { dedent } from "./unstable_dedent.ts";
33
import { assertEquals } from "@std/assert";
4+
import { stub } from "@std/testing/mock";
45

56
Deno.test("dedent() handles example 1", () => {
67
assertEquals(
@@ -78,3 +79,99 @@ Deno.test("dedent() handles multiline substitution", () => {
7879
`;
7980
assertEquals(outer, "1\n2\n3\n4");
8081
});
82+
83+
Deno.test("dedent() handles mixed tabs and spaces", async (t) => {
84+
// @ts-ignore augmenting globalThis so we don't need to resort to bare `eval`
85+
using _ = stub(globalThis, "dedent", dedent);
86+
87+
await t.step("with partial common prefix", () => {
88+
assertEquals(
89+
globalThis.eval(`dedent\`\n a\n \tb\n\``),
90+
" a\n\tb",
91+
);
92+
});
93+
94+
await t.step("with no common prefix", () => {
95+
assertEquals(
96+
globalThis.eval(`dedent\`\n\t a\n \tb\n\``),
97+
"\t a\n \tb",
98+
);
99+
});
100+
});
101+
102+
Deno.test("dedent() handles blank lines correctly", async (t) => {
103+
// @ts-ignore augmenting globalThis so we don't need to resort to bare `eval`
104+
using _ = stub(globalThis, "dedent", dedent);
105+
106+
for (const lineEnding of ["\n", "\r\n"]) {
107+
// CRLF actually doesn't change the output, as literal CRLFs in template literals in JS files are read as `\n`
108+
// (this behavior is in the JS spec, not library behavior).
109+
await t.step(
110+
`${lineEnding === "\n" ? "LF" : "CRLF"} line ending`,
111+
async (t) => {
112+
for (const space of [" ", "\t"]) {
113+
const spaceName = space === " " ? "space" : "tab";
114+
await t.step(`${spaceName}s`, async (t) => {
115+
for (const indent of [0, 1, 2]) {
116+
for (const between of [0, 1, 2]) {
117+
// these cases won't fully dedent, which is probably (??) fine
118+
if (indent === 0 && between !== 0) continue;
119+
120+
const testName =
121+
`${indent}-${spaceName} indent with ${between} ${spaceName}s between`;
122+
123+
await t.step(testName, () => {
124+
const source = [
125+
"",
126+
`${space.repeat(indent)}a`,
127+
space.repeat(between),
128+
`${space.repeat(indent)}b`,
129+
"",
130+
].join(lineEnding);
131+
132+
const result = globalThis.eval(`dedent\`${source}\``);
133+
assertEquals(result, "a\n\nb");
134+
});
135+
136+
// these cases will strip the first-line/last-line indents, which is probably (??) fine
137+
if (indent === 0) continue;
138+
139+
await t.step(
140+
`${testName} preserves added first-line indent`,
141+
() => {
142+
const source = [
143+
"",
144+
`${space.repeat(indent + 1)}a`,
145+
space.repeat(between),
146+
`${space.repeat(indent)}b`,
147+
"",
148+
].join(lineEnding);
149+
150+
const result = globalThis.eval(`dedent\`${source}\``);
151+
assertEquals(result, `${space}a\n\nb`);
152+
},
153+
);
154+
155+
await t.step(
156+
`${testName} preserves added last-line indent`,
157+
() => {
158+
const source = [
159+
"",
160+
`${space.repeat(indent)}a`,
161+
space.repeat(between),
162+
`${space.repeat(indent + 1)}b`,
163+
"",
164+
].join(lineEnding);
165+
166+
const result = globalThis.eval(`dedent\`${source}\``);
167+
assertEquals(result, `a\n\n${space}b`);
168+
},
169+
);
170+
}
171+
}
172+
});
173+
}
174+
},
175+
);
176+
}
177+
});

0 commit comments

Comments
 (0)