Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/remend-escaped-markers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"remend": patch
---

fix(remend): stop appending emphasis closers that match no opener

- Match `_` and `__` delimiters with the CommonMark emphasis algorithm (flanking, rule of 3, partial matches) instead of counting parity, and drop an underscore closer that would not match
- Treat a marker as escaped only after an odd run of backslashes, so `\\*`, `\\_`, `\\**`, `\\~~`, and `` \\` `` still open
- Escape a trailing backslash before appending closers, so it cannot escape them
- Close every open underscore run in one pass, including runs before an unterminated code span, so healed output re-heals to itself
11 changes: 4 additions & 7 deletions packages/remend/__tests__/broken-markdown-variants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,17 +77,14 @@ describe("backslash escapes with incomplete formatting", () => {
expect(result).toBe("\\*not italic");
});

it("should not close double-escaped backslash before asterisk", () => {
// remend sees the char before * as \ (the second backslash) and treats it as escaped
it("should close an asterisk after an escaped backslash", () => {
const result = remend("\\\\*actually italic");
expect(result).toBe("\\\\*actually italic");
expect(result).toBe("\\\\*actually italic*");
});

it("should close escaped double asterisks (remend does not track escape depth)", () => {
// remend doesn't understand that \** has the first * escaped;
// it sees ** and closes bold
it("should not close escaped double asterisks", () => {
const result = remend("\\**not bold");
expect(result).toBe("\\**not bold**");
expect(result).toBe("\\**not bold");
});

it("should handle mixed escaped and real formatting", () => {
Expand Down
91 changes: 91 additions & 0 deletions packages/remend/__tests__/escaped-markers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import remend from "../src";

describe("escaped markers", () => {
it.each<{ name: string; input: string; expected: string }>([
{
name: "closes bold whose content holds an escaped **",
input: "**a\\**b",
expected: "**a\\**b**",
},
{
name: "skips an escaped ~~",
input: "\\~~strike",
expected: "\\~~strike",
},
{
name: "closes ** after an escaped backslash",
input: "\\\\**bold",
expected: "\\\\**bold**",
},
{
name: "closes ~~ after an escaped backslash",
input: "\\\\~~strike",
expected: "\\\\~~strike~~",
},
{
name: "closes * after an escaped backslash",
input: "\\\\*foo",
expected: "\\\\*foo*",
},
{
name: "closes _ after an escaped backslash",
input: "\\\\_foo",
expected: "\\\\_foo_",
},
{
name: "opens a code span after an escaped backslash",
input: "\\\\``a",
expected: "\\\\``a``",
},
])("$name", ({ input, expected }) => {
expect(remend(input)).toBe(expected);
});
});

describe("math", () => {
// Inside math a backslash is TeX, so it does not escape the closing $$
it.each<{ name: string; input: string }>([
{ name: "a backslash before $$", input: "$$a\\$$" },
{ name: "a lone backslash", input: "$$\\$$" },
{ name: "inline block math", input: "text $$\\frac\\$$ more" },
])("leaves complete math with $name unchanged", ({ input }) => {
expect(remend(input)).toBe(input);
});
});

describe("trailing backslash", () => {
// A closer appended after a trailing backslash would be escaped by it, so
// the backslash is escaped first
it.each<{ name: string; input: string; expected: string }>([
{ name: "**", input: "**foo\\", expected: "**foo\\\\**" },
{ name: "*", input: "*foo\\", expected: "*foo\\\\*" },
{ name: "_", input: "_foo\\", expected: "_foo\\\\_" },
{ name: "__", input: "__foo\\", expected: "__foo\\\\__" },
{ name: "~~", input: "~~foo\\", expected: "~~foo\\\\~~" },
{ name: "a partial __ match", input: "____.\\", expected: "____.\\\\__" },
{
name: "a backslash after whitespace",
input: "**foo \\",
expected: "**foo \\\\**",
},
{
name: "nothing to heal",
input: "plain text\\",
expected: "plain text\\",
},
{
name: "an opener whose only content is the backslash",
input: "______\n__\\",
expected: "______\n__\\\\__",
},
{
name: "an escaped trailing backslash",
input: "**foo\\\\",
expected: "**foo\\\\**",
},
{ name: "inline code", input: "`foo\\", expected: "`foo\\`" },
])("$name", ({ input, expected }) => {
expect(remend(input)).toBe(expected);
});
});
3 changes: 1 addition & 2 deletions packages/remend/__tests__/italic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,7 @@ describe("italic formatting with single underscores (_)", () => {
expect(remend("naïve_approach")).toBe("naïve_approach");
});

