Skip to content

fix: escape a character only where it can still be syntax - #263

Merged
Azganoth merged 2 commits into
mainfrom
bug/precise-serializer-escaping
Aug 16, 2026
Merged

fix: escape a character only where it can still be syntax#263
Azganoth merged 2 commits into
mainfrom
bug/precise-serializer-escaping

Conversation

@Azganoth

@Azganoth Azganoth commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Summary

serializeMarkdownText still delegates to remark-stringify's state.safe, then decodes the result into characters and escape flags and clears the flags it can prove unnecessary from the text around them:

  • Attention (*, _) — builds the CommonMark delimiter runs and their flanking. A run that can neither open nor close loses its escape wherever it appears, which is the intraword _ case. A run that can only open, or only close, loses it when the phrasing content holds no counterpart run to reach.
  • [ — loses its escape when no ] can close it, in the same node or a later sibling.
  • ( — loses its escape when the ] before it closes no bracket that was left open, so \[a]\(b) becomes \[a](b).

Sibling scanning is limited to paragraph, heading, and tableCell parents, whose children are a whole line of inline content. Inside a link label or an emphasis the pairing markers are not in any sibling value, so only the checks that read the characters beside a run apply there. A non-text sibling counts as carrying markers, so anything the analysis cannot decide falls back to the existing escaping rather than to none.

Three test fixtures moved to escapes that are still load-bearing under the precise rule, since their subject is that an escape survives rather than which character carries it: [plain \*literal\* and **bold**](…) for a literal beside a mark, <p>*a*</p> for a paste into a projected label, and [\*literal\* and **bold**](…) for selection mapping through escaped text. The two footnote assertions now compare the whole serialization instead of matching on a backslash.

Related Issue

Closes #252

Verification

src/features/editor/tests/markdownCompatibility.test.tsx gains an Escape precision block asserting both directions: eleven documents that must save without an escape, covering every row of the issue's table, the sibling cases the same rules reach, and the full escapable-punctuation run from corpus/commonmark/text-and-breaks.md; and twenty-two that must keep one, covering the context-aware backslash cases a \ b, C:\Users\me, \\#, and \\[, and twelve of the fourteen escape forms #245 verified.

The thirteenth, \], is asserted in the other direction, since state.safe never writes it. The fourteenth, an escaped definition label, cannot be asserted while #260 inlines the definition into its reference and deletes the line: [\*label\*]: /url with [\*label\*] saves as [\*label\*](/url). The escape survives that rewrite, so what is missing is the assertion rather than the behavior.

src/features/editor/tests/corpusRoundTrip.test.tsx still converges on all sixteen scoped files.

Measured by driving the editor mount used by the plugin tests, stashing this commit's handler to get the baseline: backslashes written across the sixteen scoped corpus files fall from 289 to 173. commonmark/emphasis.md goes from 106 to 23, commonmark/links-and-images.md from 51 to 35, commonmark/html.md from 5 to 2, and gfm/autolinks.md from 6 to 4.

corpus/commonmark/links-and-images.md does not round-trip byte-identically, which #252 asked for or for the reason to be recorded. The reason is that most of its difference is not escaping: reference definitions are inlined into their references and the definition lines disappear (#260), link titles normalize to double quotes (#261), formatting and nested images inside an image description flatten (#259), and character-reference destinations decode (#262). Its escape-caused remainder is filed as #257 and #258. The one line the issue named, \[intentionally literal](garden.md), now matches the corpus byte for byte.

Not verified: no manual pass over the application. The change is confined to serialization and every scenario is reachable from the editor mount.

Notes

The relaxation is one-directional by construction — it only ever removes an escape it can prove unnecessary, and every uncertain case keeps today's behavior — so the exposure is a proof that is subtly wrong rather than a case that is missed. The flanking implementation carries that risk and the twenty keep-cases plus corpus convergence are what hold it.

