diff --git a/.changeset/spotty-eyes-brake.md b/.changeset/spotty-eyes-brake.md
new file mode 100644
index 00000000..5036d669
--- /dev/null
+++ b/.changeset/spotty-eyes-brake.md
@@ -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, and text-only link mode resolves every unmatched bracket in one call.
diff --git a/packages/remend/__benchmarks__/remend.bench.ts b/packages/remend/__benchmarks__/remend.bench.ts
index 98d206a6..16e38391 100644
--- a/packages/remend/__benchmarks__/remend.bench.ts
+++ b/packages/remend/__benchmarks__/remend.bench.ts
@@ -296,3 +296,25 @@ describe("Streamed Code Blocks", () => {
{ iterations: 10 }
);
});
+
+// 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 }
+ );
+ }
+});
diff --git a/packages/remend/__tests__/broken-markdown-variants.test.ts b/packages/remend/__tests__/broken-markdown-variants.test.ts
index 03bdf3d6..b483fddc 100644
--- a/packages/remend/__tests__/broken-markdown-variants.test.ts
+++ b/packages/remend/__tests__/broken-markdown-variants.test.ts
@@ -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");
});
});
diff --git a/packages/remend/__tests__/code-block-utils.test.ts b/packages/remend/__tests__/code-block-utils.test.ts
index 9bf45833..a91fd97d 100644
--- a/packages/remend/__tests__/code-block-utils.test.ts
+++ b/packages/remend/__tests__/code-block-utils.test.ts
@@ -1,47 +1,9 @@
import { describe, expect, it } from "vitest";
import { isInsideCodeBlock } from "../src/code-block-utils";
-// Reference implementation: the previous per-call scan, kept verbatim so the
-// lookup-based rewrite can be checked against it position by position.
-const referenceIsInsideCodeBlock = (
- text: string,
- position: number
-): boolean => {
- let inInlineCode = false;
- let inMultilineCode = false;
-
- for (let i = 0; i < position; i += 1) {
- if (text[i] === "\\" && i + 1 < text.length && text[i + 1] === "`") {
- i += 1;
- continue;
- }
- if (text.substring(i, i + 3) === "```") {
- inMultilineCode = !inMultilineCode;
- i += 2;
- continue;
- }
- if (!inMultilineCode && text[i] === "`") {
- inInlineCode = !inInlineCode;
- }
- }
-
- return inInlineCode || inMultilineCode;
-};
-
-// Returns positions where the rewrite disagrees with the reference scan.
-const parityMismatches = (text: string): number[] => {
- const mismatches: number[] = [];
- for (let p = 0; p <= text.length + 1; p += 1) {
- if (isInsideCodeBlock(text, p) !== referenceIsInsideCodeBlock(text, p)) {
- mismatches.push(p);
- }
- }
- return mismatches;
-};
-
describe("isInsideCodeBlock", () => {
it("reports positions inside a fenced code block", () => {
- const text = "before ```js\nconst x = arr[0];\n``` after";
+ const text = "before\n```js\nconst x = arr[0];\n```\nafter";
expect(isInsideCodeBlock(text, text.indexOf("arr"))).toBe(true);
expect(isInsideCodeBlock(text, text.indexOf("before"))).toBe(false);
expect(isInsideCodeBlock(text, text.indexOf("after"))).toBe(false);
@@ -64,17 +26,10 @@ describe("isInsideCodeBlock", () => {
expect(isInsideCodeBlock(text, text.length)).toBe(true);
});
- it("matches the per-call scan at every position on mixed input", () => {
- const cases = [
- "a `b` c ```\nd [e] `f`\n``` g \\` h ``` i",
- "``````",
- "\\`",
- "`unclosed inline [x]",
- "text \\``real` code",
- ];
- for (const text of cases) {
- expect(parityMismatches(text)).toEqual([]);
- }
+ it("treats a backtick run that is not at line start as inline code", () => {
+ const text = "before ```js const x = arr[0]; ``` after";
+ expect(isInsideCodeBlock(text, text.indexOf("arr"))).toBe(true);
+ expect(isInsideCodeBlock(text, text.indexOf("after"))).toBe(false);
});
it("stays correct when queried texts alternate", () => {
diff --git a/packages/remend/__tests__/coverage-gaps.test.ts b/packages/remend/__tests__/coverage-gaps.test.ts
index bdda90b1..fa04e73c 100644
--- a/packages/remend/__tests__/coverage-gaps.test.ts
+++ b/packages/remend/__tests__/coverage-gaps.test.ts
@@ -93,8 +93,12 @@ describe("link handler edge cases", () => {
expect(remend("](partial")).toBe("](partial");
});
- it("should skip image brackets in text-only mode", () => {
- expect(remend(""
+ );
});
it("should skip complete links in text-only mode", () => {
@@ -196,9 +200,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__");
});
});
diff --git a/packages/remend/__tests__/fence-semantics.test.ts b/packages/remend/__tests__/fence-semantics.test.ts
new file mode 100644
index 00000000..37f8e025
--- /dev/null
+++ b/packages/remend/__tests__/fence-semantics.test.ts
@@ -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`");
+ });
+});
diff --git a/packages/remend/__tests__/katex.test.ts b/packages/remend/__tests__/katex.test.ts
index 40d8b3aa..77a96467 100644
--- a/packages/remend/__tests__/katex.test.ts
+++ b/packages/remend/__tests__/katex.test.ts
@@ -332,3 +332,15 @@ plain trailing text.`;
);
});
});
+
+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_"
+ );
+ });
+});
diff --git a/packages/remend/__tests__/links.test.ts b/packages/remend/__tests__/links.test.ts
index 78399d28..45433029 100644
--- a/packages/remend/__tests__/links.test.ts
+++ b/packages/remend/__tests__/links.test.ts
@@ -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]"
);
diff --git a/packages/remend/__tests__/streaming-properties.test.ts b/packages/remend/__tests__/streaming-properties.test.ts
new file mode 100644
index 00000000..435798c6
--- /dev/null
+++ b/packages/remend/__tests__/streaming-properties.test.ts
@@ -0,0 +1,151 @@
+import fc from "fast-check";
+import { describe, expect, it } from "vitest";
+import remend from "../src";
+
+// A streaming consumer feeds every prefix of a document through remend, so
+// these tests assemble documents from COMPLETE constructs and derive all
+// truncation from the streaming cut. The atoms deliberately include
+// identifiers with double-underscore runs (snake__case style), which look
+// like emphasis delimiters to a context-free counter.
+
+const INLINE_ATOMS = [
+ "plain words",
+ "**bold text**",
+ "*italic text*",
+ "_underscore italic_",
+ "__strong text__",
+ "___strong italic___",
+ "~~struck text~~",
+ "`inline code`",
+ "``double `tick` span``",
+ "snake__case",
+ "user__id",
+ "a_b_c",
+ "[label](https://example.com)",
+ "",
+ "tag",
+ "value is 20~25 degrees",
+];
+
+const BLOCK_ATOMS = [
+ "# heading",
+ "> a quote",
+ "- item one\n- item two",
+ "```js\nconst total__count = 1;\n```",
+ "~~~\ntilde fenced\n~~~",
+ "$$\nx^2\n$$",
+];
+
+// The longest fragment healing may legitimately drop: an incomplete
+// construct cut mid-stream is at most one atom long, plus the stripped
+// trailing space
+const MAX_LOSS =
+ Math.max(...[...INLINE_ATOMS, ...BLOCK_ATOMS].map((a) => a.length)) + 2;
+
+// Length of the longest prefix of `input` that appears as a subsequence of
+// `output`. Healing may insert characters (closers, escapes) and drop a
+// trailing fragment, so authored text is preserved exactly when everything
+// except a bounded tail survives as a subsequence.
+const preservedPrefixLength = (input: string, output: string): number => {
+ let matched = 0;
+ for (let i = 0; i < output.length && matched < input.length; i += 1) {
+ if (output[i] === input[matched]) {
+ matched += 1;
+ }
+ }
+ return matched;
+};
+
+const assertStreamingSafe = (prefix: string): void => {
+ const healed = remend(prefix);
+
+ const loss = prefix.length - preservedPrefixLength(prefix, healed);
+ if (loss > MAX_LOSS) {
+ throw new Error(
+ `healing dropped ${loss} chars of ${JSON.stringify(prefix)} -> ${JSON.stringify(healed)}`
+ );
+ }
+
+ const rehealed = remend(healed);
+ if (rehealed !== healed) {
+ throw new Error(
+ `healing is not idempotent: ${JSON.stringify(prefix)} -> ${JSON.stringify(healed)} -> ${JSON.stringify(rehealed)}`
+ );
+ }
+};
+
+// A document is a sequence of atoms. Inline atoms join with spaces into
+// paragraphs, block atoms stand alone, and everything joins with blank lines
+// so fences and headings begin at a line start.
+const documentArbitrary = fc
+ .array(
+ fc.oneof(
+ { weight: 3, arbitrary: fc.subarray(INLINE_ATOMS, { minLength: 1 }) },
+ { weight: 1, arbitrary: fc.constantFrom(...BLOCK_ATOMS).map((a) => [a]) }
+ ),
+ { minLength: 1, maxLength: 6 }
+ )
+ .map((groups) => groups.map((atoms) => atoms.join(" ")).join("\n\n"));
+
+describe("streaming properties", () => {
+ it("preserves authored text and re-heals to itself on every cut", () => {
+ fc.assert(
+ fc.property(documentArbitrary, fc.nat(), (doc, cutSeed) => {
+ const cut = cutSeed % (doc.length + 1);
+ assertStreamingSafe(doc.slice(0, cut));
+ }),
+ { numRuns: 2000 }
+ );
+ });
+
+ it("does not modify complete documents", () => {
+ // Escape-oriented handlers (comparison operators, single tilde between
+ // digits) intentionally rewrite complete text, so their trigger shapes
+ // are excluded here
+ const noOpAtoms = INLINE_ATOMS.filter((atom) => !atom.includes("20~25"));
+ const noOpDocArbitrary = fc
+ .array(
+ fc.oneof(
+ { weight: 3, arbitrary: fc.subarray(noOpAtoms, { minLength: 1 }) },
+ {
+ weight: 1,
+ arbitrary: fc.constantFrom(...BLOCK_ATOMS).map((a) => [a]),
+ }
+ ),
+ { minLength: 1, maxLength: 6 }
+ )
+ .map((groups) => groups.map((atoms) => atoms.join(" ")).join("\n\n"));
+
+ fc.assert(
+ fc.property(noOpDocArbitrary, (doc) => {
+ expect(remend(doc)).toBe(doc);
+ }),
+ { numRuns: 1000 }
+ );
+ });
+});
+
+describe("exhaustive prefix sweep", () => {
+ // Every prefix of a fixed corpus, deterministically. The corpus mixes
+ // constructs that interact: identifiers with double underscores next to
+ // real emphasis, fences of both characters, spans with multi-backtick runs,
+ // and half-typed closers.
+ const corpus = [
+ "Use snake__case for names and __bold text__ throughout.",
+ "The `obj__attr` field pairs with **bold** and _italic_ text.",
+ "```python\ndef f():\n return a__b\n```\n\nAfter the fence __open",
+ "~~~\ntilde __fence\n~~~\n\n~~struck~~ and more",
+ "A [link](https://example.com) and  done.",
+ "Math $$\nx^2 + y^2\n$$ and `code` mixed with ___strong italic___.",
+ "| a | b |\n| - | - |\n| 1 | 2 |\n\nTable then **bold**",
+ "Nested **bold with *italic* inside** plus ``double `tick` span``.",
+ ];
+
+ it("preserves authored text and re-heals to itself on every prefix", () => {
+ for (const doc of corpus) {
+ for (let cut = 0; cut <= doc.length; cut += 1) {
+ assertStreamingSafe(doc.slice(0, cut));
+ }
+ }
+ });
+});
diff --git a/packages/remend/__tests__/underscore-runs.test.ts b/packages/remend/__tests__/underscore-runs.test.ts
new file mode 100644
index 00000000..120f6a29
--- /dev/null
+++ b/packages/remend/__tests__/underscore-runs.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from "vitest";
+import remend from "../src";
+
+// Double underscores are counted per maximal run with flanking rules, so
+// identifiers containing __ (snake__case style) neither invent nor swallow
+// emphasis delimiters.
+describe("word-internal double underscores", () => {
+ it("should not treat an identifier's __ as a delimiter", () => {
+ expect(remend("fields user__id and org__id are join keys")).toBe(
+ "fields user__id and org__id are join keys"
+ );
+ });
+
+ it("should still close an opener when an identifier follows", () => {
+ // Counting raw __ occurrences would pair the identifier's run against
+ // the opener and swallow the closer that is still needed
+ expect(remend("Use snake__case and __bold")).toBe(
+ "Use snake__case and __bold__"
+ );
+ });
+
+ it("should not invent a closer for a lone identifier", () => {
+ expect(remend("the value of some__field is set")).toBe(
+ "the value of some__field is set"
+ );
+ });
+
+ it("should ignore identifiers inside complete inline code", () => {
+ expect(remend("`obj__attr` and __bold")).toBe("`obj__attr` and __bold__");
+ });
+
+ it("should ignore identifiers inside bold content", () => {
+ expect(remend("**bold snake__case text** and more")).toBe(
+ "**bold snake__case text** and more"
+ );
+ });
+});
+
+describe("underscore run lengths", () => {
+ it("should treat a run of four as balanced", () => {
+ expect(remend("a ____ b")).toBe("a ____ b");
+ });
+
+ it("should not complete a thematic break line", () => {
+ expect(remend("text\n\n___\n")).toBe("text\n\n___\n");
+ });
+
+ it("should keep ___text___ balanced", () => {
+ expect(remend("___both___ done")).toBe("___both___ done");
+ });
+});
+
+describe("escaped underscores", () => {
+ it("should treat the run after an escaped underscore as a delimiter", () => {
+ expect(remend("\\___bold")).toBe("\\___bold__");
+ });
+});
diff --git a/packages/remend/package.json b/packages/remend/package.json
index 4b736e1a..39365ce9 100644
--- a/packages/remend/package.json
+++ b/packages/remend/package.json
@@ -34,6 +34,7 @@
},
"devDependencies": {
"@vitest/coverage-v8": "^4.1.10",
+ "fast-check": "^4.9.0",
"mdast-util-from-markdown": "^2.0.3",
"tsup": "^8.5.1",
"vitest": "^4.1.10"
diff --git a/packages/remend/src/code-block-utils.ts b/packages/remend/src/code-block-utils.ts
index b2ab6ad1..499aa465 100644
--- a/packages/remend/src/code-block-utils.ts
+++ b/packages/remend/src/code-block-utils.ts
@@ -1,129 +1,13 @@
-// Builds the isInsideCodeBlock answer for every position in one linear pass.
-// lookup[p] === 1 means scanning chars [0, p) ends inside inline or fenced
-// code. Scanning per call is O(position), which makes callers that probe many
-// positions (e.g. the link handler walking every "[" of a long streamed code
-// block) quadratic overall.
-// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: "Mirrors the original scan's control flow exactly so the lookup is provably equivalent"
-const buildCodeBlockLookup = (text: string): Uint8Array => {
- const lookup = new Uint8Array(text.length + 1);
- let inInlineCode = false;
- let inMultilineCode = false;
- let i = 0;
+import { getScan, isCodeAt, isCompleteSpanAt } from "./scan";
- while (i < text.length) {
- // Skip escaped backticks
- if (text[i] === "\\" && i + 1 < text.length && text[i + 1] === "`") {
- const state = inInlineCode || inMultilineCode ? 1 : 0;
- lookup[i + 1] = state;
- lookup[i + 2] = state;
- i += 2;
- continue;
- }
+// A code construct here means a fenced block or an inline code span.
+export const isInsideCodeBlock = (text: string, position: number): boolean =>
+ isCodeAt(getScan(text), position);
- // Check for triple backticks (multiline code blocks)
- if (text.substring(i, i + 3) === "```") {
- inMultilineCode = !inMultilineCode;
- const state = inInlineCode || inMultilineCode ? 1 : 0;
- const next = Math.min(i + 3, text.length);
- for (let p = i + 1; p <= next; p += 1) {
- lookup[p] = state;
- }
- i = next;
- continue;
- }
-
- // Only check for inline code if not in multiline code
- if (!inMultilineCode && text[i] === "`") {
- inInlineCode = !inInlineCode;
- }
- lookup[i + 1] = inInlineCode || inMultilineCode ? 1 : 0;
- i += 1;
- }
-
- return lookup;
-};
-
-// Handlers repeatedly probe positions of the same text within one remend()
-// call, so a single-entry cache converts each probe to O(1) after one O(n)
-// build per distinct text.
-let cache: { text: string; lookup: Uint8Array } | null = null;
-
-// Check if a position is inside a code block (between ``` or `)
-export const isInsideCodeBlock = (text: string, position: number): boolean => {
- let current = cache;
- if (current === null || current.text !== text) {
- current = { text, lookup: buildCodeBlockLookup(text) };
- cache = current;
- }
- // Positions past the end resolve to the state after scanning the full text,
- // matching the previous per-call scan.
- return current.lookup[Math.min(position, text.length)] === 1;
-};
-
-// Checks if a backtick at position i is part of a triple backtick sequence
-export const isPartOfTripleBacktick = (text: string, i: number): boolean => {
- const isTripleStart = text.substring(i, i + 3) === "```";
- const isTripleMiddle = i > 0 && text.substring(i - 1, i + 2) === "```";
- const isTripleEnd = i > 1 && text.substring(i - 2, i + 1) === "```";
-
- return isTripleStart || isTripleMiddle || isTripleEnd;
-};
-
-// Counts single backticks that are not part of triple backticks or escaped
-export const countSingleBackticks = (text: string): number => {
- let count = 0;
- for (let i = 0; i < text.length; i += 1) {
- // Skip escaped backticks
- if (text[i] === "\\" && i + 1 < text.length && text[i + 1] === "`") {
- i += 1;
- continue;
- }
- if (text[i] === "`" && !isPartOfTripleBacktick(text, i)) {
- count += 1;
- }
- }
- return count;
-};
-
-// Check if a position is inside a COMPLETE inline code span (both opening and closing backtick present).
-// Returns false for incomplete inline code spans (streaming) so emphasis markers can still be completed.
+// Check if a position is within a COMPLETE inline code span (both opening and
+// closing backtick runs present). Returns false for incomplete spans
+// (streaming) so emphasis markers can still be completed.
export const isWithinCompleteInlineCode = (
text: string,
position: number
-): boolean => {
- let inInlineCode = false;
- let inMultilineCode = false;
- let inlineCodeStart = -1;
-
- for (let i = 0; i < text.length; i += 1) {
- // Skip escaped backticks
- if (text[i] === "\\" && i + 1 < text.length && text[i + 1] === "`") {
- i += 1;
- continue;
- }
-
- // Check for triple backticks (multiline code blocks)
- if (text.substring(i, i + 3) === "```") {
- inMultilineCode = !inMultilineCode;
- i += 2;
- continue;
- }
-
- // Only check for inline code if not in multiline code
- if (!inMultilineCode && text[i] === "`") {
- if (inInlineCode) {
- // Found closing backtick — check if position is inside this complete span
- if (inlineCodeStart < position && position < i) {
- return true;
- }
- inInlineCode = false;
- inlineCodeStart = -1;
- } else {
- inInlineCode = true;
- inlineCodeStart = i;
- }
- }
- }
-
- return false;
-};
+): boolean => isCompleteSpanAt(getScan(text), position);
diff --git a/packages/remend/src/emphasis-handlers.ts b/packages/remend/src/emphasis-handlers.ts
index 43dd4ff2..5771f7ef 100644
--- a/packages/remend/src/emphasis-handlers.ts
+++ b/packages/remend/src/emphasis-handlers.ts
@@ -14,19 +14,19 @@ import {
whitespaceOrMarkersPattern,
} from "./patterns";
import {
- isHorizontalRule,
- isWithinHtmlTag,
- isWithinLinkOrImageUrl,
- isWithinMathBlock,
- isWordChar,
-} from "./utils";
-
-const hasMathDelimiters = (text: string): boolean =>
- text.includes("$") || text.includes("\\(") || text.includes("\\[");
+ countDoublePairs,
+ getScan,
+ inHtmlTagAt,
+ inLinkUrlAt,
+ inMathAt,
+ REGION,
+ type TextScan,
+} from "./scan";
+import { isHorizontalRule, isWordChar } from "./utils";
// Helper function to check if an asterisk should be skipped
const shouldSkipAsterisk = (
- text: string,
+ scan: TextScan,
index: number,
prevChar: string,
nextChar: string
@@ -37,7 +37,7 @@ const shouldSkipAsterisk = (
}
// Skip if within math block
- if (hasMathDelimiters(text) && isWithinMathBlock(text, index)) {
+ if (inMathAt(scan, index)) {
return true;
}
@@ -45,7 +45,8 @@ const shouldSkipAsterisk = (
// If this is the first * in ***, don't skip it - it can close a single * italic
// Example: **bold and *italic*** should count the first * of *** as closing the italic
if (prevChar !== "*" && nextChar === "*") {
- const nextNextChar = index < text.length - 2 ? text[index + 2] : "";
+ const nextNextChar =
+ index < scan.text.length - 2 ? scan.text[index + 2] : "";
if (nextNextChar === "*") {
// This is the first * in a *** sequence
// Count it as a single asterisk for matching purposes
@@ -110,34 +111,15 @@ const shouldCountSingleAsterisk = (
return { count: false };
};
-// OPTIMIZATION: Counts single asterisks without split("").reduce()
-// Counts single asterisks that are not part of double asterisks, escaped, or list markers,
-// and not inside fenced code blocks.
export const countSingleAsterisks = (text: string): number => {
+ const scan = getScan(text);
let count = 0;
- let inCodeBlock = false;
let inWordAsteriskChain = false;
const len = text.length;
for (let index = 0; index < len; index += 1) {
- // Track fenced code blocks (```)
- if (
- text[index] === "`" &&
- index + 2 < len &&
- text[index + 1] === "`" &&
- text[index + 2] === "`"
- ) {
- inCodeBlock = !inCodeBlock;
- index += 2;
- continue;
- }
-
- // Skip content inside fenced code blocks
- if (inCodeBlock) {
- continue;
- }
-
- if (text[index] !== "*") {
+ // Code regions neither contribute delimiters nor belong to a word chain
+ if (text[index] !== "*" || scan.regions[index] !== REGION.PROSE) {
if (!isWordChar(text[index])) {
inWordAsteriskChain = false;
}
@@ -147,7 +129,7 @@ export const countSingleAsterisks = (text: string): number => {
const prevChar = index > 0 ? text[index - 1] : "";
const nextChar = index < len - 1 ? text[index + 1] : "";
- if (shouldSkipAsterisk(text, index, prevChar, nextChar)) {
+ if (shouldSkipAsterisk(scan, index, prevChar, nextChar)) {
continue;
}
@@ -168,7 +150,7 @@ export const countSingleAsterisks = (text: string): number => {
// Helper function to check if an underscore should be skipped
const shouldSkipUnderscore = (
- text: string,
+ scan: TextScan,
index: number,
prevChar: string,
nextChar: string
@@ -179,17 +161,17 @@ const shouldSkipUnderscore = (
}
// Skip if within math block
- if (hasMathDelimiters(text) && isWithinMathBlock(text, index)) {
+ if (inMathAt(scan, index)) {
return true;
}
// Skip if within a link or image URL
- if (isWithinLinkOrImageUrl(text, index)) {
+ if (inLinkUrlAt(scan, index)) {
return true;
}
// Skip if within an HTML tag (e.g. )
- if (isWithinHtmlTag(text, index)) {
+ if (inHtmlTagAt(scan, index)) {
return true;
}
@@ -206,40 +188,20 @@ const shouldSkipUnderscore = (
return false;
};
-// OPTIMIZATION: Counts single underscores without split("").reduce()
-// Counts single underscores that are not part of double underscores, not escaped, not in math blocks,
-// and not inside fenced code blocks
export const countSingleUnderscores = (text: string): number => {
+ const scan = getScan(text);
let count = 0;
- let inCodeBlock = false;
const len = text.length;
for (let index = 0; index < len; index += 1) {
- // Track fenced code blocks (```)
- if (
- text[index] === "`" &&
- index + 2 < len &&
- text[index + 1] === "`" &&
- text[index + 2] === "`"
- ) {
- inCodeBlock = !inCodeBlock;
- index += 2;
- continue;
- }
-
- // Skip content inside fenced code blocks
- if (inCodeBlock) {
- continue;
- }
-
- if (text[index] !== "_") {
+ if (text[index] !== "_" || scan.regions[index] !== REGION.PROSE) {
continue;
}
const prevChar = index > 0 ? text[index - 1] : "";
const nextChar = index < len - 1 ? text[index + 1] : "";
- if (!shouldSkipUnderscore(text, index, prevChar, nextChar)) {
+ if (!shouldSkipUnderscore(scan, index, prevChar, nextChar)) {
count += 1;
}
}
@@ -248,37 +210,14 @@ export const countSingleUnderscores = (text: string): number => {
};
// Counts triple asterisks that are not part of quadruple or more asterisks
-// and not inside fenced code blocks
-// OPTIMIZATION: Count *** without regex to avoid allocation
+// and not inside code regions
export const countTripleAsterisks = (text: string): number => {
+ const scan = getScan(text);
let count = 0;
let consecutiveAsterisks = 0;
- let inCodeBlock = false;
for (let i = 0; i < text.length; i += 1) {
- // Track fenced code blocks (```)
- if (
- text[i] === "`" &&
- i + 2 < text.length &&
- text[i + 1] === "`" &&
- text[i + 2] === "`"
- ) {
- // Flush any pending asterisks before toggling
- if (consecutiveAsterisks >= 3) {
- count += Math.floor(consecutiveAsterisks / 3);
- }
- consecutiveAsterisks = 0;
- inCodeBlock = !inCodeBlock;
- i += 2;
- continue;
- }
-
- // Skip content inside fenced code blocks
- if (inCodeBlock) {
- continue;
- }
-
- if (text[i] === "*") {
+ if (text[i] === "*" && scan.regions[i] === REGION.PROSE) {
consecutiveAsterisks += 1;
} else {
// End of asterisk sequence
@@ -297,58 +236,127 @@ export const countTripleAsterisks = (text: string): number => {
return count;
};
-// Counts ** pairs outside fenced code blocks
-const countDoubleAsterisksOutsideCodeBlocks = (text: string): number => {
- let count = 0;
- let inCodeBlock = false;
+const countDoubleAsterisks = (text: string): number =>
+ countDoublePairs(text, "*");
- for (let i = 0; i < text.length; i += 1) {
- if (
- text[i] === "`" &&
- i + 2 < text.length &&
- text[i + 1] === "`" &&
- text[i + 2] === "`"
- ) {
- inCodeBlock = !inCodeBlock;
- i += 2;
- continue;
- }
- if (inCodeBlock) {
- continue;
- }
- if (text[i] === "*" && i + 1 < text.length && text[i + 1] === "*") {
- count += 1;
- i += 1;
- }
+// Whether the text has an unmatched __ delimiter, counted per maximal
+// underscore run.
+//
+// Counting raw occurrences misreads identifiers: a name like snake__case
+// contains __ but cannot open or close emphasis, and counting it either
+// invents a closer (odd count) or pairs it against a real delimiter and
+// swallows a closer that was needed (even count). Per run:
+//
+// - A run contributes floor(length / 2) pairs, and flips delimiter parity
+// only when that is odd. __ and ___ flip; ____ does not.
+// - A word-internal run (word characters on both sides) is part of an
+// identifier, never a delimiter.
+// - Runs inside code regions, math, link URLs, and HTML tags are skipped,
+// matching the single-underscore handler's flanking rules.
+// - A run alone on its line is a thematic break, not emphasis.
+const isLineBoundaryChar = (char: string): boolean =>
+ char === "" || char === " " || char === "\t" || char === "\n";
+
+// isHorizontalRule scans the run's whole line, so its verdict is memoized
+// per line to keep run counting linear when many runs share a line
+interface ThematicBreakMemo {
+ lineEnd: number;
+ result: boolean;
+}
+
+const isThematicBreakRun = (
+ text: string,
+ runStart: number,
+ prevChar: string,
+ nextChar: string,
+ memo: ThematicBreakMemo
+): boolean => {
+ // A thematic break line holds only markers and whitespace, so only runs
+ // flanked by whitespace or line boundaries can be part of one
+ if (!(isLineBoundaryChar(prevChar) && isLineBoundaryChar(nextChar))) {
+ return false;
}
- return count;
+ if (runStart > memo.lineEnd) {
+ const lineEnd = text.indexOf("\n", runStart);
+ memo.lineEnd = lineEnd === -1 ? text.length : lineEnd;
+ memo.result = isHorizontalRule(text, runStart, "_");
+ }
+ return memo.result;
};
-// Counts __ pairs outside fenced code blocks
-const countDoubleUnderscoresOutsideCodeBlocks = (text: string): number => {
- let count = 0;
- let inCodeBlock = false;
+// Whether an underscore run at [runStart, runEnd) flips delimiter parity
+const doubleUnderscoreRunFlips = (
+ scan: TextScan,
+ initialRunStart: number,
+ runEnd: number,
+ memo: ThematicBreakMemo
+): boolean => {
+ const text = scan.text;
- for (let i = 0; i < text.length; i += 1) {
- if (
- text[i] === "`" &&
- i + 2 < text.length &&
- text[i + 1] === "`" &&
- text[i + 2] === "`"
- ) {
- inCodeBlock = !inCodeBlock;
- i += 2;
+ // A backslash escapes the first underscore of the run. The escaped
+ // underscore is literal punctuation, so the rest of the run still flanks
+ // as a delimiter.
+ let runStart = initialRunStart;
+ let escaped = false;
+ if (runStart > 0 && text[runStart - 1] === "\\") {
+ runStart += 1;
+ escaped = true;
+ }
+ const runLength = runEnd - runStart;
+ if (runLength < 2) {
+ return false;
+ }
+
+ const beforeRun = runStart > 0 ? text[runStart - 1] : "";
+ const prevChar = escaped ? "\\" : beforeRun;
+ const nextChar = runEnd < text.length ? text[runEnd] : "";
+ if (isWordChar(prevChar) && isWordChar(nextChar)) {
+ return false;
+ }
+ if (isThematicBreakRun(text, runStart, prevChar, nextChar, memo)) {
+ return false;
+ }
+ if (
+ inMathAt(scan, runStart) ||
+ inLinkUrlAt(scan, runStart) ||
+ inHtmlTagAt(scan, runStart)
+ ) {
+ return false;
+ }
+
+ return Math.floor(runLength / 2) % 2 === 1;
+};
+
+const hasUnmatchedDoubleUnderscore = (text: string): boolean => {
+ const scan = getScan(text);
+ const n = text.length;
+ const memo: ThematicBreakMemo = { lineEnd: -1, result: false };
+ let unmatched = false;
+ let i = 0;
+
+ while (i < n) {
+ if (text[i] !== "_" || scan.regions[i] !== REGION.PROSE) {
+ i += 1;
continue;
}
- if (inCodeBlock) {
- continue;
+
+ const runStart = i;
+ let runEnd = i + 1;
+ while (
+ runEnd < n &&
+ text[runEnd] === "_" &&
+ scan.regions[runEnd] === REGION.PROSE
+ ) {
+ runEnd += 1;
}
- if (text[i] === "_" && i + 1 < text.length && text[i + 1] === "_") {
- count += 1;
- i += 1;
+ i = runEnd;
+
+ if (doubleUnderscoreRunFlips(scan, runStart, runEnd, memo)) {
+ unmatched = !unmatched;
}
}
- return count;
+
+ return unmatched;
};
// Helper to check if bold marker should not be completed
@@ -403,7 +411,7 @@ export const handleIncompleteBold = (text: string): string => {
return text;
}
- const asteriskPairs = countDoubleAsterisksOutsideCodeBlocks(text);
+ const asteriskPairs = countDoubleAsterisks(text);
if (asteriskPairs % 2 === 1) {
// Check for half-complete closing marker: **content* should become **content**
// The trailing * is the first char of the closing ** being streamed
@@ -462,12 +470,10 @@ export const handleIncompleteDoubleUnderscoreItalic = (
!(
isInsideCodeBlock(text, markerIndex) ||
isWithinCompleteInlineCode(text, markerIndex)
- )
+ ) &&
+ hasUnmatchedDoubleUnderscore(text)
) {
- const underscorePairs = countDoubleUnderscoresOutsideCodeBlocks(text);
- if (underscorePairs % 2 === 1) {
- return `${text}_`;
- }
+ return `${text}_`;
}
}
return text;
@@ -488,72 +494,57 @@ export const handleIncompleteDoubleUnderscoreItalic = (
return text;
}
- const underscorePairs = countDoubleUnderscoresOutsideCodeBlocks(text);
- if (underscorePairs % 2 === 1) {
+ if (hasUnmatchedDoubleUnderscore(text)) {
return `${text}__`;
}
return text;
};
-// Helper function to find the first single asterisk index (skips fenced code blocks)
-// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: asterisk detection requires many inline conditions
+// Skips code regions when locating the asterisk.
+// A lone, unescaped asterisk in prose outside math: the only positions that
+// can open incomplete italic
+const isLoneProseAsterisk = (scan: TextScan, i: number): boolean => {
+ const { text } = scan;
+ return (
+ text[i] === "*" &&
+ scan.regions[i] === REGION.PROSE &&
+ text[i - 1] !== "*" &&
+ text[i + 1] !== "*" &&
+ text[i - 1] !== "\\" &&
+ !inMathAt(scan, i)
+ );
+};
+
const findFirstSingleAsteriskIndex = (text: string): number => {
- let inCodeBlock = false;
+ const scan = getScan(text);
for (let i = 0; i < text.length; i += 1) {
- // Track fenced code blocks (```)
- if (
- text[i] === "`" &&
- i + 2 < text.length &&
- text[i + 1] === "`" &&
- text[i + 2] === "`"
- ) {
- inCodeBlock = !inCodeBlock;
- i += 2;
+ if (!isLoneProseAsterisk(scan, i)) {
continue;
}
+ const prevChar = i > 0 ? text[i - 1] : "";
+ const nextChar = i < text.length - 1 ? text[i + 1] : "";
- // Skip content inside fenced code blocks
- if (inCodeBlock) {
+ // Skip if flanked by whitespace on both sides (not a valid emphasis delimiter)
+ const prevIsWs = !prevChar || isWhitespaceChar(prevChar);
+ const nextIsWs = !nextChar || isWhitespaceChar(nextChar);
+ if (prevIsWs && nextIsWs) {
continue;
}
- if (
- text[i] === "*" &&
- text[i - 1] !== "*" &&
- text[i + 1] !== "*" &&
- text[i - 1] !== "\\" &&
- !isWithinMathBlock(text, i)
- ) {
- const prevChar = i > 0 ? text[i - 1] : "";
- const nextChar = i < text.length - 1 ? text[i + 1] : "";
-
- // Skip if flanked by whitespace on both sides (not a valid emphasis delimiter)
- const prevIsWs = !prevChar || isWhitespaceChar(prevChar);
- const nextIsWs = !nextChar || isWhitespaceChar(nextChar);
- if (prevIsWs && nextIsWs) {
- continue;
- }
-
- // Skip cold word-internal asterisks; they are not openers for completion.
- // (Active-run closers are still counted in countSingleAsterisks.)
- if (
- prevChar &&
- nextChar &&
- isWordChar(prevChar) &&
- isWordChar(nextChar)
- ) {
- continue;
- }
-
- // A right-flanking-only marker cannot open incomplete italic.
- if (nextIsWs) {
- continue;
- }
+ // Skip cold word-internal asterisks; they are not openers for completion.
+ // (Active-run closers are still counted in countSingleAsterisks.)
+ if (prevChar && nextChar && isWordChar(prevChar) && isWordChar(nextChar)) {
+ continue;
+ }
- return i;
+ // A right-flanking-only marker cannot open incomplete italic.
+ if (nextIsWs) {
+ continue;
}
+
+ return i;
}
return -1;
};
@@ -602,35 +593,18 @@ export const handleIncompleteSingleAsteriskItalic = (text: string): string => {
return text;
};
-// Helper function to find the first single underscore index (skips fenced code blocks)
const findFirstSingleUnderscoreIndex = (text: string): number => {
- let inCodeBlock = false;
+ const scan = getScan(text);
for (let i = 0; i < text.length; i += 1) {
- // Track fenced code blocks (```)
- if (
- text[i] === "`" &&
- i + 2 < text.length &&
- text[i + 1] === "`" &&
- text[i + 2] === "`"
- ) {
- inCodeBlock = !inCodeBlock;
- i += 2;
- continue;
- }
-
- // Skip content inside fenced code blocks
- if (inCodeBlock) {
- continue;
- }
-
if (
text[i] === "_" &&
+ scan.regions[i] === REGION.PROSE &&
text[i - 1] !== "_" &&
text[i + 1] !== "_" &&
text[i - 1] !== "\\" &&
- !isWithinMathBlock(text, i) &&
- !isWithinLinkOrImageUrl(text, i)
+ !inMathAt(scan, i) &&
+ !inLinkUrlAt(scan, i)
) {
// Check if underscore is word-internal (between word characters)
const prevChar = i > 0 ? text[i - 1] : "";
@@ -673,7 +647,7 @@ const handleTrailingAsterisksForUnderscore = (text: string): string | null => {
}
const textWithoutTrailingAsterisks = text.slice(0, -2);
- const asteriskPairsAfterRemoval = countDoubleAsterisksOutsideCodeBlocks(
+ const asteriskPairsAfterRemoval = countDoubleAsterisks(
textWithoutTrailingAsterisks
);
@@ -751,7 +725,7 @@ export const handleIncompleteSingleUnderscoreItalic = (
// Helper to check if bold-italic markers are already balanced
const areBoldItalicMarkersBalanced = (text: string): boolean => {
- const asteriskPairs = countDoubleAsterisksOutsideCodeBlocks(text);
+ const asteriskPairs = countDoubleAsterisks(text);
const singleAsterisks = countSingleAsterisks(text);
return asteriskPairs % 2 === 0 && singleAsterisks % 2 === 0;
};
diff --git a/packages/remend/src/index.ts b/packages/remend/src/index.ts
index 8c2d4932..d4ddc077 100644
--- a/packages/remend/src/index.ts
+++ b/packages/remend/src/index.ts
@@ -311,6 +311,13 @@ const remend = (text: string, options?: RemendOptions): string => {
}
}
+ // A handler that removes a trailing fragment can expose a trailing space
+ // (e.g. dropping an incomplete image). Strip it the same way the input
+ // was stripped, so healed output re-heals to itself.
+ if (result.endsWith(" ") && !result.endsWith(" ")) {
+ return result.slice(0, -1);
+ }
+
return result;
};
diff --git a/packages/remend/src/inline-code-handler.ts b/packages/remend/src/inline-code-handler.ts
index b77c2c32..ed079a79 100644
--- a/packages/remend/src/inline-code-handler.ts
+++ b/packages/remend/src/inline-code-handler.ts
@@ -1,61 +1,45 @@
-import { countSingleBackticks } from "./code-block-utils";
-import {
- inlineCodePattern,
- inlineTripleBacktickPattern,
- whitespaceOrMarkersPattern,
-} from "./patterns";
+import { whitespaceOrMarkersPattern } from "./patterns";
+import { getScan } from "./scan";
+
+// Completes an unclosed inline code span (`)
+//
+// A span opened by a run of N backticks closes only on a run of exactly N,
+// so the completion appends whatever remains of the closing run. If the text
+// already ends with a partial closing run of k < N backticks, only N - k are
+// appended.
+export const handleIncompleteInlineCode = (text: string): string => {
+ const scan = getScan(text);
-// Helper function to check for incomplete inline triple backticks
-const handleInlineTripleBackticks = (text: string): string | null => {
- const inlineTripleBacktickMatch = text.match(inlineTripleBacktickPattern);
- if (!inlineTripleBacktickMatch || text.includes("\n")) {
- return null;
+ // Inside an unterminated fenced code block, backticks are content and the
+ // block is left for the renderer to display as streaming code
+ if (scan.openFence) {
+ return text;
}
- // Check if it ends with exactly 2 backticks (incomplete)
- if (text.endsWith("``") && !text.endsWith("```")) {
- return `${text}\``;
+ const span = scan.openSpan;
+ if (!span) {
+ return text;
}
- // Already complete inline triple backticks
- return text;
-};
-
-// Helper function to check if we're inside an incomplete code block
-const isInsideIncompleteCodeBlock = (text: string): boolean => {
- const allTripleBackticks = (text.match(/```/g) || []).length;
- return allTripleBackticks % 2 === 1;
-};
-// Completes incomplete inline code formatting (`)
-// Avoids completing if inside an incomplete code block
-export const handleIncompleteInlineCode = (text: string): string => {
- // Check if we have inline triple backticks (starts with ``` and should end with ```)
- // This pattern should ONLY match truly inline code (no newlines)
- // Examples: ```code``` or ```python code```
- const inlineResult = handleInlineTripleBackticks(text);
- if (inlineResult !== null) {
- return inlineResult;
+ // Don't close if there's no meaningful content after the opening run
+ const content = text.slice(span.start + span.runLength);
+ if (!content || whitespaceOrMarkersPattern.test(content)) {
+ return text;
}
- const inlineCodeMatch = text.match(inlineCodePattern);
-
- if (inlineCodeMatch && !isInsideIncompleteCodeBlock(text)) {
- // Don't close if there's no meaningful content after the opening marker
- // inlineCodeMatch[2] contains the content after `
- // Check if content is only whitespace or other emphasis markers
- const contentAfterMarker = inlineCodeMatch[2];
- if (
- !contentAfterMarker ||
- whitespaceOrMarkersPattern.test(contentAfterMarker)
- ) {
- return text;
- }
+ // A trailing backtick run is the closing run being streamed
+ let trailingRun = 0;
+ let i = text.length - 1;
+ while (i >= 0 && text[i] === "`") {
+ trailingRun += 1;
+ i -= 1;
+ }
- const singleBacktickCount = countSingleBackticks(text);
- if (singleBacktickCount % 2 === 1) {
- return `${text}\``;
- }
+ // A trailing run at least as long as the opener is a literal run inside
+ // the span. Appending backticks would only extend it, never close the span.
+ if (trailingRun >= span.runLength) {
+ return text;
}
- return text;
+ return text + "`".repeat(span.runLength - trailingRun);
};
diff --git a/packages/remend/src/katex-handler.ts b/packages/remend/src/katex-handler.ts
index 765542f1..c274fd70 100644
--- a/packages/remend/src/katex-handler.ts
+++ b/packages/remend/src/katex-handler.ts
@@ -1,32 +1,11 @@
-// Helper function to check if a backtick is part of a triple backtick
-const isTripleBacktick = (text: string, index: number): boolean =>
- (index >= 2 && text.substring(index - 2, index + 1) === "```") ||
- (index >= 1 && text.substring(index - 1, index + 2) === "```") ||
- (index <= text.length - 3 && text.substring(index, index + 3) === "```");
-
-// Helper function to count $$ pairs outside of inline code blocks
-const countDollarPairs = (text: string): number => {
- let dollarPairs = 0;
- let inInlineCode = false;
-
- for (let i = 0; i < text.length - 1; i += 1) {
- if (text[i] === "`" && !isTripleBacktick(text, i)) {
- inInlineCode = !inInlineCode;
- }
-
- if (!inInlineCode && text[i] === "$" && text[i + 1] === "$") {
- dollarPairs += 1;
- i += 1;
- }
- }
+import { countDoublePairs, getScan, REGION } from "./scan";
- return dollarPairs;
-};
+const countDollarPairs = (text: string): number => countDoublePairs(text, "$");
-// Helper function to count single $ signs (excluding $$) outside of code blocks
+// Excludes $$ pairs and any $ inside code regions.
const countSingleDollars = (text: string): number => {
+ const scan = getScan(text);
let count = 0;
- let inInlineCode = false;
for (let i = 0; i < text.length; i += 1) {
if (text[i] === "\\") {
@@ -34,12 +13,11 @@ const countSingleDollars = (text: string): number => {
continue;
}
- if (text[i] === "`" && !isTripleBacktick(text, i)) {
- inInlineCode = !inInlineCode;
+ if (scan.regions[i] !== REGION.PROSE) {
continue;
}
- if (!inInlineCode && text[i] === "$") {
+ if (text[i] === "$") {
if (i + 1 < text.length && text[i + 1] === "$") {
i += 1;
} else {
diff --git a/packages/remend/src/link-image-handler.ts b/packages/remend/src/link-image-handler.ts
index 63aad537..f3a6397a 100644
--- a/packages/remend/src/link-image-handler.ts
+++ b/packages/remend/src/link-image-handler.ts
@@ -142,11 +142,8 @@ const handleIncompleteText = (
return null;
};
-// Handles incomplete links and images by preserving them with a special marker
-export const handleIncompleteLinksAndImages = (
- text: string,
- linkMode: LinkMode = "protocol"
-): string => {
+// One healing step over the trailing incomplete link or image, if any
+const healTrailingLinkOrImage = (text: string, linkMode: LinkMode): string => {
// Look for patterns like [text]( or : string => {
+ let current = text;
+
+ // Removing a trailing incomplete image can expose another incomplete
+ // construct that ended immediately before it (an input ending in "![!["
+ // heals to "![", which is itself incomplete). Iterate until healed text
+ // re-heals to itself, so healing is idempotent. A step that grows the text
+ // has completed the construct, and truncating steps strictly shorten,
+ // so the loop terminates.
+ for (let pass = 0; pass < MAX_HEAL_PASSES; pass += 1) {
+ const next = healTrailingLinkOrImage(current, linkMode);
+ if (next.length >= current.length) {
+ return next;
+ }
+ current = next;
+ }
+ return current;
+};
diff --git a/packages/remend/src/patterns.ts b/packages/remend/src/patterns.ts
index 50700792..9e57b07e 100644
--- a/packages/remend/src/patterns.ts
+++ b/packages/remend/src/patterns.ts
@@ -3,16 +3,10 @@ export const italicPattern = /(__)([^_]*?)$/;
export const boldItalicPattern = /(\*\*\*)([^*]*?)$/;
export const singleAsteriskPattern = /(\*)([^*]*?)$/;
export const singleUnderscorePattern = /(_)([^_]*?)$/;
-export const inlineCodePattern = /(`)([^`]*?)$/;
export const strikethroughPattern = /(~~)([^~]*?)$/;
export const whitespaceOrMarkersPattern = /^[\s_~*`]*$/;
export const listItemPattern = /^[\s]*[-*+][\s]+$/;
export const letterNumberUnderscorePattern = /[\p{L}\p{N}_]/u;
-export const inlineTripleBacktickPattern = /^```[^`\n]*```?$/;
export const fourOrMoreAsterisksPattern = /^\*{4,}$/;
-export const linkImagePattern = /(!?\[)([^\]]*?)$/;
-export const incompleteLinkUrlPattern = /(!?)\[([^\]]+)\](\([^)]+)$/;
export const halfCompleteUnderscorePattern = /(__)([^_]+)_$/;
export const halfCompleteTildePattern = /(~~)([^~]+)~$/;
-export const doubleUnderscoreGlobalPattern = /__/g;
-export const doubleTildeGlobalPattern = /~~/g;
diff --git a/packages/remend/src/scan.ts b/packages/remend/src/scan.ts
new file mode 100644
index 00000000..1c742e59
--- /dev/null
+++ b/packages/remend/src/scan.ts
@@ -0,0 +1,549 @@
+// Single-pass classification of a text into code and prose regions.
+//
+// Healing runs on every streaming token, and every handler needs to know
+// whether a candidate delimiter sits in prose or in code. A single scan
+// paints a region code for every position, memoized per input string, so
+// each query is O(1) and healing stays linear in the input no matter how
+// many delimiters it holds. The lazy masks below answer the same question
+// for math, link URLs, and HTML tags.
+//
+// Fence and span semantics follow CommonMark:
+//
+// - A fence opens only at the start of a line, with any indentation. CommonMark
+// caps a top-level fence at 3 spaces, but fences nested in list items carry
+// deeper absolute indents and a line-based scan has no list context. Reading
+// an indented line as code is the safe direction: healing then leaves it
+// alone instead of corrupting it.
+// - Both ``` and ~~~ fences are recognized, with runs of 3 or more.
+// - The info string of a backtick fence cannot contain a backtick
+// (a line like ```code``` is inline code, not a fence).
+// - A fence closes on a run of the same character at least as long as the
+// opener, alone on its line. Lines may end in \n or \r\n.
+// - An inline code span opened by a run of N backticks closes only on a run
+// of exactly N backticks. Other runs are literal inside the span.
+// - A span cannot cross a blank line: inline parsing is paragraph-scoped, so
+// an unmatched run in a finished paragraph stays literal prose.
+
+export const REGION = {
+ PROSE: 0,
+ /** The ``` or ~~~ run that opens or closes a fence */
+ FENCE_MARKER: 1,
+ /** The info string on a fence opener line. Neither prose nor code body. */
+ FENCE_INFO: 2,
+ FENCE_BODY: 3,
+ /** A complete inline code span, including its backtick markers */
+ CODE_SPAN: 4,
+ /** An inline code span whose closing run has not arrived yet */
+ CODE_SPAN_OPEN: 5,
+} as const;
+
+export type Region = (typeof REGION)[keyof typeof REGION];
+
+export interface OpenFence {
+ char: "`" | "~";
+ /** Length of the opening run; a closer must be at least this long */
+ length: number;
+}
+
+export interface OpenSpan {
+ /** Length of the opening run; the closer must match it exactly */
+ runLength: number;
+ /** Index of the first backtick of the opening run */
+ start: number;
+}
+
+export interface TextScan {
+ htmlTagMask: Uint8Array | null;
+ linkUrlMask: Uint8Array | null;
+ /** Lazily computed masks backing the inMathAt/inLinkUrlAt/inHtmlTagAt helpers */
+ mathMask: Uint8Array | null;
+ /** Fence still open at end of text, if any */
+ openFence: OpenFence | null;
+ /** Inline code span still open at end of text, if any */
+ openSpan: OpenSpan | null;
+ regions: Uint8Array;
+ text: string;
+}
+
+const FENCE_OPENER_PATTERN = /^( *)(`{3,}|~{3,})(.*)$/;
+
+const paintFenceOpener = (
+ regions: Uint8Array,
+ lineStart: number,
+ lineEnd: number,
+ indentLength: number,
+ markerLength: number
+): void => {
+ const markerStart = lineStart + indentLength;
+ regions.fill(REGION.FENCE_MARKER, markerStart, markerStart + markerLength);
+ // Info string plus the line terminator belong to the fence.
+ regions.fill(
+ REGION.FENCE_INFO,
+ markerStart + markerLength,
+ Math.min(lineEnd + 1, regions.length)
+ );
+};
+
+// Whether a line inside an open fence closes it: optional indent, then a run
+// of the fence character at least as long as the opener, then only whitespace
+const isFenceCloser = (
+ text: string,
+ lineStart: number,
+ lineEnd: number,
+ fence: OpenFence
+): boolean => {
+ let i = lineStart;
+ while (i < lineEnd && text[i] === " ") {
+ i += 1;
+ }
+ let runLength = 0;
+ while (i < lineEnd && text[i] === fence.char) {
+ i += 1;
+ runLength += 1;
+ }
+ if (runLength < fence.length) {
+ return false;
+ }
+ while (i < lineEnd) {
+ if (text[i] !== " " && text[i] !== "\t" && text[i] !== "\r") {
+ return false;
+ }
+ i += 1;
+ }
+ return true;
+};
+
+const paintFences = (text: string, regions: Uint8Array): OpenFence | null => {
+ const n = text.length;
+ let openFence: OpenFence | null = null;
+ let lineStart = 0;
+
+ while (lineStart < n) {
+ let lineEnd = text.indexOf("\n", lineStart);
+ if (lineEnd === -1) {
+ lineEnd = n;
+ }
+
+ if (openFence) {
+ if (isFenceCloser(text, lineStart, lineEnd, openFence)) {
+ regions.fill(REGION.FENCE_MARKER, lineStart, lineEnd);
+ openFence = null;
+ } else {
+ regions.fill(REGION.FENCE_BODY, lineStart, Math.min(lineEnd + 1, n));
+ }
+ } else {
+ const contentEnd =
+ lineEnd > lineStart && text[lineEnd - 1] === "\r"
+ ? lineEnd - 1
+ : lineEnd;
+ const line = text.slice(lineStart, contentEnd);
+ const opener = line.match(FENCE_OPENER_PATTERN);
+ if (opener) {
+ const markerChar = opener[2][0] as "`" | "~";
+ // A backtick fence's info string cannot contain a backtick; such a
+ // line is inline code instead
+ if (markerChar === "~" || !opener[3].includes("`")) {
+ paintFenceOpener(
+ regions,
+ lineStart,
+ lineEnd,
+ opener[1].length,
+ opener[2].length
+ );
+ openFence = { char: markerChar, length: opener[2].length };
+ }
+ }
+ }
+
+ lineStart = lineEnd + 1;
+ }
+
+ return openFence;
+};
+
+const measureBacktickRun = (text: string, start: number): number => {
+ let end = start + 1;
+ while (end < text.length && text[end] === "`") {
+ end += 1;
+ }
+ return end;
+};
+
+// A blank line ends the paragraph, and with it any chance of closing a span
+const isParagraphBreakAt = (text: string, newlineIndex: number): boolean => {
+ let j = newlineIndex + 1;
+ while (
+ j < text.length &&
+ (text[j] === " " || text[j] === "\t" || text[j] === "\r")
+ ) {
+ j += 1;
+ }
+ return j < text.length && text[j] === "\n";
+};
+
+// Paint inline code spans in the regions the fence pass left as prose
+const paintSpans = (text: string, regions: Uint8Array): OpenSpan | null => {
+ const n = text.length;
+ let spanStart = -1;
+ let spanRunLength = 0;
+ let i = 0;
+
+ while (i < n) {
+ if (regions[i] !== REGION.PROSE) {
+ // A span cannot cross into a fence, so leave it marked open up to here
+ if (spanStart >= 0) {
+ regions.fill(REGION.CODE_SPAN_OPEN, spanStart, i);
+ spanStart = -1;
+ }
+ i += 1;
+ continue;
+ }
+ if (text[i] === "\n" && spanStart >= 0 && isParagraphBreakAt(text, i)) {
+ // The unmatched opener stays literal prose in its finished paragraph
+ spanStart = -1;
+ i += 1;
+ continue;
+ }
+ if (text[i] === "\\" && text[i + 1] === "`" && spanStart < 0) {
+ i += 2;
+ continue;
+ }
+ if (text[i] !== "`") {
+ i += 1;
+ continue;
+ }
+
+ const runEnd = measureBacktickRun(text, i);
+ const runLength = runEnd - i;
+ if (spanStart < 0) {
+ spanStart = i;
+ spanRunLength = runLength;
+ } else if (runLength === spanRunLength) {
+ regions.fill(REGION.CODE_SPAN, spanStart, runEnd);
+ spanStart = -1;
+ }
+ // A run of a different length is literal inside the open span
+ i = runEnd;
+ }
+
+ if (spanStart >= 0) {
+ regions.fill(REGION.CODE_SPAN_OPEN, spanStart, n);
+ return { start: spanStart, runLength: spanRunLength };
+ }
+ return null;
+};
+
+const scanText = (text: string): TextScan => {
+ const regions = new Uint8Array(text.length);
+ const openFence = paintFences(text, regions);
+ const openSpan = paintSpans(text, regions);
+ return {
+ text,
+ regions,
+ openFence,
+ openSpan,
+ mathMask: null,
+ linkUrlMask: null,
+ htmlTagMask: null,
+ };
+};
+
+// Memoize the most recent scan. Handlers query many positions of the same
+// string, and remend's handler chain passes each handler's output to the
+// next, so a single-entry cache gives O(1) queries within a handler while
+// staying O(n) per handler overall.
+let cachedText: string | null = null;
+let cachedScan: TextScan | null = null;
+
+export const getScan = (text: string): TextScan => {
+ if (cachedScan !== null && text === cachedText) {
+ return cachedScan;
+ }
+ const scan = scanText(text);
+ cachedText = text;
+ cachedScan = scan;
+ return scan;
+};
+
+/** A code construct here means a fence or inline span. */
+export const isCodeAt = (scan: TextScan, position: number): boolean => {
+ if (position >= scan.regions.length) {
+ return scan.openFence !== null || scan.openSpan !== null;
+ }
+ if (position < 0) {
+ return false;
+ }
+ return scan.regions[position] !== REGION.PROSE;
+};
+
+/** Whether the position is inside a fenced code block (marker, info, or body) */
+export const isFenceAt = (scan: TextScan, position: number): boolean => {
+ if (position >= scan.regions.length) {
+ return scan.openFence !== null;
+ }
+ if (position < 0) {
+ return false;
+ }
+ const region = scan.regions[position];
+ return (
+ region === REGION.FENCE_MARKER ||
+ region === REGION.FENCE_INFO ||
+ region === REGION.FENCE_BODY
+ );
+};
+
+export const isCompleteSpanAt = (scan: TextScan, position: number): boolean =>
+ scan.regions[position] === REGION.CODE_SPAN;
+
+/** Counts non-overlapping double-character pairs (**, ~~, $$) in prose */
+export const countDoublePairs = (text: string, char: string): number => {
+ const scan = getScan(text);
+ let count = 0;
+
+ for (let i = 0; i < text.length; i += 1) {
+ if (scan.regions[i] !== REGION.PROSE) {
+ continue;
+ }
+ if (text[i] === char && i + 1 < text.length && text[i + 1] === char) {
+ count += 1;
+ i += 1;
+ }
+ }
+ return count;
+};
+
+// The masks share the empty array when their trigger character is absent, so
+// plain prose skips three allocations and passes per scan
+const EMPTY_MASK = new Uint8Array(0);
+
+type MathContext =
+ | "none"
+ | "inlineDollar"
+ | "blockDollar"
+ | "inlineLatex"
+ | "blockLatex";
+
+const isLatexMathContext = (context: MathContext): boolean =>
+ context === "inlineLatex" || context === "blockLatex";
+
+const getLatexMathContext = (
+ context: MathContext,
+ nextChar: string
+): MathContext | null => {
+ if (nextChar === "[" && context === "none") {
+ return "blockLatex";
+ }
+ if (nextChar === "]" && context === "blockLatex") {
+ return "none";
+ }
+ if (nextChar === "(" && context === "none") {
+ return "inlineLatex";
+ }
+ if (nextChar === ")" && context === "inlineLatex") {
+ return "none";
+ }
+ return null;
+};
+
+const getDollarMathContext = (
+ context: MathContext,
+ isBlockDelimiter: boolean
+): MathContext => {
+ if (isBlockDelimiter) {
+ return context === "blockDollar" ? "none" : "blockDollar";
+ }
+ if (context === "blockDollar") {
+ return context;
+ }
+ return context === "inlineDollar" ? "none" : "inlineDollar";
+};
+
+const hasMathDelimiters = (text: string): boolean =>
+ text.includes("$") || text.includes("\\(") || text.includes("\\[");
+
+// Recognizes a math delimiter or escaped dollar at position i, returning the
+// context after it and the number of characters it spans
+interface MathDelimiter {
+ /** Context in effect after the delimiter */
+ context: MathContext;
+ /** Number of characters the delimiter spans */
+ length: 1 | 2;
+}
+
+const mathDelimiterAt = (
+ text: string,
+ i: number,
+ context: MathContext
+): MathDelimiter | null => {
+ const next = text[i + 1];
+ if (text[i] === "\\") {
+ if (next === "$") {
+ return { context, length: 2 };
+ }
+ const latexContext = getLatexMathContext(context, next);
+ return latexContext === null ? null : { context: latexContext, length: 2 };
+ }
+ if (text[i] === "$" && !isLatexMathContext(context)) {
+ const isBlockDelimiter = next === "$";
+ return {
+ context: getDollarMathContext(context, isBlockDelimiter),
+ length: isBlockDelimiter ? 2 : 1,
+ };
+ }
+ return null;
+};
+
+// Math mask: for each position, whether it is inside $...$, $$...$$,
+// \(...\) or \[...\]. Delimiters inside code regions are literal and do
+// not change math state. A two-character delimiter marks its second
+// character with the context it establishes.
+const buildMathMask = (scan: TextScan): Uint8Array => {
+ const { text, regions } = scan;
+ const n = text.length;
+ const mask = new Uint8Array(n);
+ let context: MathContext = "none";
+
+ let i = 0;
+ while (i < n) {
+ mask[i] = context === "none" ? 0 : 1;
+ const delimiter: MathDelimiter | null =
+ regions[i] === REGION.PROSE ? mathDelimiterAt(text, i, context) : null;
+ if (delimiter === null) {
+ i += 1;
+ continue;
+ }
+ context = delimiter.context;
+ if (delimiter.length === 2) {
+ mask[i + 1] = context === "none" ? 0 : 1;
+ }
+ i += delimiter.length;
+ }
+
+ return mask;
+};
+
+export const inMathAt = (scan: TextScan, position: number): boolean => {
+ if (position < 0 || position >= scan.text.length) {
+ return false;
+ }
+ if (scan.mathMask === null) {
+ scan.mathMask = hasMathDelimiters(scan.text)
+ ? buildMathMask(scan)
+ : EMPTY_MASK;
+ }
+ return scan.mathMask[position] === 1;
+};
+
+// Marks the URL positions of one line: those between a "](" opener and the
+// next ")" on the line. Two sub-passes: backward to know whether a ")" still
+// follows a position, forward to know whether the nearest paren boundary
+// before a position is a "](" opener.
+const paintLinkUrlLine = (
+ scan: TextScan,
+ lineStart: number,
+ lineEnd: number,
+ mask: Uint8Array
+): void => {
+ const { text, regions } = scan;
+ // closerFollows[i - lineStart]: a ")" exists at or after i on this line
+ const closerFollows = new Uint8Array(lineEnd - lineStart);
+ let seenCloser = 0;
+ for (let i = lineEnd - 1; i >= lineStart; i -= 1) {
+ if (text[i] === ")" && regions[i] === REGION.PROSE) {
+ seenCloser = 1;
+ }
+ closerFollows[i - lineStart] = seenCloser;
+ }
+
+ let inUrl = false;
+ for (let i = lineStart; i < lineEnd; i += 1) {
+ if (inUrl && closerFollows[i - lineStart] === 1) {
+ mask[i] = 1;
+ }
+ if (regions[i] !== REGION.PROSE) {
+ continue;
+ }
+ if (text[i] === ")") {
+ inUrl = false;
+ } else if (text[i] === "(") {
+ inUrl = i > 0 && text[i - 1] === "]";
+ }
+ }
+};
+
+// Link/image URL mask: positions inside the (url) part of [text](url).
+// Delimiters inside code regions are literal and never open or close a URL.
+const buildLinkUrlMask = (scan: TextScan): Uint8Array => {
+ const { text } = scan;
+ const n = text.length;
+ const mask = new Uint8Array(n);
+ let lineStart = 0;
+
+ while (lineStart < n) {
+ let lineEnd = text.indexOf("\n", lineStart);
+ if (lineEnd === -1) {
+ lineEnd = n;
+ }
+ paintLinkUrlLine(scan, lineStart, lineEnd, mask);
+ lineStart = lineEnd + 1;
+ }
+
+ return mask;
+};
+
+export const inLinkUrlAt = (scan: TextScan, position: number): boolean => {
+ if (position < 0 || position >= scan.text.length) {
+ return false;
+ }
+ if (scan.linkUrlMask === null) {
+ scan.linkUrlMask = scan.text.includes("](")
+ ? buildLinkUrlMask(scan)
+ : EMPTY_MASK;
+ }
+ return scan.linkUrlMask[position] === 1;
+};
+
+// HTML tag mask: positions after a "<" that begins a plausible tag (letter
+// or /), through the closing ">" inclusive, within a single line. Angle
+// brackets inside code regions are literal and never open or close a tag.
+const buildHtmlTagMask = (scan: TextScan): Uint8Array => {
+ const { text, regions } = scan;
+ const n = text.length;
+ const mask = new Uint8Array(n);
+ let inTag = false;
+
+ for (let i = 0; i < n; i += 1) {
+ if (text[i] === "\n") {
+ inTag = false;
+ continue;
+ }
+ mask[i] = inTag ? 1 : 0;
+ if (regions[i] !== REGION.PROSE) {
+ continue;
+ }
+ if (text[i] === ">") {
+ inTag = false;
+ } else if (text[i] === "<") {
+ const next = text[i + 1];
+ inTag =
+ next !== undefined &&
+ ((next >= "a" && next <= "z") ||
+ (next >= "A" && next <= "Z") ||
+ next === "/");
+ }
+ }
+
+ return mask;
+};
+
+export const inHtmlTagAt = (scan: TextScan, position: number): boolean => {
+ if (position < 0 || position >= scan.text.length) {
+ return false;
+ }
+ if (scan.htmlTagMask === null) {
+ scan.htmlTagMask = scan.text.includes("<")
+ ? buildHtmlTagMask(scan)
+ : EMPTY_MASK;
+ }
+ return scan.htmlTagMask[position] === 1;
+};
diff --git a/packages/remend/src/strikethrough-handler.ts b/packages/remend/src/strikethrough-handler.ts
index bbb22dd4..61e9e2a8 100644
--- a/packages/remend/src/strikethrough-handler.ts
+++ b/packages/remend/src/strikethrough-handler.ts
@@ -3,11 +3,16 @@ import {
isWithinCompleteInlineCode,
} from "./code-block-utils";
import {
- doubleTildeGlobalPattern,
halfCompleteTildePattern,
strikethroughPattern,
whitespaceOrMarkersPattern,
} from "./patterns";
+import { countDoublePairs } from "./scan";
+
+// Tilde runs that open or close a fence are painted as fence regions by the
+// scanner, so a line-start ~~~ fence never counts as strikethrough while a
+// mid-line tilde run still does.
+const countDoubleTildes = (text: string): number => countDoublePairs(text, "~");
// Completes incomplete strikethrough formatting (~~)
export const handleIncompleteStrikethrough = (text: string): string => {
@@ -34,9 +39,7 @@ export const handleIncompleteStrikethrough = (text: string): string => {
return text;
}
- // doubleTildeGlobalPattern always matches when strikethroughPattern matched
- const tildePairs = text.match(doubleTildeGlobalPattern)?.length;
- if (tildePairs % 2 === 1) {
+ if (countDoubleTildes(text) % 2 === 1) {
return `${text}~~`;
}
} else {
@@ -52,9 +55,7 @@ export const handleIncompleteStrikethrough = (text: string): string => {
) {
return text;
}
- // doubleTildeGlobalPattern always matches when halfCompleteTildePattern matched
- const tildePairs = text.match(doubleTildeGlobalPattern)?.length;
- if (tildePairs % 2 === 1) {
+ if (countDoubleTildes(text) % 2 === 1) {
return `${text}~`;
}
}
diff --git a/packages/remend/src/utils.ts b/packages/remend/src/utils.ts
index fbb96c0c..06291fd0 100644
--- a/packages/remend/src/utils.ts
+++ b/packages/remend/src/utils.ts
@@ -1,4 +1,5 @@
import { letterNumberUnderscorePattern } from "./patterns";
+import { getScan, inHtmlTagAt, inLinkUrlAt, inMathAt, isFenceAt } from "./scan";
// OPTIMIZATION: Precompute which characters are word characters
// Using ASCII fast path before falling back to Unicode regex
@@ -20,20 +21,8 @@ export const isWordChar = (char: string): boolean => {
return letterNumberUnderscorePattern.test(char);
};
-// Check if a position is within a code block (between ``` markers)
-export const isWithinCodeBlock = (text: string, position: number): boolean => {
- let inCodeBlock = false;
-
- for (let i = 0; i < position; i += 1) {
- // Check for triple backticks
- if (text[i] === "`" && text[i + 1] === "`" && text[i + 2] === "`") {
- inCodeBlock = !inCodeBlock;
- i += 2; // Skip the next two backticks
- }
- }
-
- return inCodeBlock;
-};
+export const isWithinCodeBlock = (text: string, position: number): boolean =>
+ isFenceAt(getScan(text), position);
// Helper function to find the matching opening bracket for a closing bracket
// Handles nested brackets correctly by searching backwards
@@ -75,147 +64,21 @@ export const findMatchingClosingBracket = (
return -1; // No matching bracket found
};
-type MathContext =
- | "none"
- | "inlineDollar"
- | "blockDollar"
- | "inlineLatex"
- | "blockLatex";
-
-const isLatexMathContext = (context: MathContext): boolean =>
- context === "inlineLatex" || context === "blockLatex";
-
-const getLatexMathContext = (
- context: MathContext,
- nextChar: string
-): MathContext | null => {
- if (nextChar === "[" && context === "none") {
- return "blockLatex";
- }
- if (nextChar === "]" && context === "blockLatex") {
- return "none";
- }
- if (nextChar === "(" && context === "none") {
- return "inlineLatex";
- }
- if (nextChar === ")" && context === "inlineLatex") {
- return "none";
- }
- return null;
-};
-
-const getDollarMathContext = (
- context: MathContext,
- isBlockDelimiter: boolean
-): MathContext => {
- if (isBlockDelimiter) {
- return context === "blockDollar" ? "none" : "blockDollar";
- }
- if (context === "blockDollar") {
- return context;
- }
- return context === "inlineDollar" ? "none" : "inlineDollar";
-};
-
-// Check if a position is within a math block (between $, $$, \(, or \[)
-export const isWithinMathBlock = (text: string, position: number): boolean => {
- let mathContext: MathContext = "none";
-
- for (let i = 0; i < text.length && i < position; i += 1) {
- // Skip escaped dollar signs
- if (text[i] === "\\" && text[i + 1] === "$") {
- i += 1; // Skip the next character
- continue;
- }
-
- if (text[i] === "\\") {
- const nextContext = getLatexMathContext(mathContext, text[i + 1]);
- if (nextContext !== null) {
- mathContext = nextContext;
- i += 1;
- continue;
- }
- }
-
- if (text[i] === "$" && !isLatexMathContext(mathContext)) {
- const isBlockDelimiter = text[i + 1] === "$";
- mathContext = getDollarMathContext(mathContext, isBlockDelimiter);
- if (isBlockDelimiter) {
- i += 1; // Skip the second $
- }
- }
- }
-
- return mathContext !== "none";
-};
-
-// Helper to check if position is before closing paren on same line
-const isBeforeClosingParen = (text: string, position: number): boolean => {
- for (let j = position; j < text.length; j += 1) {
- if (text[j] === ")") {
- return true;
- }
- if (text[j] === "\n") {
- return false;
- }
- }
- return false;
-};
+// Check if a position is within a math block (between $ or $$)
+export const isWithinMathBlock = (text: string, position: number): boolean =>
+ inMathAt(getScan(text), position);
// Check if a position is within a link or image URL
// Links and images have the format [text](url) or 
export const isWithinLinkOrImageUrl = (
text: string,
position: number
-): boolean => {
- // Search backwards from position to find if we're inside a (url) part
- for (let i = position - 1; i >= 0; i -= 1) {
- if (text[i] === ")") {
- return false;
- }
- if (text[i] === "(") {
- // Check if there's a ] immediately before the (
- if (i > 0 && text[i - 1] === "]") {
- // We're potentially inside a link/image URL
- // Check if we're before the closing )
- return isBeforeClosingParen(text, position);
- }
- return false;
- }
- if (text[i] === "\n") {
- return false;
- }
- }
-
- return false;
-};
+): boolean => inLinkUrlAt(getScan(text), position);
// Check if a position is within an HTML tag (between < and >)
// e.g. — the underscore in _blank is inside the tag
-export const isWithinHtmlTag = (text: string, position: number): boolean => {
- // Search backwards from position to find < or >
- for (let i = position - 1; i >= 0; i -= 1) {
- if (text[i] === ">") {
- return false; // Found closing > first — we're outside a tag
- }
- if (text[i] === "<") {
- // Found opening < — check it starts a valid tag (followed by letter or /)
- const nextChar = i + 1 < text.length ? text[i + 1] : "";
- if (
- (nextChar >= "a" && nextChar <= "z") ||
- (nextChar >= "A" && nextChar <= "Z") ||
- nextChar === "/"
- ) {
- return true;
- }
- return false;
- }
- if (text[i] === "\n") {
- return false; // Tags don't span lines in this context
- }
- }
- return false;
-};
+export const isWithinHtmlTag = (text: string, position: number): boolean =>
+ inHtmlTagAt(getScan(text), position);
// Check if a marker sequence appears to be a horizontal rule
// Horizontal rules must be on their own line with optional leading/trailing whitespace
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 96d45570..7bb7ef99 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -268,6 +268,9 @@ importers:
'@vitest/coverage-v8':
specifier: ^4.1.10
version: 4.1.10(vitest@4.1.10)
+ fast-check:
+ specifier: ^4.9.0
+ version: 4.9.0
mdast-util-from-markdown:
specifier: ^2.0.3
version: 2.0.3
@@ -4961,6 +4964,10 @@ packages:
extendable-error@0.1.7:
resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==}
+ fast-check@4.9.0:
+ resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==}
+ engines: {node: '>=12.17.0'}
+
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -6223,6 +6230,9 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
+ pure-rand@8.4.2:
+ resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==}
+
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
@@ -11636,6 +11646,10 @@ snapshots:
extendable-error@0.1.7: {}
+ fast-check@4.9.0:
+ dependencies:
+ pure-rand: 8.4.2
+
fast-deep-equal@3.1.3: {}
fast-glob@3.3.3:
@@ -13261,6 +13275,8 @@ snapshots:
punycode@2.3.1: {}
+ pure-rand@8.4.2: {}
+
quansync@0.2.11: {}
queue-microtask@1.2.3: {}