From 95c4f791331db51e40524237814344ef976fa8ac Mon Sep 17 00:00:00 2001 From: Shaurya Singh Date: Tue, 26 May 2026 15:03:20 -0700 Subject: [PATCH] fix(text): preserve indent inside substituted values in dedent (#6830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dedent` computed the common indent from the joined template (with a single-char placeholder for each substitution) and then stripped that indent from the FULLY substituted string. When a multi-line substitution's lines happened to start with whitespace matching the outer template's indent, those leading spaces were stripped too — breaking the substituted value. Reproduction from #6830 (and #6665 partially): `inner` is dedented correctly, but interpolating it back into an outer dedent with 6-space outer indent eats the 6 spaces from `inner`'s ` ...` line, turning the rendered substitution into `...`. Restructure the tagged-template branch to apply indent stripping per literal template part (the entries in `input.raw`), then interleave substitution values verbatim. The first literal part also gets a start-of-template strip for templates that begin with content (no leading newline). String-input mode is unchanged. Two new tests cover the issue's exact V1/V2 reproducer and a narrower regression where a substituted value begins with whitespace that matches the outer indent. Full dedent suite (13 tests / 84 steps) stays green. --- text/unstable_dedent.ts | 62 ++++++++++++++++++++++++++---------- text/unstable_dedent_test.ts | 45 ++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 16 deletions(-) diff --git a/text/unstable_dedent.ts b/text/unstable_dedent.ts index 4e3fa0f91688..fcd78cfea155 100644 --- a/text/unstable_dedent.ts +++ b/text/unstable_dedent.ts @@ -90,22 +90,52 @@ export function dedent( const commonPrefix = longestCommonPrefix(linesToCheck); const indent = commonPrefix.match(INDENT_REGEXP)?.[0]; - const inputString = typeof input === "string" - ? input - : String.raw({ raw: input }, ...values); - const trimmedInput = inputString.replace(/^\n/, "").replace(/\n[\t ]*$/, ""); + const whitespaceOnlyLineRegex = new RegExp( + WHITE_SPACE_ONLY_LINE_REGEXP, + WHITE_SPACE_ONLY_LINE_REGEXP.flags + "g", + ); - // No lines to indent - if (!indent) return trimmedInput; + if (typeof input === "string") { + const trimmedInput = input.replace(/^\n/, "").replace(/\n[\t ]*$/, ""); + if (!indent) return trimmedInput; + const minIndentRegex = new RegExp(String.raw`^${indent}`, "gmu"); + return trimmedInput + .replaceAll(minIndentRegex, "") + .replaceAll(whitespaceOnlyLineRegex, ""); + } + + // #6830: previously the indent regex was applied to the fully substituted + // input string, which also stripped leading whitespace from substitution + // values whenever that whitespace happened to match the outer template's + // computed indent. Apply indent stripping only to the literal template + // parts so a multi-line substituted value keeps its own indentation + // verbatim. + const stripIndentAfterNewlineRegex = indent + ? new RegExp(String.raw`\n${indent}`, "gu") + : null; + const stripIndentAtStartRegex = indent + ? new RegExp(String.raw`^${indent}`, "u") + : null; - const minIndentRegex = new RegExp(String.raw`^${indent}`, "gmu"); - return trimmedInput - .replaceAll(minIndentRegex, "") - .replaceAll( - new RegExp( - WHITE_SPACE_ONLY_LINE_REGEXP, - WHITE_SPACE_ONLY_LINE_REGEXP.flags + "g", - ), - "", - ); + let assembled = ""; + for (let i = 0; i < input.raw.length; i++) { + let part = input.raw[i] ?? ""; + if (stripIndentAfterNewlineRegex) { + part = part.replaceAll(stripIndentAfterNewlineRegex, "\n"); + } + // The very first literal char is at the start of the template (no + // preceding newline), so leading-indent there is not caught by the + // `\n${indent}` rule above. + if (i === 0 && stripIndentAtStartRegex) { + part = part.replace(stripIndentAtStartRegex, ""); + } + assembled += part; + if (i < values.length) { + assembled += String(values[i]); + } + } + + const trimmedInput = assembled.replace(/^\n/, "").replace(/\n[\t ]*$/, ""); + if (!indent) return trimmedInput; + return trimmedInput.replaceAll(whitespaceOnlyLineRegex, ""); } diff --git a/text/unstable_dedent_test.ts b/text/unstable_dedent_test.ts index 6569f040c2c2..20639cc2fe92 100644 --- a/text/unstable_dedent_test.ts +++ b/text/unstable_dedent_test.ts @@ -3,6 +3,51 @@ import { dedent } from "./unstable_dedent.ts"; import { assertEquals } from "@std/assert"; import { stub } from "@std/testing/mock"; +Deno.test("dedent() preserves indentation inside multi-line substitution values (#6830)", () => { + // The substituted `inner` has lines with 4 and 6 spaces of leading + // whitespace. The outer template's computed indent is 6 spaces. + // Previously the regex stripped 6 spaces from every line including the + // substituted content's " ...", turning it into "...". The fix + // applies indent stripping only to literal template parts so the + // substitution survives unchanged. + const inner = dedent` + [ + ... + ... + ] + `; + assertEquals(inner, " [\n ...\n...\n ]"); + + const outerV1 = dedent` + a + b + ${inner} + `; + assertEquals(outerV1, `a\n b\n${inner}`); + + // V2 has a wider outer indent (8 spaces vs 6) but the same substitution. + // Should produce the identical output — V1 and V2 were diverging before + // the fix. + const outerV2 = dedent` + a + b + ${inner} + `; + assertEquals(outerV2, `a\n b\n${inner}`); +}); + +Deno.test("dedent() does not strip indent that originated inside a substituted value", () => { + const sub = " leading-four-spaces"; + const result = dedent` + prefix + ${sub} + `; + // The outer indent is 6 spaces. The substituted line starts with 4 + // spaces. Those 4 spaces are part of `sub`, not part of the literal + // template's indent, so they must survive. + assertEquals(result, `prefix\n${sub}`); +}); + Deno.test("dedent() handles example 1", () => { assertEquals( dedent(`