One conservative limit worth knowing about, since it is visible in the corpus: in \[outer [inner](inner.md) text]\(outer.md) the ( keeps its escape because the [ that would need to be open sits in an earlier sibling, whose escape state this pass cannot see. The output is correct, just not minimal.

Follow-ups measured while doing this and filed rather than folded in: #256 (tildes, the same mechanism one character set away and the largest remaining escape class), #257 and #258 (escapes that need the candidate construct parsed rather than the neighbouring characters classified), and #259 through #262 (the document-level and authored-form differences in the same corpus file). #241 keeps \@, \., and \:, which #252 scoped out.

`state.safe` answers whether a character could be a marker somewhere, not
whether one can form where it sits, and its unsafe patterns cannot express
the difference: emphasis depends on a counterpart run elsewhere in the
phrasing content, and a `[` depends on a `]` that may be several siblings
away. So the handler keeps calling `state.safe` and relaxes its result,
which also keeps every case the analysis cannot decide falling back to the
existing escaping rather than to none.

The sibling scan is limited to parents that hold a whole line of inline
content. Inside a link label or an emphasis, the markers that would pair
with a run are not in any sibling value, so only the checks that read the
characters beside the run apply there.
@Azganoth Azganoth added the Bug Something isn't working label Aug 16, 2026
@Azganoth Azganoth self-assigned this Aug 16, 2026
`\!` is escaped only before a `[`, and breaking the image there leaves the
link beneath it live, so its fixture holds a literal bang and a live link
rather than literal text. `\|` is escaped only inside a table cell, so its
fixture carries the column padding the serializer writes.
@Azganoth
Azganoth merged commit f409cdb into main Aug 16, 2026
3 checks passed
@Azganoth
Azganoth deleted the bug/precise-serializer-escaping branch August 16, 2026 20:36
Azganoth added a commit that referenced this pull request Aug 18, 2026
## Summary

Typing `[a](b)`, `https://example.com`, or `<https://example.com>` left
literal text while pasting the same characters created a link. A run of
text that is exactly one link's source now becomes that link when the
caret leaves it, for inline links, autolink literals, and URI autolinks.

- The run is never projected. It is already the source, so the caret
sitting in it changes nothing; the link adapter validates it through the
same `parseLinkSource` that already turns edited projected source into a
link, and the engine's existing `appendTransaction` trigger commits it.
- The caret has left once whitespace, a line break, or another block
separates it from the run. Until then the next character can still move
where the source ends, which is what keeps a bare URL whole while it is
typed: the parser reads `https://example.com/path.` as a link stopping
before the dot the author is still typing.
- Only source the session wrote commits, and never a run the file
escaped. A file can hold escaped source because its author wanted the
characters, and the escape does not survive parsing, so the document
alone cannot tell the two apart. The engine records the ranges the
session writes, and the source run a write lands in while the document
there still reads as the file wrote it; a commit needs the first and
must avoid the second.
- A run an escape spent in projection also stays literal. #265 makes a
backslash typed into projected source commit the run as the text it
spells, and that text is exactly the source this rule commits, written
by the session and named by nothing else. The commit that spends an
escape records the range it wrote, which puts the run where a
file-escaped one already sits.
- History clears that record, so an undone commit stays undone rather
than returning on the next caret move.
- A committed link projects its source again whenever the caret returns,
so the visible text never changes.

## Related Issue

Closes #239
Refs #238, #265

## Verification

Focused tests in `sourceProjectionTypedLink.test.tsx` cover each form
committing when the caret leaves it and when the sentence continues past
it; a typed URL committing once at its full length; `Enter` and
`Shift+Enter`; backslash-escaped, incomplete, and code-block source
staying literal; typing and plain-text paste of the same characters
reaching the same document; a committed link projecting its source
again, and the link the caret lands in projecting while the run it left
commits.

Nine tests pin the escape hatch. Source the file escaped stays literal
through a caret visit, through an edit elsewhere in its paragraph,
through an edit inside it, through one change that writes into it twice,
and through a word dropped into it by either a move or a copy, while
replacing it outright commits it. Source an escape spent in projection
stays literal through repeated caret moves and through an edit inside
it, matching what the file-escaped run does. One guards the gesture that
the strict alternative would have broken: source written by hand around
words the file already held still commits. One pins reversal: `Undo`
returns a committed link to the source it was written as, and a caret
move afterwards leaves it reverted.

`markdownCompatibility.test.tsx` carries #234's typed-source fixtures
forward. With the caret still on the source, all three forms still
serialize escaped; once a space follows, the same fixtures assert the
live form, which is the behavior this change accepts.

Nine assertions in `sourceProjectionTypedLink.test.tsx` moved to the
single escape #263 now writes, from `\[test link]\(./test.html)` to
`\[test link](./test.html)`. Each still asserts what it always did, that
the run stays literal, since the subject is literalness rather than
which character carries the escape. The label split by a line ending
keeps its pair, where the opening bracket sits in an earlier sibling
than that analysis reads.

Measured the added cost against `main` by driving the editor mount used
by the plugin tests, medians of seven 120-operation batches per
workload, each in its own mount (ms per keystroke or caret move):

| Workload | `main` | This branch |
| --- | --- | --- |
| Typing in a 600-character prose paragraph | 0.140 | 0.150 |
| Typing in a paragraph holding link syntax | 0.098 | 0.196 |
| Typing in a 10k-character paragraph whose link syntax is far from the
caret | 0.066 | 0.078 |
| Typing in a paragraph of 200 links | 1.194 | 1.191 |
| Caret moves through an edited document | 0.204 | 0.277 |
| First character written into an untouched region | 0.310 | 0.694 |

Not verified: those figures were taken at `f0d1da57`, before the rebase
onto #263, #267, and #268, and they were not taken again after it. What
those releases change is what the serializer writes and what reaches
projected source, while the scan these numbers describe parses and is
untouched. Also not verified: the desktop E2E suite and manual
verification in the Tauri application. The rest of the change is
document text and serialization, both observable in the automated DOM
tests.

## Notes

Caret-leave is the only trigger, including when the caret leaves by
writing. Typing a space after `[a](b)` commits it, because that space is
what puts the caret off the run; without that, source typed mid-sentence
would never commit, since the caret leaves such a run by writing rather
than by moving. #239 rules out an eager commit on a boundary character,
and no boundary character is a trigger here: the same separation rule
decides every case.

`Enter` and `Shift+Enter` commit through that rule rather than through
`finalizeSourceProjection`, which serves an active session and there is
none. The line break separates the caret from the run, so the behavior
#239 asks for holds without a second path.

The escape-hatch tests exist because the first implementation broke
#238's shared decision that `\[a](b)` round-trips as literal text: a
caret passing through `corpus/commonmark/links-and-images.md`'s
`\[intentionally literal](garden.md)` converted it and the save rewrote
the file. Requiring the whole run to be written would close that hole
too, but it refuses source wrapped by hand around words the file already
held, so the run a write lands in is recorded instead. Recognizing it
costs the extra time in the last row of the table, once per region
rather than once per keystroke. It locates the change by its two ends,
which name the same place in the document before and after it however
many steps ran, and stands down under an active projection, where the
source belongs to the engine rather than to the file.

Standing down there is also why an escape spent in projection needs the
commit to report itself. The engine writes that run while its own
session is still open, so nothing classifies it as source a file holds,
and the record the commit leaves is what a later caret move reads. The
condition is that the commit consumed an escape rather than that it
committed literal text at all: an author who edits projected source into
`[a](b) x` typed those characters, and committing that run is this rule
working rather than failing.

An edit inside a protected run leaves it protected here, as it already
did for source the file escaped, and only replacing the run outright
commits it. If writing into a run was meant to spend its escape instead,
the assertion that pins it is the one to change.

Before parsing a text block, the adapter looks for link markers within
1000 characters of the caret. That radius is a ceiling rather than a
guarantee: source whose markers all sit further away, or a form the
marker pattern does not describe, stays the literal text it already was.
Without it, a URL anywhere in a 10k-character paragraph cost 1.2 ms of
parsing per keystroke; the table above is measured with it.

The written-range record grows with the number of distinct places a
session edits, not with the number of edits — continuous typing merges
into one range. Two thousand scattered single-character edits leave 1911
ranges and add about 0.1 ms per keystroke. It is uncapped: every safe
drop policy loses coverage, and the cost of growth is gradual rather
than a cliff, while dropping the wrong range would withhold a commit the
author expects.

What the engine now knows, the author cannot see. A run the file escaped
and a run typed a moment ago read identically, yet only one of them
commits, and the escape has no character to remove: converting a
protected run means deleting it and retyping it. The consequence reaches
the clipboard too. An in-app copy carries characters rather than
literalness, so pasting a copy of literal link source now commits it to
a link where it stayed literal before, and the Markdown flavor of such a
copy is written unescaped whatever this branch does. #255 supplies the
gesture that answers the first half, and #244 covers the clipboard
writing live source. Neither is a precondition for this change: the
protections here hold without them, and both were reachable before it.

The changelog's #234 entry claimed typed source stays literal when a
space follows it. It now describes the escaping fix that entry was
really about, since the space case is what this change alters.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The serializer escapes characters that cannot be syntax where they sit

1 participant