Skip to content
Open
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
11 changes: 11 additions & 0 deletions .changeset/spotty-eyes-brake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"remend": minor
---

Rework code-region detection and double-underscore counting.

A shared single-pass scanner now classifies fences and inline code spans, replacing the per-character rescans that made healing quadratic on delimiter-heavy input. Fence and span detection follows CommonMark, so `~~~` fences, list-indented fences, CRLF line endings, and multi-backtick spans are all recognized, and content inside code is never healed as prose.

Double underscores are counted per maximal run with flanking rules, so identifiers containing `__` (like `snake__case`) no longer invent or swallow emphasis closers.

Healing is now idempotent. Healed output re-heals to itself, including incomplete image removal and the trailing space it exposes.
22 changes: 22 additions & 0 deletions packages/remend/__benchmarks__/remend.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,3 +279,25 @@ ${"Regular paragraph text with some [links](https://example.com) and more conten
{ iterations: 1000 }
);
});

// Delimiter-heavy input at doubling sizes. Cost should grow in proportion to
// input size: a doubling that more than doubles the time signals a
// superlinear rescan in a handler.
describe("Scaling", () => {
const unit =
"word snake__case text __bold__ and _it_ plus `code` *star* ~~del~~ ".repeat(
30
);
const sizes = [1, 2, 4, 8] as const;

for (const mult of sizes) {
const doc = `${unit.repeat(mult)}__open`;
bench(
`delimiter-heavy ${doc.length} chars`,
() => {
remend(doc);
},
{ iterations: 200 }
);
}
});
12 changes: 6 additions & 6 deletions packages/remend/__tests__/broken-markdown-variants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,9 @@ describe("multiple incomplete links", () => {
});

it("should handle two incomplete links in text-only mode", () => {
// Fixed-point healing resolves both unmatched brackets in one call
const result = remend("[link1 and [link2", { linkMode: "text-only" });
expect(result).toBe("link1 and [link2");
expect(result).toBe("link1 and link2");
});
});

Expand Down Expand Up @@ -586,11 +587,10 @@ describe("real-world AI streaming patterns", () => {
);
});

