🐛 FIX: attrs: unterminated groups, class accumulation, closing-token merge - #153
Open
chrisjsewell wants to merge 6 commits into
Open
🐛 FIX: attrs: unterminated groups, class accumulation, closing-token merge#153chrisjsewell wants to merge 6 commits into
chrisjsewell wants to merge 6 commits into
Conversation
`parse()` returned success when the scanner ran off the end of the string without reaching DONE, so callers advanced past the end of the inline range with an empty attribute dict and the group, plus anything after it, never entered the token stream. It now raises `ParseError`, which all three call sites already catch and decline. A `}` also ends a `%` comment now, as the plugin docstring already promises and as djot does, so comments closed only by `}` keep working.
`_attr_inline_rule` looked for an existing class on `state.tokens[-1]`, which
for a span or a link is the closing token and never carries attributes, so a
second group replaced the first group's classes instead of joining them. It
now reads the opening token, the one `_add_attrs` writes to.
Concatenation is not de-duplicated, matching the existing image path
("merging attributes" gives `class="a b x x g"`) and djot, so the
"spans: merge attributes" row moves from `class="a b"` to `class="a a b"`.
When an attributes block was the last thing inside a container, the token
after it was that container's closing token, and merging onto it rendered
attributes into a closing tag: `> {.a}` alone in a blockquote produced
`</blockquote class="a">`. The merge is now skipped for a token with
negative nesting; the attributes block is still popped, so such a group is
dropped exactly as one with nothing after it already is.
`{k="a\"b"}` gives `k` the value `a\"b`: the escape lets the quote through
the scanner but is not stripped, and the docstring is the rendered docs page
(docs/index.md is `autofunction` only). The old sentence read as though the
backslash was removed. The example uses double backticks because RST eats the
backslash inside single backticks. A fixture row pins the behaviour.
The previous commit made a `}` end a `%` comment unconditionally. That
changed input accepted today: in `{% c } % .b}` the comment is closed by
the second `%` and `class="b"` applies, but ending the group at the first
`}` dropped the class and, at block level, let a line such as
`{% see } below %{x}` be consumed as an empty attribute block instead of
rendering as a paragraph.
A comment now ends at the next `%`; only when no `%` follows anywhere in
the rest of the string does the next `}` end both the comment and the
group. Every group that already terminated is unchanged, a comment closed
only by `}` still works without running off the end of the string, and
text after such a comment is kept. This matches neither reference exactly:
djot.js ends a comment at any `}`, the Lua original at none, and both would
change accepted input. The docstring is reworded to say what the code does.
The previous wording, "if no `%` follows", read as "within the attribute". The scan actually covers the rest of the scanned string: the line for a block attribute and the rest of the paragraph for an inline one, so a `%` in later prose keeps a comment open. Say so.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #153 +/- ##
==========================================
+ Coverage 92.80% 93.65% +0.85%
==========================================
Files 31 40 +9
Lines 1835 2286 +451
==========================================
+ Hits 1703 2141 +438
- Misses 132 145 +13
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
parse()treated running offthe end of the string as success, so
`a`{rendered<p><code>a</code></p>— the brace andeverything after it were deleted from the token stream. It now raises
ParseError, which all threecall sites already catch and decline. In the same change, a
%comment that no second%can closeis ended by
}, so comments closed only by}keep working — they only "work" today by running offthe end, which is the very path being removed.
and images.
[a]{.x}{.y}gaveclass="y"; it now givesclass="x y".> {.a}alone in a blockquoterendered
</blockquote class="a">; the group is now dropped, exactly as a group with nothing afterit already is.
Compatibility
Only input that v0.7.0 currently loses characters on is affected. That is not a claim by
assertion: it is measured twice, exhaustively, below under "Evidence".
Unterminated groups. Four shapes:
An unterminated group after an eligible element now renders literally instead of being deleted:
`a`{-><code>a</code>{,`a`{.a-><code>a</code>{.a,[a](u){,[a](u){.x,`a`{.a}{,`a`{k="x.`a`{.a\nmorerecovers a whole word, becauseparse()ishanded the rest of the inline source and the run-off consumed all of it.
`a`{ .a bshows therelated sub-case: attributes scanned out of an unterminated group are no longer applied
(
class="a"used to be applied while{ .a bwas deleted).[a]{and[a]{.xno longer produce a<span>at all; the text is left literal. This is a shapechange, not only recovered characters — and it is on the worst input of the set: at present the
span is created,
{.xis eaten, and the class it asks for is thrown away, because{.xcompiles to an empty attribute dict.
{k="a\}forms — a backslash-escaped}inside a quoted value, which therefore never terminates— now render literally instead of being consumed. (
{k="a}without the escape is unaffected: italready raised and rendered literally. Where such a group had already produced attributes, e.g.
{.a k="x\}, those attributes are no longer applied — that is shape 1.)%comments. A comment ends at the next%. Only if no%occurs anywhere in the rest of thescanned string does it end at the next
}, which then also ends the attribute group. That secondclause is what keeps comments closed only by
}working:`a`{%c},`a`{.a %c},[a]{%c},[a](u){%c},{%x}and{.a %c}above a paragraph all render exactly as they dotoday, and today they do so only because the scanner runs off the end. Where this changes anything,
it recovers text v0.7.0 deleted:
`a`{%a}bkeeps theb,`a`{.a %c} and {more}keepsand {more},`a`{%a}{.b}now applies the second group, and`a`{%c} tailkeeps itsspace (at v0.7.0
parse('{%c}')returned(4, {})because it ran off the end, and the caller'sstate.pos += new_pos + 1then over-advanced by one character; it now returns(3, {}), the indexof the
}, so the+ 1lands exactly past it).The rule is deliberately lazy, and the reason matters. Ending a comment at every
}— whatdjot.js does — changes input this plugin accepts today:
{% c } % .b}above a paragraph renders<p class="b">para</p>at v0.7.0 and would silently lose the class, and because the "was the wholeline consumed" check in
_attr_block_ruleis disabled,{% see } below %{x}on its own line —a paragraph today — would be consumed and render to nothing. The lazy rule leaves both exactly as
they are.
Its cost, stated plainly: a
%later in the same paragraph (for an inline group) or the same line (for a block group) keeps a brace-closed comment open, so`a`{.a %c} and 100% surenow renders literally as<code>a</code>{.a %c} and 100% sureinstead of applying
class="a"and swallowing the tail. Nothing is lost, but nothing is appliedeither. A fixture row pins that, titled
comment: a later percent keeps a brace-closed comment open.Class accumulation. Span and link join classes across groups now:
[a]{.x}{.y}->class="x y",[a](u){.x}{.y}{.z}->class="x y z", and[a]{.x #p}{.y #q}->id="q" class="x y"(idwas already last-wins and stays that way; ordinary keys are stilllast-wins). Inline code and images already behaved this way and are untouched. Concatenation is not
de-duplicated:
[a]{#a .a}{#b .a .b other=c}{other=d}givesclass="a a b". That matches theexisting image behaviour in this repo (the
merging attributesfixture expectsclass="a b x x g")and djot's
insert_attribute, which appends with a space and no de-duplication — so the one existingfixture row this PR changes,
spans: merge attributes, has its expectedclass="a b"updated toclass="a a b". That is the only existing expectation that moves.Closing-token merge. Every output this changes is invalid HTML today —
</blockquote class="a">,</li class="a">— so nothing a user can depend on changes.> {.a}alone in a blockquote,- {.a},1. {.a},> {.a}\n> {.b}and{.a}\n{.b}\n> {.c}all drop the trailing group now. Controls are unchanged:> {.a}\n> parastillattaches to the inner paragraph, an attrs block before a fence, heading, hr, list, blockquote or
table still attaches to it, and
{.a}with nothing after it was already dropped. Note the drop issilent — no warning is emitted, consistent with the existing behaviour for a group with nothing
after it.
Docs. No behaviour change.
Evidence for "only input that currently loses characters is affected"
parse()differential, every string{+ up to 7 characters of{}.%a#(335,923 strings), v0.7.0 against this branch:
0 inputs that v0.7.0 parsed to completion changed their position or attributes; 0 inputs
v0.7.0 rejected are now accepted; 0 inputs v0.7.0 parsed to a closing brace are now rejected. Of the 31,161
strings v0.7.0 ran off the end of (the buggy path), 15,901 now raise — the group is left literal —
and 15,260 now terminate with exactly the attributes v0.7.0 produced, with the tail recovered as
text instead of being swallowed.
0 documents lose text; 5,936 differ, and every one of them preserves strictly more of the
source's characters than v0.7.0 did. (Metric: the multiset of the document's own characters that
survive into the rendered text or into an attribute value.)
}:they report 34 accepted inputs with changed attributes, 3,901 new acceptances, and 194 documents
losing text. On this branch, zero of each.
Tests
All rows are in
tests/fixtures/attrs.md; each fix and its rows are in one commit, and each row wasrun RED on the unfixed tree and GREEN after.
tests/fixtures/attrs.mdis excluded from everypre-commit hook by the repository's own
exclude:pattern (test.*\.md), so it was checked by hand:a single trailing newline, no trailing whitespace on any added line.
after. Examples:
unterminated: inline code(`a`{) failed withexpected <p><code>a</code>{</p>, got <p><code>a</code></p>;unterminated: following text is not consumedwithexpected <p><code>a</code>{.a\nmore</p>, got <p><code class="a">a</code></p>;spans: unterminated attributes with a class are not a spanwithexpected <p>[a]{.x</p>, got <p><span>a</span></p>;comment: text after a brace-closed comment is keptwithexpected <p><code class="a">a</code> and {more}</p>, got <p><code class="a">a</code></p>.The fixture file had no
%rows at all before this PR, so the suite could not have caught acomment regression. Eight of the new pins are load-bearing for the comment half — with the raise
applied but the comment rule removed, these fail:
comment: block,comment: block after a class,comment: inline code,comment: after a class,comment: link,comment: closed by a brace, following text is kept,comment: text after a brace-closed comment is kept,spans: comment.Seven more guard the laziness: with a comment ended at every
}instead, these fail —comment: block, attributes after a brace inside the comment({% c } % .b}would lose its class),comment: block, a brace inside the comment does not terminate the group(
{% see } below %{x}would render to nothing),comment: a brace inside a comment closed by a percent,comment: attributes after a brace inside the comment,comment: an empty comment containing a brace,comment: a later percent keeps a brace-closed comment open,spans: a brace inside an unterminated comment is not a span.spans: merge classes from two groupsfailed withexpected <p><span class="x y">a</span></p>, got <p><span class="y">a</span></p>, andlinks: merge classes from three groupswithexpected class="x y z", got class="z". Plus the oneupdated expectation on
spans: merge attributes(class="a b"->class="a a b"), which failswith the old expectation once the fix is in.
block: attrs last in a blockquotefailed withexpected <blockquote></blockquote>\n<p>para</p>, got <blockquote></blockquote class="a">\n<p>para</p>.The rows assert the full rendered string on purpose: a guard that skips the merge but forgets to
pop the token renders
< class="a">, still invalid, and all five rows catch it (verified bymutation). Controls:
block: attrs followed by a paragraph in a blockquoteand the existingblock fencerow.quoted value keeps a backslash escape:`a`{k="a\"b"}-><p><code k="a\"b">a</code></p>, green before and after. It pins thebehaviour the docstring describes; the docstring prose itself is not covered by any test (the repo
has no docstring harness), so treat it as reviewed text, not tested text. Note the example uses
double backticks deliberately: inside single backticks docutils eats the backslash and the example
would contradict itself.
Gates
exit 0. (511 at v0.7.0 + 42 new rows.)
pre-commit run --all-files: exit 0, all hooks pass, nothing rewritten. That includes the mypyhook, which type-checks against
markdown-it-py~=3.0as configured.mdit_py_pluginsresolving to the patched tree): 1245 passed, 0 failed, 11 skipped, in three chunks
(23 / 391+8 skipped / 831+3 skipped), with the same 11 skip lines as against released v0.7.0.
Zero downstream churn.
Changelog lines
Follow-ups noticed
%comment at any}(
attributes.ts,handlers[State.SCANNING_COMMENT]); the Lua implementation this module cites asits source ends one only at
%. This PR does neither exactly, on purpose: matching djot.js changesattributes on input accepted today (
{% c } % .b}loses its class) and, while the full-line checkis disabled, deletes whole block lines (
{% see } below %{x}). Aligning would want the full-linecheck enabled first. Receipts: 34 accepted inputs with changed attributes and 194 fuzzed documents
losing text, measured against that variant.
allowed=:attrs_plugin(spans=True, allowed=["id"])stillrenders
[a]{.x}as<span class="x">, with noinsecure_attrsmeta._span_ruleis registeredwithout
allowedand assignstoken.attrsdirectly instead of going through_add_attrs.meta["insecure_attrs"]is overwritten rather than merged across groups: withallowed=["id"],[a](u){.x}{.y}leaves only{'class': 'y'}. (Related, and disclosed for completeness: withallowed=, a span'sinsecure_attrs["class"]now carries the accumulatedx yrather than thelast group only. The rendered HTML is identical; this is visible only because of the bypass above.)
parse()is givenstate.src[state.pos:]rather than aposMax-bounded slice, so it can read pastthe current inline sub-range. Today every over-read still ends in
ParseError—[x `a`{.a](u)renders
<a href="u">x <code>a</code>{.a</a></p>— and this PR adds a row pinning that.class="a a b",class="a b x x g"). djot does noteither, so this is deliberate, but it may deserve a note in the docs.
`a`{k="x\ny"}-><code k="x">.`a`{-=v}-><code -="v">,`a`{1=v}-><code 1="v">.[a [b]{.c} d](u)-><p>[a <span class="c">b</span> d](u)</p>.ParseErrorpositions are swallowed at all three call sites; surfacing them as warnings would be auseful diagnostic, including for the silent drop this PR's third fix produces.
ast.lua'sinsert_attributesthen applies:gsub("\\(%p)", "%1")("resolve backslash escapes"),so djot's value for
{k="a\"b"}isa"bwhile this plugin's isa\"b. This PR documents thecurrent behaviour rather than changing an accepted input's value; aligning with djot would be a
behaviour change worth its own discussion.
Out of scope by ruling
class="a b x x g"->"a b x g"; a behaviour change on accepted input._attr_block_rule({.a}{#b}on one line appliesonly the first group). It stays disabled: enabling it turns such a line into a paragraph
(
{.a}{#b}\npara-><p>{.a}{#b}\npara</p>instead of<p class="a">para</p>, measured), which isa behaviour change on accepted input. For the record, the reason it "was not working in some
instances" is an indexing bug, not the unterminated-group behaviour: the commented-out
if (maximum - 1) != new_poscomparesnew_pos, an index into the slicestate.src[pos:maximum],with
maximum - 1, an index into the full source; they agree only whenpos == 0. Measured at bothpins: at v0.7.0 the check as written fails 3 tests and the corrected form
(maximum - pos - 1)passes the whole suite; on this branch the check as written fails 9 fixture rows plus
test_attrs_allowed, and the corrected form still passes the whole suite (75 passed). No row addedby this PR pins the disabled check.
ParseErrorpositions, and theallowed=bypass on spans — each needs its own discussion.than introducing a new warning.