Skip to content

fix(text): preserve indent inside substituted values in dedent (#6830)#7160

Open
LeSingh1 wants to merge 1 commit into
denoland:mainfrom
LeSingh1:fix/6830-dedent-preserve-substitution-indent
Open

fix(text): preserve indent inside substituted values in dedent (#6830)#7160
LeSingh1 wants to merge 1 commit into
denoland:mainfrom
LeSingh1:fix/6830-dedent-preserve-substitution-indent

Conversation

@LeSingh1

Copy link
Copy Markdown
Contributor

Fixes #6830 (and the residual cases from #6665).

Problem

dedent computed the common indent using the template joined with a single-char \"x\" placeholder, then stripped that indent from the fully substituted input string. When a multi-line substitution's lines happened to start with whitespace matching the outer template's computed indent, those leading spaces were stripped too — silently breaking the substituted value.

Reproduction from the issue: inner dedents correctly to \" [\\n ...\\n...\\n ]\". Interpolating it back into an outer dedent with 6-space outer indent eats the 6 leading spaces from inner's \" ...\" line, rendering it as \"...\". V1 and V2 in the issue body diverge even though they should be identical.

Fix

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.

Concretely, instead of building the full substituted string and running replaceAll(^indent/gmu, \"\") on it, the new code runs replaceAll(\\n${indent}/gu, \"\\n\") (plus a once-only ^${indent} for the very first part) on each input.raw[i] and then concatenates the substitution value after each processed part.

Tests

Two new cases in text/unstable_dedent_test.ts:

  • preserves indentation inside multi-line substitution values (#6830) — the exact V1/V2 reproducer from the issue. Both must equal \a\n b\n${inner}``.
  • does not strip indent that originated inside a substituted value — narrower regression: ${\" leading-four-spaces\"} inside a 6-space outer template must keep its 4 leading spaces.
$ deno test --allow-read text/unstable_dedent_test.ts
ok | 13 passed (84 steps) | 0 failed (19ms)

$ deno fmt text/unstable_dedent.ts text/unstable_dedent_test.ts
Checked 2 files

$ deno lint text/unstable_dedent.ts text/unstable_dedent_test.ts && deno check text/unstable_dedent.ts text/unstable_dedent_test.ts
Checked 2 files
Check text/unstable_dedent.ts
Check text/unstable_dedent_test.ts

…and#6830)

`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 denoland#6830 (and denoland#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.
@github-actions github-actions Bot added the text label May 26, 2026
@bartlomieju

Copy link
Copy Markdown
Member

Reviewed and ran the code locally on the branch. The per-part restructuring is the right idea for #6830, and the new tests pass — but there's a blocking correctness regression: switching from the cooked template strings to input.raw means escape sequences are no longer interpreted.

🔴 Blocking: input.raw emits raw escapes instead of cooked characters

The old code built the string from the cooked strings (String.raw({ raw: input }, ...values) passes the cooked array input as the raw property). The new loop iterates input.raw[i], which is the raw form where \t, \n, \\, \u…, etc. are left as literal backslash sequences.

Verified on the branch vs main:

dedent`a\tb${"X"}c`
// main (cooked):  "a\tbXc"  -> [97, 9, 98, 88, 99]   (real TAB)
// PR   (raw):     "a\\tbXc" -> [97, 92, 116, 98, 88, 99] (literal backslash-t)

dedent`x\ny`
// main: "x\ny" (len 3, real newline)
// PR:   "x\\ny" (len 4, literal backslash-n)

Any dedent template containing an escape sequence (very common — \n, \t, \", unicode escapes) now produces wrong output. The existing tests don't catch this because none of them use escape sequences.

Fix: iterate the cooked strings, i.e. input[i] / input.length instead of input.raw[i] / input.raw.length. I patched this locally — the escape cases go back to cooked output and all 13 tests still pass. Please also add a regression test, e.g. assertEquals(dedent\a\tb`, "a\tb")` with a real tab expected.

🟡 Secondary: indent on a literal line whose newline came from inside a value

Because stripping is now \n${indent} per literal part, template indentation on a line is only stripped when the preceding newline lives in the same literal part. When the newline originates inside a multi-line substitution, the following literal line keeps its template indent:

const x = "line1\n";
dedent`
  ${x}  line2
`
// main: "line1\nline2"      (template indent stripped)
// PR:   "line1\n  line2"    (2-space template indent survives)

This is inherent to the per-part approach and is a much narrower case than what #6830 fixes, so it may be an acceptable trade-off — but worth a comment in the code and ideally a test documenting the chosen behavior.

🟢 Minor

  • String(values[i]) differs slightly from template coercion for symbol (template throws, String() doesn't). Harmless, just noting.
  • The whitespace-only-line replacement now runs over the assembled string including substituted values, so a spaces-only line inside a value would be blanked. Edge case; matches prior intent, fine to leave.

Nice structure and good issue write-up overall — just the raw vs cooked fix is required before this can land.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

text/unstable-dedent still gives poor results when interpolating multi-line text into tagged template in certain edge cases

2 participants