it("should handle incomplete image with partial URL (preserves trailing space)", () => {
// Image is removed, leaving "See " - the trailing space remains
// because remend only trims single trailing space at the very start
// before handlers run, and the handler produces new trailing space
expect(remend("See ![diagram](http://example.com/img")).toBe("See ");
it("should handle incomplete image with partial URL", () => {
// The space exposed by the removal is stripped like an input trailing
// space, so healed output re-heals to itself
expect(remend("See ![diagram](http://example.com/img")).toBe("See");
});

it("should handle link with incomplete formatting after it", () => {
Expand Down
14 changes: 9 additions & 5 deletions packages/remend/__tests__/coverage-gaps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,10 @@ describe("link handler edge cases", () => {
expect(remend("](partial")).toBe("](partial");
});

it("should skip image brackets in text-only mode", () => {
expect(remend("![img [text", { linkMode: "text-only" })).toBe("![img text");
it("should remove incomplete images in text-only mode", () => {
// Stripping the inner bracket exposes an incomplete image, which is
// removed like any other
expect(remend("![img [text", { linkMode: "text-only" })).toBe("");
});

it("should skip complete links in text-only mode", () => {
Expand Down Expand Up @@ -196,9 +198,11 @@ describe("double underscore half-complete in code block", () => {
});
});

describe("double underscore half-complete with even pairs", () => {
it("should not complete when __ pairs are balanced", () => {
expect(remend("__a__ __b__content_")).toBe("__a__ __b__content_");
describe("double underscore half-complete with word-internal run", () => {
it("should complete the opener left unmatched by a word-internal run", () => {
// b__content is word-internal, so the __ before b is an unmatched
// opener and the trailing _ is its half-typed closer
expect(remend("__a__ __b__content_")).toBe("__a__ __b__content__");
});
});

Expand Down
116 changes: 116 additions & 0 deletions packages/remend/__tests__/fence-semantics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import remend from "../src";

describe("tilde fences", () => {
it("should not heal emphasis inside a complete tilde fence", () => {
expect(remend("~~~\ncode with __stuff\n~~~\ndone")).toBe(
"~~~\ncode with __stuff\n~~~\ndone"
);
});

it("should not heal emphasis inside an open tilde fence", () => {
expect(remend("~~~js\nx = a__b")).toBe("~~~js\nx = a__b");
});

it("should heal strikethrough after a complete tilde fence", () => {
expect(remend("~~~\ncode\n~~~\nafter ~~open")).toBe(
"~~~\ncode\n~~~\nafter ~~open~~"
);
});

it("should treat a mid-line tilde run as strikethrough context, not a fence", () => {
expect(remend("prose ~~struck~~ more prose __bold")).toBe(
"prose ~~struck~~ more prose __bold__"
);
});
});

describe("fence opener position", () => {
it("should recognize a fence indented up to three spaces", () => {
expect(remend(" ```\n__code\n ```\n__open")).toBe(
" ```\n__code\n ```\n__open__"
);
});

it("should treat mid-line triple backticks as inline code", () => {
// A fence can only open at the start of a line, so a mid-line run is an
// inline code span and heals by completing its closing run
expect(remend("see ```inline code``")).toBe("see ```inline code```");
});
});

describe("fence closer length", () => {
it("should not close a fence with a shorter run", () => {
// The ``` run is shorter than the ```` opener, so the fence is still
// open and its content is not healed
expect(remend("````\ncode\n```\nstill __code")).toBe(
"````\ncode\n```\nstill __code"
);
});

it("should close a fence with a longer run", () => {
expect(remend("```\ncode\n````\nafter __bold")).toBe(
"```\ncode\n````\nafter __bold__"
);
});
});

describe("fence info strings", () => {
it("should not heal emphasis in an info string", () => {
expect(remend("```python__hint\ncode")).toBe("```python__hint\ncode");
});
});

describe("inline code span run lengths", () => {
it("should complete a double-backtick span with a double run", () => {
expect(remend("``code`")).toBe("``code``");
});

it("should complete only the missing part of the closing run", () => {
expect(remend("``code")).toBe("``code``");
});

it("should leave a longer literal run inside an open span alone", () => {
// The trailing run is longer than the opener, so appending backticks
// could never close the span
expect(remend("`a``")).toBe("`a``");
});
});

describe("list-indented fences", () => {
it("should recognize a fence indented inside a list item", () => {
expect(remend("1. Install:\n ```bash\n npm install foo")).toBe(
"1. Install:\n ```bash\n npm install foo"
);
});

it("should not heal emphasis inside a list-indented fence", () => {
expect(remend("- step\n - nested\n ```js\n const x = a__b")).toBe(
"- step\n - nested\n ```js\n const x = a__b"
);
});
});

describe("CRLF line endings", () => {
it("should recognize a fence opener on a CRLF line", () => {
expect(remend("```js\r\nconst a = 1")).toBe("```js\r\nconst a = 1");
});

it("should close a CRLF fence and heal after it", () => {
expect(remend("```\r\ncode\r\n```\r\n__open")).toBe(
"```\r\ncode\r\n```\r\n__open__"
);
});
});

describe("spans across paragraphs", () => {
it("should leave an unmatched run literal once its paragraph ends", () => {
expect(remend("use ``` to open a block\n\nmore **bold streaming")).toBe(
"use ``` to open a block\n\nmore **bold streaming**"
);
});

it("should still complete an open span in the last paragraph", () => {
expect(remend("intro\n\nrun `npm i")).toBe("intro\n\nrun `npm i`");
});
});
14 changes: 10 additions & 4 deletions packages/remend/__tests__/images.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import remend from "../src";

describe("image handling", () => {
it("should remove incomplete images", () => {
expect(remend("Text with ![incomplete image")).toBe("Text with ");
// The space exposed by the removal is stripped like an input trailing space
expect(remend("Text with ![incomplete image")).toBe("Text with");
expect(remend("![partial")).toBe("");
});

Expand All @@ -12,18 +13,23 @@ describe("image handling", () => {
expect(remend(text)).toBe(text);
});

it("should preserve a hard-break double space before a removed image", () => {
// Only a single exposed space is stripped. A double space is a markdown hard break and survives.
expect(remend("line one ![partial")).toBe("line one ");
});

it("should handle partial image at chunk boundary", () => {
expect(remend("See ![the diag")).toBe("See ");
expect(remend("See ![the diag")).toBe("See");
// Images with partial URLs should be removed (images can't show skeleton)
expect(remend("![logo](./assets/log")).toBe("");
});

it("should handle nested brackets in incomplete images", () => {
// When findMatchingClosingBracket returns -1 for an image (lines 74-79)
// For this to happen, we need an opening bracket with a ] but no proper matching
expect(remend("Text ![outer [inner]")).toBe("Text ");
expect(remend("Text ![outer [inner]")).toBe("Text");
expect(remend("![nested [brackets] text")).toBe("");
expect(remend("Start ![foo [bar] baz")).toBe("Start ");
expect(remend("Start ![foo [bar] baz")).toBe("Start");
});

it("should not add trailing underscore for images with underscores in URL (#284)", () => {
Expand Down
12 changes: 12 additions & 0 deletions packages/remend/__tests__/katex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,3 +286,15 @@ describe("math blocks with asterisks", () => {
expect(remend(text)).toBe("Start *italic with $$x^{*}$$*");
});
});

describe("dollar signs inside code", () => {
it("should not let a $ in inline code suppress later healing", () => {
expect(remend("`$` _hello")).toBe("`$` _hello_");
});

it("should not let a $ in a fence suppress later healing", () => {
expect(remend("```\nprice = $5\n```\n_hello")).toBe(
"```\nprice = $5\n```\n_hello_"
);
});
});
13 changes: 7 additions & 6 deletions packages/remend/__tests__/links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,11 @@ describe("link handling with linkMode: text-only", () => {
});

it("should handle nested brackets without matching closing bracket", () => {
// Fixed-point healing resolves every unmatched bracket in one call
expect(remend("Text [outer [inner", textOnlyOptions)).toBe(
"Text outer [inner"
"Text outer inner"
);
expect(remend("[foo [bar [baz", textOnlyOptions)).toBe("foo [bar [baz");
expect(remend("[foo [bar [baz", textOnlyOptions)).toBe("foo bar baz");
expect(remend("Text [outer [inner]", textOnlyOptions)).toBe(
"Text outer [inner]"
);
Expand All @@ -128,9 +129,9 @@ describe("link handling with linkMode: text-only", () => {
});

it("should still remove incomplete images", () => {
// Images should still be removed entirely, regardless of linkMode
// Note: the space before the image is preserved
expect(remend("Text ![incomplete image", textOnlyOptions)).toBe("Text ");
expect(remend("Text ![alt](http://partial", textOnlyOptions)).toBe("Text ");
// Images are removed entirely regardless of linkMode, and the exposed
// trailing space is stripped
expect(remend("Text ![incomplete image", textOnlyOptions)).toBe("Text");
expect(remend("Text ![alt](http://partial", textOnlyOptions)).toBe("Text");
});
});
Loading