Quarkdown 粗体(Strong)解析全解:从 strong.md 测试夹具到 Strong AST 节点的完整管线
【免费下载链接】quarkdown🪐 Markdown with superpowers: from ideas to papers, presentations, websites, books, and knowledge bases.项目地址: https://gitcode.com/GitHub_Trending/qu/quarkdown
本篇以 strong.md 测试夹具为核心,拆解 Quarkdown 核心解析器对 Markdown 粗体(strong emphasis)的完整处理链路:三行测试输入分别对应什么 AST 结构、正则词法模式如何落实 CommonMark 的 flanking(左/右邻接)规则、解析器如何递归重词法化以实现嵌套强调,以及测试中记录的一处已知解析边界。读完后可掌握 Quarkdown 从源码文本到Strong/Emphasis/StrongEmphasis节点的全部关键实现与验证方式。
1. strong.md 夹具:三行输入定义的全部测试语义
strong.md 位于quarkdown-core的测试资源目录(src/test/resources/parsing/inline/),它不是面向用户的文档,而是行内(inline)解析测试的输入文件,全文仅 5 行,包含三条测试用例:
**foo** **foo*bar*baz** __foo_bar_baz__这三行覆盖了粗体解析的三种关键场景,其在 InlineParserTest 中strong()测试方法里对应的期望结构如下:
| 输入 | 期望解析结果 |
|---|---|
**foo** | 一个Strong节点,唯一子节点为Text("foo") |
**foo*bar*baz** | 一个Strong节点,子节点依次为Text("foo")、Emphasis(Text("bar"))、Text("baz")—— 星号粗体内部可嵌套斜体 |
__foo_bar_baz__ | 一个Strong节点,唯一子节点为Text("foo_bar_baz")—— 下划线粗体内部的_不产生嵌套强调 |
第二条与第三条的对比是整组用例最核心的信息:同为内部夹带单个分隔符,星号版本产生嵌套的Emphasis节点,下划线版本则保持纯文本。这一差异并非偶然,而直接由词法层为星号与下划线编写的两套不同严格程度的正则模式决定(第 4 节展开)。测试结尾的assertFalse(nodes.hasNext())则保证没有多余的Strong节点被误产生。
2. 测试读取机制:inlineIterator 与 flavor
strong()测试的入口只有一行:
val nodes = inlineIterator<Strong>(readSource("/parsing/inline/strong.md"))其中readSource按测试资源路径读取文件文本(/parsing/inline/strong.md正对应src/test/resources下的 strong.md)。inlineIterator是 InlineParserTest 的私有泛型辅助方法,其实现揭示了 Quarkdown 解析管线的通用入口形态:
private inline fun <reified T : Node> inlineIterator( source: CharSequence, assertType: Boolean = true, flavor: MarkdownFlavor = QuarkdownFlavor, ): Iterator<T> { val lexer = flavor.lexerFactory.newInlineLexer(source) val parser = flavor.parserFactory.newParser(MutableContext(flavor)) return nodesIterator(lexer, parser, assertType) }从源码结构看,这里体现了三个设计点:
- flavor 机制:词法器(lexer)与解析器(parser)都通过
MarkdownFlavor的工厂创建,默认使用QuarkdownFlavor;模式文件的 KDoc 也注明这些正则服务于BaseMarkdownFlavor的行内 token,意味着不同 Markdown 方言可以替换模式与解析策略; - 词法先行:
newInlineLexer(source)先把原始文本切分为 token 流,再由解析器把 token 组装成 AST 节点,词法与语法两层解耦; assertType默认开启:迭代出的每个节点都会被断言为泛型类型T(此处即Strong),因此该测试隐含了“源文件中恰好产出 3 个Strong节点、且类型精确”的校验。
3. AST 节点层:Strong、Emphasis 与 StrongEmphasis
解析目标节点定义在 Emphasis.kt 中,四个强调类节点并列存在:
/** Weakly emphasized content. */ class Emphasis( @Diverge override val text: InlineContent, ) : TextNode /** Strongly emphasized content. */ class Strong( @Diverge override val text: InlineContent, ) : TextNode /** Heavily emphasized content. */ class StrongEmphasis( @Diverge override val text: InlineContent, ) : TextNode /** Strikethrough content. */ class Strikethrough( @Diverge override val text: InlineContent, ) : TextNode要点:
- 三类强调强度独立建类:
Emphasis(弱强调,*foo*/_foo_)、Strong(强强调,**foo**/__foo__)、StrongEmphasis(双重强调,***foo***/___foo___),外加 GFM 风格的Strikethrough(~~foo~~); - 每个节点的
text字段类型是InlineContent,即可继续嵌套行内节点的内容列表,这正是**foo*bar*baz**能表达出Strong内嵌Emphasis的数据基础; - 四个类均继承
TextNode并实现accept(visitor: NodeVisitor<T>),采用访问者模式,使渲染器、重写器(rewriter)等下游阶段可以统一遍历而不依赖节点具体类型。
4. 词法层:token 类型与落实 flanking 规则的正则模式
4.1 强调相关 token
InlineTokens.kt 在文件下半部的 “Emphasis” 分组中定义了这些 token 的包裹类型,每个 token 仅携带TokenData(含文本与正则分组)并实现accept(TokenVisitor):
- StrongToken:
**strong**与__strong__两种写法; - EmphasisToken:
*emphasis*与_emphasis_; - StrongEmphasisToken:
***emphasis***与___emphasis___。
4.2 模式注册:星号宽松、下划线严格
模式集中定义在 BaseMarkdownInlineTokenRegexPatterns.kt,文件内注释标明这些模式遵循 CommonMark 规范的 “emphasis and strong emphasis” 一节。与强调相关的注册项为:
| 模式属性 | 起始分隔符 | 结束分隔符 | strict | 对应 token |
|---|---|---|---|---|
strongAsterisk | \*{2}(两个星号) | \*{2,}(两个及以上星号) | false | StrongToken |
strongUnderscore | _{2} | _{2,} | true | StrongToken |
emphasisAsterisk | \* | \*+ | false | EmphasisToken |
emphasisUnderscore | _ | _+ | true | EmphasisToken |
strongEmphasisAsterisk | \*{3} | \*{3,} | false | StrongEmphasisToken |
strongEmphasisUnderscore | _{3} | _{3,} | true | StrongEmphasisToken |
注意结束分隔符允许“两个及以上”:**的闭合端可以匹配更长的星号串,这是处理相邻强调(如粗体内嵌斜体)时正则能够正确切分的基础。
4.3 delimiteredPattern:CommonMark flanking 规则的正则化
所有强调模式都由同一个私有函数 delimitedPattern 生成,其 KDoc 对strict参数的语义给出了权威说明:
non-strict means the start delimiter must be left-flanking and end delimiter must be right-flanking; strict means any of the delimiters must not be left and right-flanking at the same time.
生成的正则骨架为:
(?<!start) [起始分隔符:按 strict 与否采用不同的 flanking 约束] (?!start)((.|\R)+?) // 内容(非贪婪) [结束分隔符:按 strict 与否采用不同的 flanking 约束]其中punct引用为\p{IsP}\p{IsS}(Unicode 标点与符号字符类),即“空白 + 标点/符号”共同构成分隔符两侧的判定上下文。翻译回 CommonMark 术语:
- 星号模式(strict = false):起始分隔符只需“左邻接”(后随非空白,且要么非标点要么左侧为行首/空白/标点),结束分隔符只需“右邻接”。星号可以出现在单词内部参与强调(如
foo*bar*); - 下划线模式(strict = true):分隔符不得同时左、右邻接,即禁止“词内”下划线强调(如
foo_bar_baz中的单个_不构成分隔符)。这与 CommonMark 对_的限制一致。
夹具中第二条与第三条的分野(*bar*生效、_bar_不生效)正是这两套模式在同一解析流程下的直接体现。
5. 解析器层:emphasisContent 的递归重词法化
token 到 AST 节点的组装发生在 InlineTokenParser。三个强调(及删除线)节点共用同一个内容提取逻辑:
private fun emphasisContent(token: Token): InlineContent { // The raw string content, without the delimiters. val text = token.data.groups .iterator(consumeAmount = 3) .next() return parseSubContent(text) } override fun visit(token: EmphasisToken): Node = Emphasis(emphasisContent(token)) override fun visit(token: StrongToken): Node = Strong(emphasisContent(token)) override fun visit(token: StrongEmphasisToken): Node = StrongEmphasis(emphasisContent(token))这里有两个关键实现细节:
- 取“不含定界符的内容分组”:
consumeAmount = 3对应delimitedPattern生成的三个捕获组(起始分隔符、内容、结束分隔符),迭代器跳过后取到的第一组即内容主体。对**foo*bar*baz**,取出的是foo*bar*baz; - 递归词法 + 解析:
parseSubContent会调用context.flavor.lexerFactory.newInlineLexer(source)对这段内容重新走一遍完整的行内词法与解析(见 tokenizeAndParse)。因此内容中的*bar*会再次命中emphasisAsterisk模式,被解析为Emphasis节点嵌入Strong的子节点列表——嵌套强调结构完全由“内容递归解析”这一机制自然产生,解析器没有为嵌套编写专门分支。
6. 逐行解读夹具的三条输入
第 1 行**foo**:strongAsterisk模式匹配整行,内容分组为foo,递归解析只产生一个Text节点。测试断言children.first()是Text且text == "foo"。
第 2 行**foo*bar*baz**:外层由strongAsterisk命中,内容分组foo*bar*baz进入递归词法。其中的*bar*:起始*前为字母o、后为字母b,满足左邻接;结束*前为字母r、后为字母a,满足右邻接;星号模式为 non-strict,允许词内强调,故匹配为Emphasis。最终子节点序列为Text("foo")→Emphasis(Text("bar"))→Text("baz"),与 strong() 测试 的逐项断言完全一致。
第 3 行__foo_bar_baz__:外层由strongUnderscore命中(行首__与行尾__均满足 strict 模式的 flanking 约束)。内容foo_bar_baz递归词法时,单个_bar_的起始_左邻接且右邻接同时成立,违反 strict 模式“不得同时左、右邻接”的约束,因此不会被识别为Emphasis分隔符,整体退化为纯文本。测试断言第三个Strong节点的子节点只有Text("foo_bar_baz")一个,验证了词内下划线不产生强调。
7. 已知边界:被注释掉的**foo*bar***用例
strong() 测试 的尾部保留了一段被注释的期望值,并标注了明确的待办:
/* TODO fix for **foo*bar*** ... */这记录了当前实现的一个解析边界:**foo*bar***这类定界符不平衡的混合用例(粗体起始两个星号、末尾三个星号,需要把***切分为“闭合斜体的*+ 闭合粗体的**”)目前尚未按期望处理,期望结构(Strong内含Text("foo")、Emphasis("bar")等)被整体注释待修复。撰写或审查粗体解析相关代码时,应将此视为当前仓库的已知限制,而非已支持能力。
8. 相邻夹具:strongemphasis.md 与 emphasis.md
同一资源目录下还有与本文主题强相关的两个夹具,构成完整的强调解析测试族:
- strongemphasis.md:内容为
***foo***与___foo*bar*baz___,对应 strongEmphasis() 测试,验证StrongEmphasisToken(三个及以上星号/下划线)路径,且下划线双重强调内部同样允许嵌套Emphasis; - emphasis.md:覆盖斜体及其与粗体的互嵌、括号边界等更多场景,对应 emphasis() 测试。
三者共用同一套 token、模式与解析器实现,仅由起始/结束分隔符的星号(或下划线)数量与 strict 标志区分,这也解释了为何Strong的结束正则写作\*{2,}——它必须能与***、****等更长分隔符共存。
9. 复现方式:运行 InlineParserTest
仓库根目录提供了 Gradle wrapper,可在仓库根目录执行以下命令运行整个行内解析测试类(其中包含 strong 用例):
./gradlew :quarkdown-core:test --tests "com.quarkdown.core.InlineParserTest"若需单独验证 strong 夹具的解析行为,可将测试类限定后观察strong()方法的断言输出;输入与期望一一对应 strong.md 与 InlineParserTest,修改任一端的夹具或断言都可用于快速回归词法/解析改动。
小结
strong.md虽只有三行输入,但它锚定了 Quarkdown 粗体解析的完整证据链:词法层用 BaseMarkdownInlineTokenRegexPatterns 中的delimitedPattern把 CommonMark 的 flanking 规则编译进正则(星号宽松、下划线严格),InlineTokenParser 通过emphasisContent对分隔符内容递归重词法化生成嵌套结构,Emphasis.kt 中独立的Strong/Emphasis/StrongEmphasis节点承载三级强调强度,最终由访问者模式交给渲染阶段。结合**foo*bar***的 TODO 注释,这份夹具与测试共同勾勒出当前实现已支持的能力与尚待补齐的边界。
【免费下载链接】quarkdown🪐 Markdown with superpowers: from ideas to papers, presentations, websites, books, and knowledge bases.项目地址: https://gitcode.com/GitHub_Trending/qu/quarkdown
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考