惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

J
Java Code Geeks
博客园 - 司徒正美
博客园 - 【当耐特】
爱范儿
爱范儿
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
IT之家
IT之家
人人都是产品经理
人人都是产品经理
雷峰网
雷峰网
酷 壳 – CoolShell
酷 壳 – CoolShell
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
大猫的无限游戏
大猫的无限游戏
月光博客
月光博客
宝玉的分享
宝玉的分享
V
V2EX
S
SegmentFault 最新的问题
V
Visual Studio Blog
阮一峰的网络日志
阮一峰的网络日志
Martin Fowler
Martin Fowler
Jina AI
Jina AI
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
博客园_首页
L
LangChain Blog
D
Docker
腾讯CDC

See you soon

哟,好久不见,无线打印 | See you soon 试试将文章版本化管理吧 | See you soon 使用 Quadlet 将 Podman 中的 Postgres 当作 systemd 服务运行 | See you soon 大他者,那个无时无刻都在盯着你的东西 | See you soon Laws of Software Engineering,软件工程定律 | See you soon 浅记多因素身份认证 | See you soon Linux 内核中的度量单位 | See you soon 重置 GPG 智能密钥 | See you soon 向 NAS 引入 samba | See you soon 无法重复键入的 Fcitx5 | See you soon ZFS 降级事故 | See you soon 记被 XanMod Kernel 和 AppArmor 联合坑的一次踩坑 | See you soon agent 的 skill 与 toolcall | See you soon 记一次服务器被挂恶意挖矿二进制 | See you soon 活着的 Arc | See you soon 令 acme.sh 使用 Cloudflare 的 DNS API 签发与续签证书 | See you soon 于 Tokio 中卸载 CPU Bound 任务 | See you soon 如我所见,梦破碎的时候 | See you soon 74LS 家族手册 | See you soon JDK Projects 备忘录 | See you soon 关于历史 | See you soon 用 curl 下载 OnePlus 的 ROM | See you soon 实用命令切片 | See you soon 再见,Oh My Zsh。 | See you soon 你不应该复用 strings.Builder | See you soon 博客的明日 | See you soon 被 AppArmor 击杀的 Dockge | See you soon AI 时代的自我 | See you soon 支持删除的布隆过滤器 | See you soon 基于栈的虚拟机与基于寄存器的虚拟机 | See you soon
Paradoxical 札记 | See you soon
Krysztal Huang · 2025-05-12 · via See you soon

最近把 neorg 作为笔记和规划系统有点上头,但苦于没一个好用的 Language Server,自己又对编译器前端方面略有了解于是决定写下这篇大概会持续更新的札记

我们的最终目的是实现一个不错的 Language Server 并且可以在 neovim/helix 上跑起来

为什么是 neovim/helix 呢?因为我暂时不会继续用 VSCode 了

流水线设计

考虑到我希望 paradoxical 有如下两个功能

  • 重新格式化 neorg 文档
  • 良好的补全

因此我决定采用如下流水线设计:

Text → Lexer (RawTokenKind) → CST → Typed AST/HIR → Semantic Analysis → LSP Server → Editor

| src | Lazy Lexer | CST | AST |

需要着重说明的是,我们使用 rope 来保存和编辑源文件,这样我们所有的操作的时间复杂度都是

目标

阶段 0

在阶段 0 需要实现最基本的功能:解析

但由于 neorg 的格式内容较多所以在这个阶段只考虑实现一个子集

  • 标题
  • 有序/无序列表
  • 文本段
    • 文本类型 Paragraph:这种类型允许使用 neorg 的修饰符例如 {} // $||$ 等,这些修饰符可以正常执行其对应的功能
    • 逐字类型 VerbatimParagrah: 这种类型的文本段不会处理 neorg 的修饰符,所有输入的修饰符都应该原样输出
  • 标签 Tag

NOTE

请注意,由于 neorg 设计上要求的非歧义解析,所有解析失败的内容都应该回归为 文本类型 Paragraph

Token 设计

基于 Token 的功能和现在的目标,我设计出如下的 RawTokenKind

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]

pub enum RawTokenKind {

Punct(char),

NormalChar(char),

Tag,

Linending(LinendingKind),

}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]

pub enum LinendingKind {

LF,

CRLF,

}

以上的代码省略了对于 Display trait 的实现

Token 解析规则

我们使用 nom 这个库来实现解析,因为 neorg 的文档风格非常适合使用组合子解析器的方式进行解析。

不自己编写递归下降解析器是因为 neorg 的规范实际上比较简单,而且 neorg 的二义性情况很容易被判断,因此我们直接使用组合子解析器来实现解析就好

由于我们需要保留 Token 在文档里的位置信息,因此基于 RawTokenKind 派生出 RawToken,添加 span 字段保留当前 Token 的在文档里的位置信息(以偏移表示)

#[derive(Debug, Clone)]

pub struct RawToken {

pub kind: RawTokenKind,

pub span: Range<usize>,

}

接下来我们开始编写对于几个类型 Token 的组合子

pub const AVAILIABLE_PUNCT: &[char] = &['(', ')', '-', '~', '*', '/', '{', '}', '[', ']'];

fn parse_punct(input: Span) -> IResult<Span, RawToken> {

let (input, pos) = position(input)?;

let (input, matched) = nom::character::complete::one_of(AVAILIABLE_PUNCT)(input)?;

let token = RawToken {

kind: RawTokenKind::Punct(matched),

span: pos.location_offset()..(pos.location_offset() + matched.len_utf8()),

};

Ok((input, token))

}