it("should not count word-internal single underscores in countSingleUnderscores", () => {
// This tests the path where underscore is between word characters (lines 106-108)
it("should not count word-internal single underscores", () => {
expect(remend("some_variable_name")).toBe("some_variable_name");
expect(remend("test_123_value")).toBe("test_123_value");
expect(remend("_start with underscore")).toBe("_start with underscore_");
Expand Down
4 changes: 3 additions & 1 deletion packages/remend/__tests__/lists.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,10 @@ describe("list handling", () => {
expect(remend("* __\n* **")).toBe("* __\n* **");
expect(remend("+ __\n+ **")).toBe("+ __\n+ **");

// A __ followed by whitespace cannot open emphasis
expect(remend("- __ text after")).toBe("- __ text after");

// List items with emphasis markers and text should still complete
expect(remend("- __ text after")).toBe("- __ text after__");
expect(remend("- ** text after")).toBe("- ** text after**");

// Mixed list items
Expand Down
87 changes: 87 additions & 0 deletions packages/remend/__tests__/streaming-properties.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const preservedPrefixLength = (input: string, output: string): number => {
interface CodeNode {
lang?: string | null;
meta?: string | null;
position?: { end: { offset?: number }; start: { offset?: number } };
type: string;
value: string;
}
Expand All @@ -68,6 +69,7 @@ interface MdastNode {
children?: MdastNode[];
lang?: string | null;
meta?: string | null;
position?: { end: { offset?: number }; start: { offset?: number } };
type: string;
value?: string;
}
Expand Down Expand Up @@ -267,6 +269,91 @@ describe("streaming properties", () => {
});
});

const UNDERSCORE_RUN_TOKENS = [
"a",
"word",
" ",
".",
"`",
"\\",
"_",
"__",
"___",
];

const underscoreRunArbitrary = fc
.array(fc.constantFrom(...UNDERSCORE_RUN_TOKENS), {
minLength: 1,
maxLength: 10,
})
.map((tokens) => tokens.join(""));

// Gaps outside this property: a closer appended after whitespace cannot
// close, and remend does not recognize indented code blocks
const TRAILING_WHITESPACE = /\s$/;
const INDENTED_CODE = /^ {4}/;

const MARKER_PATTERN = /[_*]/g;
// Closers, after the backslash that escapes a trailing backslash
const APPENDED_CLOSERS = /^\\?[_*]+$/;

const countLiteralMarkers = (markdown: string): number => {
let count = 0;
const visit = (node: MdastNode): void => {
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (node.type === "text" && start !== undefined && end !== undefined) {
count += markdown.slice(start, end).match(MARKER_PATTERN)?.length ?? 0;
}
for (const child of node.children ?? []) {
visit(child);
}
};
visit(fromMarkdown(markdown));
return count;
};

// Each marker healing appends must close one the prefix left literal, so the
// literal count drops by at least as many as were appended. A closer that
// renders as literal text matched nothing, so remend invented it.
const assertAppendedClosersMatch = (prefix: string): void => {
const healed = remend(prefix);
const suffix = healed.slice(prefix.length);
if (!(healed.startsWith(prefix) && APPENDED_CLOSERS.test(suffix))) {
return;
}
const appended = suffix.match(MARKER_PATTERN)?.length ?? 0;
if (countLiteralMarkers(healed) > countLiteralMarkers(prefix) - appended) {
throw new Error(
`healing appended an unmatched closer: ${JSON.stringify(prefix)} -> ${JSON.stringify(healed)}`
);
}
};

describe("appended closers", () => {
it("never appends an underscore closer that matches no opener", () => {
fc.assert(
fc.property(underscoreRunArbitrary, (prefix) => {
fc.pre(
!(TRAILING_WHITESPACE.test(prefix) || INDENTED_CODE.test(prefix))
);
assertAppendedClosersMatch(prefix);
}),
{ numRuns: Number(process.env.NUM_RUNS ?? 2000) }
);
});

it("re-heals underscore runs to themselves", () => {
fc.assert(
fc.property(underscoreRunArbitrary, (prefix) => {
const healed = remend(prefix);
expect(remend(healed)).toBe(healed);
}),
{ numRuns: Number(process.env.NUM_RUNS ?? 2000) }
);
});
});

describe("exhaustive prefix sweep", () => {
// Every prefix of a fixed corpus, deterministically. The corpus mixes
// constructs that interact.
Expand Down
46 changes: 46 additions & 0 deletions packages/remend/__tests__/underscore-runs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,50 @@ describe("escaped underscores", () => {
it("should treat the run after an escaped underscore as a delimiter", () => {
expect(remend("\\___bold")).toBe("\\___bold__");
});

it("should not treat a run after an escaped backslash as escaped", () => {
expect(remend("\\\\__bold")).toBe("\\\\__bold__");
});
});

describe("underscore delimiter matching", () => {
it.each<[string, string]>([
["a run followed by whitespace", "__ foo"],
["a spaced run before an escaped run", "a __ b \\__c"],
["a spaced run before an escaped run at the start", "__ \\__alph"],
["a run after a letter and before punctuation", "a__."],
["a closer-only run with nothing open", "__ a__ a"],
["a longer run that closes the opener", "__a____.a"],
["an opener the rule of 3 keeps a __ closer from matching", "\\_____\\a"],
["a leftover single underscore after an escape", "__\\__ a"],
])("should not append a closer for %s", (_name, input) => {
expect(remend(input)).toBe(input);
});

it("should still close an opener after a spaced run", () => {
expect(remend("a __ b __bold")).toBe("a __ b __bold__");
});

it.each<{ name: string; input: string; expected: string }>([
{
name: "an outer run after an escape",
input: "\\__._a",
expected: "\\__._a__",
},
{ name: "a run across an asterisk", input: "_a *_a", expected: "_a *_a*_" },
{
name: "a run after an open code span",
input: "__`x",
expected: "__`x`__",
},
{
name: "a run after an open code span ending in a backslash",
input: "__`x\\",
expected: "__`x\\`__",
},
])("should close $name in one pass", ({ input, expected }) => {
const healed = remend(input);
expect(healed).toBe(expected);
expect(remend(healed)).toBe(healed);
});
});
Loading
Loading