Markup · .not & .md
原生 .not 由 notist-syntax.markup 解析为无损 CST,再由 pipeline.frontend.markup 转换为 Expr 森林;调用、注解与字面量语法通过片段入口复用 notist-syntax.code,数据转换来自 pipeline.lowering。Markdown 由 pipeline.frontend.markdown 解析并复用相同 Code 入口;两种前端汇入相同的内容管线。Code 的内容字面量由所选前端识别范围并解析,Code CST 保留不透明 ContentLiteral,前端语法产物单独关联。
本文定义原生 Markup 的共用词法、上下文、正文分派、调用与注解,并单独说明 Markdown 扩展。具体元素的语法糖见 Builtin,字面量与内容 flavor 见 Types,声明文法见 Code,阶段职责见 Markup 管线。
本文中的产生式使用 Grammar Notation。scanner 是纯探测,返回位置与状态;syntax 规则和 syntax algorithm 负责消费 token、构造 CST 和产生解析诊断。合法产生式与实现的恢复行为分别说明。
文件级导入
#import "relative.notc" as name; 独占一行,只允许文件顶层。as 可省略,默认名称为文件名去掉扩展名;也可写 #import "relative.notc": member as local, other; 或 #import "relative.notc": *;。原生 Notist Markup 与 Markdown 扩展共用这些规则,绑定对整个文件有效。声明复用 Code 的 ImportDecl 语法,前端 lower 为并列的 Import 列表,不产生内容节点;字面量内部及嵌套内容不接受导入。模块加载与名称规则见 本地模块。
原生词法
输入与状态
lexer 处理 Unicode 字符,parser 处理 token。token 保存 kind 和 UTF-8 源码范围,文本按范围从原始源码取得。空格、tab、换行与注释作为 trivia 保留在 CST 中;每条解析规则明确决定如何消费它们。
词法状态 | 使用位置 | 识别规则 |
|---|---|---|
Markup 正文 | 普通文字与调用 body | Markup 标记、转义、trivia 和 Text |
调用路径 | 紧邻 | Ident 与紧邻的 |
字面量 | 调用参数与注解 payload | Ident、String、Number、集合标点与 trivia |
调用参数和注解的圆括号按深度维护字面量状态;调用 body 使用 Markup 正文规则。lexer 独立产生完整 token 流,parser 将失败的调用入口返回文字时仍使用这份 token 流。
lex Whitespace -> [` ` U+0009]+;
lex Newline -> (U+000D U+000A) | U+000A | U+000D;
lex Eq -> `=`+;
lex Backtick -> U+0060+;
lex Ident -> first:UnicodeChar @{ alphabetic(first) or first == "_" }
(c:UnicodeChar @{ alphanumeric(c) or c in {"_", "-"} })*;UnicodeChar 是一个 Unicode scalar;alphabetic / alphanumeric 使用 Rust 字符的 Unicode 字母 / 字母数字判断。Eq 和 Backtick 描述 Markup 正文中的连续标记;Ident 用于调用路径与字面量状态。Code 复用 Ident,但箭头的优先级见 Code 词法。String 与 Number 见 Types,Escape 见 Builtin 的 text 小节。
WS、NL、LINE_COMMENT、BLOCK_COMMENT、EQ、BACKTICK、IDENT、STR、NUMBER、TEXT、ESCAPE 分别表示对应 token kind。scanner 中的 CamelCase 名与大写别名表示同一 kind,例如 LBracket 与 LBRACKET。DOT 是整个 . token;其他标点别名在相应词法状态下表示单字符 token。连续 = 与反引号各组成一个正文 token,-、+、* 逐字符组成 token。
普通正文中的引号、冒号与逗号属于 Text;进入字面量状态后才按 String、Colon、Comma 等规则识别。只有已形成 STR 的字符串才屏蔽其中的括号。
注释
下面的词法 scanner 使用 UTF-8 字节偏移;ASCII 注释标记占两个字节。它们返回扫描结果,由 lexer 据此生成 token。
lex scanner LineComment(mode) -> { end } | Miss {
start = cursor
若 source[start..] 不以 "//" 开头,返回 Miss
若 mode == Markup 且 start 前一个源码字符为 ":",返回 Miss
end = start 之后首个 LF 的字节位置;没有 LF 时为 source.len
若 source[end - 1] == CR,end -= 1
返回 { end }
}
lex scanner BlockComment -> { end, closed } | Miss {
若 source[cursor..] 不以 "/*" 开头,返回 Miss
p = cursor + 2;depth = 1
循环至 source.len:
"/*":depth += 1;p += 2
"*/":depth -= 1;p += 2
若 depth == 0,返回 { end: p, closed: true }
其他:p 前进一个字节
返回 { end: source.len, closed: false }
}块注释可以嵌套;未闭合的块注释仍形成 trivia token,parser 消费它时产生 Syntax 诊断。行注释终止于 LF 或文件末尾,终点前紧邻的 CR 留给 Newline;单独的 CR 不终止行注释。
正文中的 // 仅在紧随冒号时享有 URL 豁免,因此 https://example 可作为普通文字。调用参数、注解 payload 与 Code 的字面量扫描不使用此豁免,URL 参数应写成 String。单独的斜杠属于 Text。
共用 token 规则
syntax Trivia -> WS | NL | LINE_COMMENT | BLOCK_COMMENT;
syntax TokenKind(k) -> t:ANY @{ t.kind == k };
syntax End(ctx) -> &NL | @{ limit(cursor, ctx) };
syntax BlankLine -> WS? NL;
syntax ItemBody(ctx) -> BlockRun(ctx, Item);ANY 消费一个 token。End 只判断当前位置是否为 NL 或有效区间末尾,不消费输入;EOF 表示有效区间末尾的观察结果,lexer 不额外生成 EOF token。
上下文与边界
位置和容器
字段 | 含义 | 文档入口值 |
|---|---|---|
limit | 当前 token 区间的右开边界 | 文件 token 数 |
| 行内分派是否在 RBracket 处停止 | false |
| 当前列表正文的最小缩进列 | 0 |
| 列表项第一行正文的 token 起点 | none |
token 位置与源码字节位置分别使用。kind(p) 在 p 达到 limit 时返回 EOF;column(p) 根据该 token 的源码起点计算列数,普通 Unicode scalar 计一列,tab 到下一个四列边界。行首判断使用源码中最近的 LF / CR,而非只查找独立的 NL token。
limit(p, ctx) = p >= ctx.limit
body_close(p, ctx) = ctx.in_body and kind(p) == RBracket
line_start(p) = 最近 LF / CR 之后至 token p 起点的源码仅含空格或 tab
block_start(p, ctx) =
p == ctx.body_start
or line_start(p) and (
column(p) == 0
or ctx.block_indent > 0 and column(p) >= ctx.block_indent
)
can_open(p, ctx) = kind(p + 1) not in {EOF, Whitespace, Newline}
can_close(p, ctx) = p > 0 and kind(p - 1) not in {EOF, Whitespace, Newline}
always_true = 恒真谓词
always_false = 恒假谓词
不同边界承担不同职责:
机制 | 设置位置 | 约束范围 |
|---|---|---|
limit | 列表项范围、表格单元格范围 | 同时约束消费和向前探测 |
| 调用的 | 使正文分派在当前 RBracket 处停止 |
| 行内序列遇到 NL 时 | 判断这次换行是否结束当前块 |
调用 body 设置 in_body,继承外层 limit;列表项临时收窄 limit,并设置正文缩进和 body_start。表格单元格临时收窄 limit、设置 in_body=false,使单元格中的独立 RBracket 可以作为文字。离开容器后恢复外层上下文。
换行是否结束块
predicate block_end(p, ctx) {
要求 kind(p) == Newline
q = p + 1;跳过从 q 开始的 Whitespace
i = 跳过前导空白得到的缩进列数
若 kind(q) in {EOF, Newline} 或 i < ctx.block_indent,返回 true
若 table_start(q, ctx),返回 true
若非 block_start(q, ctx),返回 false
按 kind(q) 判断:
Eq:kind(q + 1) == Whitespace
Backtick:当前 run 长度至少 3
Minus / Plus:kind(q + 1) == Whitespace,或 Divider 前缀成立
At:true
其他:false
}
table_start 与 Divider 前缀由 Builtin 的 table / divider 小节定义。block_end 是纯判断;At 分支只判断下一行以 At 开始,payload 的合法性留给后续解析。
正常行内序列中的单 NL 是软换行;空行、块起点和显式反斜杠换行结束相应序列。正文空白与换行的降糖见 Builtin 的 paragraph 小节。
原生正文分派
块序列
BlockRun 按下列顺序循环分派,每轮先检查 limit 与 body_close。Document 在文件末尾结束;Body 和 Item 还会在有效的 RBracket 处结束。已消费的 trivia 保留在所属容器的 CST 中。
syntax algorithm BlockRun(ctx, container) {
循环至 limit,或 container != Document 且 body_close(cursor, ctx):
container == Item 且当前为 WS / NL / LINE_COMMENT / BLOCK_COMMENT:消耗一个 token
NL:消耗一个 token
EQ 且下一 token 为 WS:Heading
BACKTICK 且 run 长度至少 3:RawBlock
Divider 前缀成立:Divider
MINUS / PLUS 且下一 token 为 WS:List
table_start(cursor, ctx):Table
WS 且后续仅为 WS,直至 NL 或 EOF:消耗 WS
WS 且 block_indent > 0 且 container == Body:消耗 WS
LINE_COMMENT / BLOCK_COMMENT:消耗一个 token
AT 且 block_annotation(cursor, ctx):BlockAnnotation
BACKSLASH NL:ExplicitBreak
其他:InlineRun(ctx, block_end, None)
}
container 为 Document、Body 或 Item;Heading、RawBlock、Divider、List、Table 与 ExplicitBreak 的具体规则见 Builtin。Item 消费正文缩进;Document 保留非空行的前导空白并交给行内分派。块路径产生 BlockAnnotation,行内路径产生 Annotation;两者共用 Annotation AST 视图,lowering 按节点 kind 记录 side annotation。
块分派使用当前位置的前缀。Heading、RawBlock、List 的入口没有统一的 line_start 检查;Divider、Table 及换行后的块边界判断使用 block_start。通常独占行的块标记位于列 0,但不能把“所有块标记必须在源码列 0”作为 parser 的统一条件,例如 /* comment */= Title 仍可在文档入口形成 Heading。
行内序列
InlineRun 只在当前位置为 NL 时调用 stop;active 表示包围它的行内结构的闭合约定。
syntax algorithm InlineRun(ctx, stop, active) {
循环:
limit:停止
NL 且 stop(cursor, ctx):停止
NL:消耗一个 token
body_close(cursor, ctx):停止
active_closes(active, cursor, ctx):停止
BACKSLASH NL:停止
BACKTICK:RawInline
STAR / UNDERSCORE / TILDE:Strong / Emph / Strike
DOLLAR:Math
BANG LBRACKET:Embed
LBRACKET LBRACKET:WikiLink
LBRACKET:Link
HASH:CodeCall
AT LPAREN 或 AT BANG LPAREN:Annotation
其他:消耗一个原始 token
}
active_closes(None, p, ctx) = false
active_closes(Flanked(k), p, ctx) = kind(p) == k and can_close(p, ctx)
active_closes(Single(k), p, ctx) = kind(p) == k
active_closes(Pair(k), p, ctx) = kind(p) == k and kind(p + 1) == k
函数的返回级别不参与 syntax 分派。名称和签名在 Resolve 确定,Resolve 按类型校验内容片段,Shape 归组 section。未形成语法构造的原始 token 由 text 降糖;普通行内 At 只有紧随合法注解起始前缀时才进入 Annotation。
内容函数调用
合法形式
syntax CodeCall(ctx) ->
`#` name:Path args:Args(ctx) body:CallBody(ctx)?
| `#` name:Path body:CallBody(ctx)
| `#` name:Path;
syntax Path -> segment:IDENT (DOT segment:IDENT)*;
syntax Args(ctx) ->
LPAREN Trivia* (Argument (Trivia* COMMA Trivia* Argument)* Trivia* COMMA?)?
Trivia* RPAREN;
syntax Argument -> key:ArgKey COLON Trivia* value:Value | value:Value;Path、Args、CallBody 的边界必须紧邻,路径中的段与 . 也必须紧邻。#name(args) 和 #name[body] 是具名调用,#node[body] 是显式透明调用。裸 #name 解析为引用;独立 Markup 在检查时拒绝引用插值,Code 字面量可拼接内容值。匿名调用不属于语言入口。
Value 和 ArgKey 由 Types 定义。具名参数的冒号紧邻 key,允许参数尾逗号;lowering 按源码顺序保存位置与具名参数的表达式,由 Resolve 按签名绑定为 fields。重复具名 key 产生诊断,恢复时后者覆盖。内容通过唯一的调用 body 挂载为 children;参数中出现 [body] 可被 parser 保留供诊断,BlockContent 参数的限制见 Types。
CST 将简单名称保存在 Path 节点,成员访问以嵌套 MemberExpr 保存;CodeCall.callee 返回目标表达式。parser 保留任意段数;Context 沿 import 绑定解析到函数。CodeCall 是 CST / AST 名称,其源码语法属于 Markup。
平衡探测
Balanced 在当前位置的开括号处探测,仅对指定的一种括号计数。cross_blocks 决定是否允许越过 block_end;外层 limit 始终有效。
scanner Balanced(open_kind, close_kind, cross_blocks; ctx)
-> { end, status, close } | Miss {
若 kind(cursor) != open_kind,返回 Miss
p = cursor;depth = 0
循环至 ctx.limit:
若非 cross_blocks 且 kind(p) == Newline 且 block_end(p, ctx),
返回 { end: p, status: BlockEnd, close: none }
若 cross_blocks 且 probe OpaqueClose(p, ctx) 成功,
p = 该结果的 close + 1;继续
若 kind(p) == open_kind,depth += 1
若 kind(p) == close_kind:
depth -= 1
若 depth == 0,返回 { end: p + 1, status: Closed, close: p }
p += 1
返回 { end: ctx.limit, status: Unclosed, close: none }
}
status | 含义 | 调用入口的使用方式 |
|---|---|---|
Closed | 找到配平闭括号 | end 为闭括号之后,close 为闭括号本身 |
BlockEnd | Args 探测遇到块边界 | 当前前缀不形成调用 |
Unclosed | 到达有效容器末尾 | Args 前缀返回文字;body 前缀产生未闭合诊断 |
这些是纯探测的结果,调用方按 status 决定消费和恢复。STR、注释和 Escape 中的括号没有独立 token,不参与计数;正文里的普通引号本身不会屏蔽括号。
OpaqueClose 导入 Builtin 的 link 小节定义,只跳过完整的行内 raw / math payload。原生 fenced RawBlock 使用自己的块解析规则;外层平衡探测不会把跨行 fence 当成同一种不透明片段。
入口、body 与恢复
调用入口依次完成以下探测:
在
#后探测完整 Path;路径最后的.缺少名称段时,前缀失败。若紧邻 LPAREN,以
cross_blocks=false 探测 Args;仅 Closed 继续。若紧邻 LBRACKET,以
cross_blocks=true 探测 body,并保留状态。名称后接完整 Args 或完整 body 时形成 CodeCall;只有 Path 时形成引用。
CallBody 的解析流程为:
syntax algorithm CallBody(ctx) {
open = cursor
frame = probe Balanced(LBracket, RBracket, true; ctx)
若 frame == Miss 或 frame.status != Closed,返回 Miss
根据 Types 的 flank 规则确定 body flavor
若 flavor 为行内,检查 open + 1 至 frame.close 内的换行
消耗 LBRACKET,临时设置 ctx.in_body=true,继承 ctx.limit
块 flavor:BlockRun(ctx, Body)
行内 flavor:InlineRun(ctx, always_false, Single(RBracket))
恢复外层 in_body,在正文停止的位置消费收尾 token
}
flavor 来自括号两端内侧的空白,与内容函数的返回类型分别确定。行内 body 的换行若使 block_end 成立,parser 产生 Syntax 诊断(inline content cannot contain a blank line),并继续以行内规则解析;该检查还会命中标题等块起点。
frame.close 用于判断 flavor 和检查换行;它不通过 within 设置正文的新 limit。正文停止条件来自 in_body、active、显式反斜杠换行及外层容器,收尾消费发生在实际停止位置。
前缀情况 | 实际解析与恢复 |
|---|---|
裸名称、非法路径或 Args 未闭合 | 入口消费一个原始 HASH,随后继续外层正文分派;调用入口本身不补充诊断 |
具名 Args 已闭合,但 body 未闭合 | 在有效容器末尾产生未闭合 body 诊断,保留仅含 Args 的 CodeCall,余下 token 继续正文分派 |
仅 body 的调用未闭合 | 产生同一诊断,入口消费原始 HASH,随后继续正文分派 |
Args 缺少或重复逗号 | parser 仍可消费字面量序列;这是容错行为,合法形式以 Args 产生式为准 |
Args 中无法识别为字面量的 token | 消费该 token 并产生 Syntax 诊断,继续参数解析 |
失败入口返回的是首 token,余下范围仍可能形成其他语法构造。解析消费继续保留原始源码,trivia 自身的诊断也照常产生。
注解
syntax Annotation(ctx) -> AT module:BANG? payload:Dict;@(dict) 与 @!(dict) 分别产生节点注解和模块注解,空注解为 @(:)。合法 payload 为完整 Dict;Dict 的成员与值由 Types 定义。节点注解不进入内容树,而是作为带源码范围的 side list 保留到 Shape 之后。
块路径与行内路径通过以下纯判断区分:
predicate block_annotation(p, ctx) {
q = p + 1;若 kind(q) == Bang,q += 1
若 kind(q) != LParen,返回 true
result = 从 q probe Balanced(LParen, RParen, false; ctx)
若 result == Miss 或 result.status != Closed,返回 true
r = result.end + 1
跳过 r 处的 Whitespace
返回 kind(r) in {EOF, Newline, LineComment}
}
探测失败仍进入块注解路径,让实际字面量解析产生诊断。block_annotation 只选择分派路径,payload 的合法性由 Annotation 检查。
检查 | 负责阶段 |
|---|---|
缺少 payload、payload 不是 Dict、集合未闭合 | 原生 parser 的 Syntax 诊断;保留 Annotation 与字面量恢复结构 |
Dict 中游离成员或非法值 | Lowering 的 Semantic 诊断 |
行内注解未紧邻内容元素、块级注解后没有块 | Materialize 的 Semantic 诊断 |
模块注解出现在内容之后或行内位置 | Lowering 的 Semantic 诊断 |
注解不进入内容树:前端只产出 Annotation { dict, span, kind } side list,Shape 完全不感知它。Shape 完成后,Materialize 把块级标注挂到 span 之后第一个 Block Item,把行内标注挂到 span 紧邻的下一个内容元素;模块注解归入 Doc attrs。多 block 的块级标注只落在第一个 block;需要范围标注时使用显式 #node[ ... ]。该挂载不会创建额外节点。
降糖与结构检查
lower 按 Builtin 的结果构造将行内结构映射为 Expr 序列;blocks 对块序列做相应转换并记录 Fragment 边界。普通正文的注释不产生 text,CST 仍保存它们。Resolve 确定调用类型后检查片段:全部 Inline 形成一个段落,单个 Block 表达式独立放置,其他组合报错。Shape 递归处理内容槽并归组 section,注解挂载在其后完成。
structural_children 表示结构槽校验所见的叶子视图:
structural_children(children) {
按原顺序访问每个 element:
若 ctor(element) == text、attrs 为空,且 text 字段为纯空白 String:跳过
若 ctor(element) == node,或 ctor(element) == paragraph 且 body flavor == None:
递归访问 element.children
否则:将 element 交给 builtin 的 require 检查构造器
}
该视图供列表项、表格行和单元格的签名检查使用。Resolve 在结构槽中展开 Fragment 语法分组、清理可忽略的空白,递归查看具有公开透明契约的 node;显式 paragraph 不是结构透明包装。普通内容槽仍遵守片段边界规则,Shape 不拆段。
Markdown 前端
解析边界
MarkdownFrontend 独立解析 .md、.markdown、.notmd 与 .nmd,使用 rushdown AST 并输出共享 Expr。Builtin 中的 MD.* 表示 AST 模式,描述到内容函数的映射。前端接口、注册和源码位置映射见 文档前端。
部分 | 解析归属 |
|---|---|
标题、段落、列表、引用、链接、表格与代码 | Markdown 前端 |
Notist 调用路径、参数与注解 payload | 原生语法与共用 lowering |
调用的 | Markdown 前端;按 flavor 选择行内或块规则 |
名称、签名、段落定稿与 section | 共享 Resolve / Shape / Materialize |
Markdown 扩展支持 @(dict)、@!(dict)、#name(args)[body]、包限定调用与透明 #node[body]。[正文] 是行内槽;两端内侧均留白的 [ 正文 ] 是块槽,可以包含空行、标题、列表、引用和代码块。位置映射将移除容器前缀后的局部位置还原为原始源码范围。
Wiki 链接
Markdown 与原生 Markup 均支持 [[target]] 和 [[target|显示文本]],target 按普通链接路径解析。target 去掉首尾空白,其余文字原样保留;显示文本按 Markdown 行内规则解析,省略时显示 target。
Wiki 链接不能跨行,未闭合的括号保留为普通文字;代码与数学 payload 内不识别链接,转义的开括号不触发 Wiki 链接。GFM 表格仍以未转义的管道符分列,带显示文本的链接在单元格中写作 [[page.not\|显示文本]]。
调用与注解的探测
Markdown 扩展扫描调用头时用共用字面量 lexer 屏蔽 String / 注释中的圆括号;扫描 body 时按 Markdown 的转义、反引号代码片段、代码围栏与行内数学片段屏蔽方括号。它使用 Markdown 容器提供的源码范围,原生 Balanced / BlockRun 的 token 上下文只用于原生 Markup。
独占行的注解产生块级 side annotation,紧邻元素的注解产生行内 side annotation;两者都在 Shape 之后按源码位置挂载(见“注解”一节)。模块注解仅允许位于文档开头;标题前的注解在 Materialize 时落到它开启的 Section。扩展节点未闭合时保留原始文字并产生 Syntax 诊断,非法字面量产生对应的源码诊断。
# 标题 仍为 Markdown 标题,普通 #tag 和邮件地址仍为文字。参数与属性字符串保持不透明;代码、数学 payload 和转义中的注解 / 调用不执行。
文字与输出
普通文字、链接地址与 embed 字段的标点转义和字符引用只解码一次;raw / math 不解码。
decode_once从左到右读取原输入,输出不再参与解码;decode_optional对 none 保留 none,否则调用decode_once。单换行、双空格换行、空行与显式分段的共享行为见 Builtin 的 paragraph;列表编号和 lazy continuation 见 list / item。
行内 HTML 标签忽略并保留周围普通文字,HTML 块整体跳过。
实现定位
规则 | 实现位置 |
|---|---|
原生词法状态与标记 |
|
共用字面量 token、字符串与注释扫描 |
|
上下文、正文分派与调用探测 |
|
共用路径、调用头、注解与字面量语法 |
|
表格行与单元格边界 |
|
原生文档 lowering |
|
共用注解与调用头 lowering |
|
Markdown 扩展扫描与位置映射 |
|
Markdown Wiki 链接 |
|
Markdown 文字解码与段落分段 |
|
注解挂载与 Item 生成 |
|
结构槽的清理与校验 |
|