fn parse_normal_char(input: Span) -> IResult<Span, RawToken> {

let (input, pos) = position(input)?;

let (input, matched) = nom::character::complete::satisfy(|c| {

!AVAILIABLE_PUNCT.contains(&c) && c != '@' && c != '\n' && c != '\r'

})(input)?;

let token = RawToken {

kind: RawTokenKind::NormalChar(matched),

span: pos.location_offset()..(pos.location_offset() + matched.len_utf8()),

};

Ok((input, token))

}

fn parse_tag(input: Span) -> IResult<Span, RawToken> {

let (input, pos) = position(input)?;

let (input, matched) = nom::character::complete::char('@')(input)?;

let token = RawToken {

kind: RawTokenKind::Tag,

span: pos.location_offset()..(pos.location_offset() + matched.len_utf8()),

};

Ok((input, token))

}

fn parse_linending(input: Span) -> IResult<Span, RawToken> {

let (input, pos) = position(input)?;

let (input, ending) = nom::branch::alt((

nom::combinator::map(nom::bytes::complete::tag("\n"), |_| LinendingKind::LF),

nom::combinator::map(nom::bytes::complete::tag("\r\n"), |_| LinendingKind::CRLF),

))

.parse(input)?;

let token = RawToken {

kind: RawTokenKind::Linending(ending),

span: pos.location_offset()..(pos.location_offset() + ending.to_string().len()),

};

Ok((input, token))

}

  • 对于 RawTokenKind::Tag 我们直接提取 @ 字符就好
  • 对于 RawTokenKind::Linending 我们需要判断并且保留文档里的换行符类型
    • \n:UNIX 平台所使用的换行符
    • \r\n:Windows 平台所使用的换行符
  • 对于其他的标点符号,我们引入一个常量 AVAILIABLE_PUNCT 表示可以出现在 RawTokenKind::Punct 内的字符(相当于白名单)
  • 如果在以上情况之外,则归类为 RawTokenKind::NormalChar

因此,我们可以总结出如下的 lexer

let rope = Rope::from_str(source);

let mut tokens = Vec::new();

let mut input = Span::new(source);

let mut parsers = nom::branch::alt((

Self::parse_tag,

Self::parse_linending,

Self::parse_punct,

Self::parse_normal_char,

));

while !input.fragment().is_empty() {

let Ok((rest, token)) = parsers.parse(input) else {

return Err(error::Error::Lexer);

};

tokens.push(token);

input = rest;

}

Ok(Self { text: rope, tokens })

单元测试也是不可少的一部分,因为包括对于中文的解析是略微麻烦也需要验证的

#[cfg(test)]

mod tests {

use super::*;

use crate::token::{LinendingKind, RawTokenKind};

#[test]

fn test_raw_document_parsing() {

let input = "@hello\n-world\r\n";

let doc = RawDocument::new(input).expect("Failed to parse document");

let expected = vec![

RawToken {

kind: RawTokenKind::Tag,

span: 0..1,

},

RawToken {

kind: RawTokenKind::NormalChar('h'),

span: 1..2,

},

RawToken {

kind: RawTokenKind::NormalChar('e'),

span: 2..3,

},

RawToken {

kind: RawTokenKind::NormalChar('l'),

span: 3..4,

},

RawToken {

kind: RawTokenKind::NormalChar('l'),

span: 4..5,

},

RawToken {

kind: RawTokenKind::NormalChar('o'),

span: 5..6,

},

RawToken {

kind: RawTokenKind::Linending(LinendingKind::LF),

span: 6..7,

},

RawToken {

kind: RawTokenKind::Punct('-'),

span: 7..8,

},

RawToken {

kind: RawTokenKind::NormalChar('w'),

span: 8..9,

},

RawToken {

kind: RawTokenKind::NormalChar('o'),

span: 9..10,

},

RawToken {

kind: RawTokenKind::NormalChar('r'),

span: 10..11,

},

RawToken {

kind: RawTokenKind::NormalChar('l'),

span: 11..12,

},

RawToken {

kind: RawTokenKind::NormalChar('d'),

span: 12..13,

},

RawToken {

kind: RawTokenKind::Linending(LinendingKind::CRLF),

span: 13..15,

},

];

assert_eq!(doc.tokens, expected);

}

#[test]

fn test_raw_document_parsing_with_chinese() {

let input = "@你好\n-世界\r\n";

let doc = RawDocument::new(input).expect("Failed to parse document");

let expected = vec![

RawToken {

kind: RawTokenKind::Tag,

span: 0..1,

},

RawToken {

kind: RawTokenKind::NormalChar(''),

span: 1..4,

},

RawToken {

kind: RawTokenKind::NormalChar(''),

span: 4..7,

},

RawToken {

kind: RawTokenKind::Linending(LinendingKind::LF),

span: 7..8,

},

RawToken {

kind: RawTokenKind::Punct('-'),

span: 8..9,

},

RawToken {

kind: RawTokenKind::NormalChar(''),

span: 9..12,

},

RawToken {

kind: RawTokenKind::NormalChar(''),

span: 12..15,

},

RawToken {

kind: RawTokenKind::Linending(LinendingKind::CRLF),

span: 15..17,

},

];

assert_eq!(doc.tokens, expected);

}

}