diff --git a/src/Predict.ts b/src/Predict.ts index bf845ca9d2028c11ccb0c297339e1bc9fa384bc7..998f6e2c2f6e0a300a277b5bc18aa8ab1cf46b64 100644 --- a/src/Predict.ts +++ b/src/Predict.ts @@ -35,6 +35,7 @@ interface TailState { delims: DelimState[]; exclusive: ExclusiveState | null; links: LinkState[]; + pendingDelim: DelimState | null; } const NESTABLE_DELIMS = new Set(["*", "**", "_", "__"]); @@ -45,6 +46,7 @@ function parseTail(tail: string): TailState { delims: [], exclusive: null, links: [], + pendingDelim: null, }; for (let i = 0; i < tail.length; i++) { @@ -179,6 +181,11 @@ function consumeDelim(state: TailState, tail: string, start: number) { return start + token.length; } + if (start + token.length === tail.length) { + state.pendingDelim = { start, token }; + return start + token.length; + } + if (!canOpenDelim(tail, start, token)) return start + token.length; state.delims.push({ start, token }); @@ -277,14 +284,16 @@ function renderTail(tail: string, state: TailState) { if (nestedClosers) return appendCloser(tail, nestedClosers); const open = findLastOpen(state); - if (!open) return tail; + if (!open) return state.pendingDelim ? tail.slice(0, state.pendingDelim.start) : tail; if (open.kind === "delim") { - if (!hasContentAfter(tail, open.start, open.token.length)) return tail; + if (!hasContentAfter(tail, open.start, open.token.length)) return tail.slice(0, open.start); return appendCloserBeforeTrailingInlineWhitespace(tail, open.token); } - if (!hasContentAfter(tail, open.start, open.token.length)) return tail; + if (!hasContentAfter(tail, open.start, open.token.length)) { + return open.kind === "fence" ? tail : tail.slice(0, open.start); + } if (open.kind === "fence") return tail; if (open.kind === "code") return appendCloser(tail, open.token); if (open.token === "$$") return appendCloser(tail, (tail.endsWith("\n") ? "" : "\n") + "$$"); @@ -296,7 +305,7 @@ function renderOpenLink(tail: string, state: LinkState) { const before = tail.slice(0, state.start); if (state.phase === "text") { - if (!hasContentAfter(tail, state.start, 1)) return tail; + if (!hasContentAfter(tail, state.start, 1)) return before; return before + tail.slice(state.start + 1); } diff --git a/tests/prediction.test.ts b/tests/prediction.test.ts index ac967ef68bd5138928d8a6bef22fd5be457ee5ed..e7344395f458757442735e9a6759158084bba1b5 100644 --- a/tests/prediction.test.ts +++ b/tests/prediction.test.ts @@ -177,6 +177,10 @@ describe.each(["stateless", "stateful"] as const)("%s processing", (mode) => { ])("%s", (_name, input, expected) => check(input, expected)); describe("whitespace around emphasis delimiters", () => { + test.each(["* ", "** ", "_ ", "__ ", "~~ "])("keeps disqualified opener %j visible", (input) => + check(input, input), + ); + test.each([ "hello * world", "hello* world", @@ -329,14 +333,14 @@ describe.each(["stateless", "stateful"] as const)("%s processing", (mode) => { describe("opener-only inputs", () => { test.each([ - ["lone italic delimiter", "*", "*"], - ["lone bold delimiter", "**", "**"], - ["lone underscore delimiter", "_", "_"], - ["lone double underscore delimiter", "__", "__"], - ["lone strike delimiter", "~~", "~~"], - ["lone code delimiter", "`", "`"], - ["lone inline math delimiter", "$", "$"], - ["bare open bracket", "[", "["], + ["lone italic delimiter", "*", ""], + ["lone bold delimiter", "**", ""], + ["lone underscore delimiter", "_", ""], + ["lone double underscore delimiter", "__", ""], + ["lone strike delimiter", "~~", ""], + ["lone code delimiter", "`", ""], + ["lone inline math delimiter", "$", ""], + ["bare open bracket", "[", ""], ["completed label without url at bol", "[x]", "x"], ["bare url opener at bol", "[x](", "x"], ])("%s", (_name, input, expected) => check(input, expected));