feat(grammar): a bare ::: figure is a composite figure, not a generic container - #68
Merged
Merged
Conversation
… container PART 9 section 4c (markup-carve/carve#1215) reserves the kind word `figure` among the `:::` types. A BARE opener - the fence, its separator, the word `figure`, and NOTHING else - is ONE figure of ordered panels. An opener carrying a quoted title or a `[label]` is not that production and stays the generic Tier-2 container it has always been. Before this the two read identically. Measured through the IDE's own TextMate engine, every spelling produced the same two scopes: ::: figure markup.other.div.carve entity.name.type.div.carve ::: figure "A titled figure div" markup.other.div.carve entity.name.type.div.carve The distinction lives entirely in the tail of one line, so the new rule's `[ \t]*$` tail is the whole of it: a titled or labelled opener does not match and falls through to `#divs` unchanged. The separator is spelled as a space run, never `[ \t]+`, because a tab does not separate a marker (PART 7, MARKER SEPARATORS; corpus 254 renders `:::<TAB>note` as a paragraph) - a tab-separated opener therefore reads exactly as it did before. WHY BEGIN/END RATHER THAN A LINE RULE. `#divs` is a flat per-line `match`, so this repo's `:::` handling is not stateful today, whatever markup-carve/carve-grammars#222 assumed. Making the new rule begin/end buys the one part of the clause a line rule cannot state: GROUPS DO NOT NEST, at any depth. It falls out of the rule leaving itself out of its own `patterns` - a nested bare opener reaches `#divs`, which recurses into no pattern list and so cannot reach back here from any depth. That is also why there is no `divs-in-group` twin, which carve-grammars needs only because its generic container is itself begin/end. The closer backreferences the opener's colon run so it closes on an exact length match, which is PART 9 section 12's colon-fence depth rule and the reason a bare `:::` inside a `:::: figure` group does not close it. WHAT THE STATE DOES NOT BUY. carve-grammars#222 expects a stateful grammar to scope the post-closer caption exactly where a stateless one over-approximates. It cannot. The group caption is an ordinary `^ ` line one line BELOW the closing fence, which puts it outside the span any begin/end rule can hold; the document-level caption rule claims it, and claims the same line after a `::: note` closer too. The caption position already worked and still does, the over-approximation is unchanged, and the test asserts both halves so the limit is recorded rather than rediscovered. Also recorded as a residual: an opener carrying a bullet prefix (`- ::: figure`, corpus 114) is not matched here and falls to `#divs`. A line-based grammar has no list-item content column, and under-colouring the rare case is the side every block rule in this file already picks. Two tests, because they answer different questions. The `composite-figure` fixture pins the whole token stream over seven shapes, which is what catches an accidental change. `CarveCompositeFigureTest` asserts what the rule is FOR - a golden agrees with whatever the grammar currently does, so on its own it cannot tell a rule that distinguishes the two readings from one that never reaches the second.
This was referenced Aug 15, 2026
Merged
dereuromark
added a commit
that referenced
this pull request
Aug 21, 2026
…pec pin (#89) * chore: prepare the 0.1.5 release, on a rebuilt engine and a current spec pin The version moves to 0.1.5 and the CHANGELOG gets its section back. It had none: #68 deleted the `## [0.1.4]` heading, so every note that shipped in 0.1.4 had fallen back under Unreleased and the next release would have repeated all of them. The compare links at the foot were missing 0.1.2 through 0.1.4 as well. Every entry is rewritten short. The sections had grown to essay length - one bullet ran nine paragraphs - and release notes are generated from this file, so the detail belongs in the commits and the tickets it references. No change is dropped; every reference is kept. THE ENGINE REBUILD CANNOT LAND ALONE, which is the substance of the diff. engine-drift.yml had been red for two nights: the vendored bundle rendered 23 of 1317 corpus documents differently where a bundle built from carve-js main renders them correctly. Rebuilding against the pin that was here left 135 of 1259 wrong, and against carve main 143 of 1341 - the engine right and the goldens old in the first case, the spec ahead of every engine in the second. The pin that makes both numbers zero is carve b78950f, the revision carve-js main itself pins: 0 of 1330. Twenty-five new categories needed a COVERED-or-SKIP decision. Each was tokenized with this grammar and its scopes compared against the 151 the covered corpus and the hand fixtures already produce; none of them adds a scope name. All twenty-five are SKIP - block context a line-based grammar cannot see, or render-time behavior with no token of its own. Four record a MEASURED FALSE POSITIVE rather than a reason to skip, and they are filed rather than buried: empty brace pairs, the braced en dash and flag-shaped hyphen runs (issue 85), the table header marker claiming cells the engine renders plain (issue 86), and a boolean attribute starting with an underscore (issue 87). A golden for any of them would pin the wrong answer, which is the lesson from the comment-fence skip that hid a defect for three releases. Two goldens change and neither is a grammar regression: upstream edited both corpus inputs, the arrow document to the doubled runs and the list continuation document to a flush-left block. * fix: register the preview only where JCEF is, so a .crv file opens without it Opening a .crv file on PhpStorm 2026.2 threw NoClassDefFoundError on com.intellij.ui.jcef.JBCefBrowser and no editor opened at all. JCEF is not part of com.intellij.modules.platform. It is its own plugin - com.intellij.modules.jcef, "Web Browser (JCEF)", shipped under plugins/jcef-plugin - and its content module intellij.platform.ui.jcef is what carries JBCefBrowser. This plugin declared no dependency on it, so the class was never on its class loader; the parent list in the report shows textmate, groovy, ssh and lsp4ij, and no jcef. The blast radius is larger than the preview because CarvePreviewEditorProvider returns HIDE_DEFAULT_EDITOR: a provider that throws leaves nothing to fall back to, so the file itself would not open. Highlighting was fine and unreachable. The two extension points that build a CarvePreviewPanel move into an optional carve-jcef.xml, gated on the JCEF plugin the same way carve-lsp.xml is gated on LSP4IJ. Where JCEF is present nothing changes. Where it is not, neither extension point is registered, the plain text editor opens the file, and highlighting, export, live templates and the language server are unaffected - which is what the split buys over a hard dependency that would refuse to load the plugin at all. CarvePluginDescriptorTest pins the arrangement rather than the symptom: the optional dependency must be declared, carve-jcef.xml must carry both extension points, plugin.xml must register nothing from the preview package, and CarvePreviewPanel must remain the only file that reaches JCEF. Verified by moving the provider back to plugin.xml, where the second assertion fails.
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.
Carries the composite-figures vocabulary (PART 9 §4c, landed as markup-carve/carve#1215) into the grammar this plugin bundles. Tracked as markup-carve/carve-grammars#222; the reference implementation for the same rules in a TextMate grammar is markup-carve/carve-grammars#223.
What the clause says a grammar has to see
The kind word
figureis RESERVED among the:::types. A bare opener - the fence, its separator, the wordfigure, and nothing else - is ONE figure of ordered panels. An opener that carries a quoted title or a[label]is not that production at all: it stays the generic Tier-2 container it has always been. A bare opener inside an open group degrades to a generic container too, at any depth, because groups do not nest.Nothing here builds the figure-group model - this is a highlighting grammar. What it has to do is tell the two readings apart, because they are different constructs differing only in the tail of one line.
Input:
Scopes, after (measured through the IDE's own TextMate engine):
The control, which must NOT change:
Before this, both scoped identically. Measured on the unchanged grammar, the bare opener, the titled opener, the labelled opener and the tab-separated opener all produced
markup.other.div.carveplusentity.name.type.div.carve, with nothing to separate them.What changed
src/main/resources/textmate/carve.tmLanguage.jsonfigure-groupbegin/end rule, included ahead of#divsin the document pattern listsrc/test/resources/fixtures/composite-figure.crv+.../fixture-tokens/composite-figure.tokenssrc/test/kotlin/.../corpus/CarveCompositeFigureTest.ktThe separator is spelled as a space run and deliberately not
[ \t]+, which is the spelling the generic rules beside it use: a tab does not separate a marker (PART 7, MARKER SEPARATORS; corpus 254 renders:::<TAB>noteas a paragraph), so:::<TAB>figurefalls to#divsand reads exactly as it does today.The closer backreferences the opener's colon run, so it closes on an exact length match. That is PART 9 §12's colon-fence depth rule, and it is the reason a bare
:::line inside a:::: figuregroup does not close it.Two findings from measuring this repo, against what carve-grammars#222 assumed
This repo's
:::handling is not stateful. markup-carve/carve-grammars#222 says intellij-carve's TextMate grammar "uses a stateful begin/end model and can do it exactly". Measured:#divsis a flat per-linematch. Begin/end appears in this grammar for frontmatter, block comments, fenced code, table rows, blockquotes, inline footnotes and citations, and for no:::container. So on the:::axis this grammar was, until this change, more stateless than carve-grammars', whose generic container is itself begin/end.Making the new rule begin/end is still worth it, because it buys the one part of the clause a line rule cannot state: groups do not nest, at any depth. That falls out of the rule leaving itself out of its own
patterns- a nested bare opener reaches#divs, which recurses into no pattern list and therefore cannot reach back to the group rule from any depth. It is also why there is nodivs-in-grouptwin here; carve-grammars needs one only because its generic container recurses into itself.The exact caption position is not reachable, with or without state. The same ticket expects a stateful grammar to scope the post-closer
^line exactly where a stateless one over-approximates. It cannot, and the reason is structural rather than a limit of this grammar: the group caption is an ordinary^line one line below the closing fence, which puts it outside the span any begin/end rule can hold. The caption position already worked here before this PR and still does, via the document-level caption rule - which claims the same line after a::: notecloser too.theGroupCaptionAfterTheCloserScopesAsACaptionOutsideTheGroupasserts both halves, so the over-approximation is recorded as a known limit rather than left to be rediscovered.Written-down residual
An opener carrying a bullet prefix (
- ::: figure, corpus 114, which#divshandles) is not matched by the new rule and falls to#divs, so a bare figure opened directly after a list marker under-colours as a generic container. A line-based grammar has no list-item content column, and under-colouring the rare case is the side every block rule in this file already picks. It is stated at the rule.Not in this PR
CarveMarkerScannerandCarveColorswere checked and need nothing. The scanner colours only the:::marker run itself, never the kind word, so a figure group's fence already getsCarveColors.DIV_MARKERand would get it under any reading of the line.CarveColorsenumerates markers, not container kinds, and a composite figure introduces no new marker character.Host limitations
gate-runreturnedno-gatesfor this repo, so the org gate recipe did not run here. The repo's own CI steps were run locally instead, against the pushed commit:./gradlew test(420 tests, 0 failures, 2 skipped),./gradlew buildPlugin verifyPlugin, and./tools/check-battery-drift.sh(41 shapes match carve-grammars).runPluginVerifierwas not run locally - it downloads full IDE distributions, which exceeds the budget on this host; CI runs it on this PR.