使用 Template Expand 拯救又臭又长的模板代码

· · 科技·工程

前言

几乎每个 OIer 都有自己的代码板子,包括但不限于快读快写、算法模板、数据结构等等。但是,如果你想要把代码提交到 OJ 上,你就必须把模板原原本本地给拷贝上去。如此一来,即使你只是在写一个 CF 800 的题,你可能也得面对 200+ 的行号,以及一堆和解法根本无关的只能扰乱你的注意力的代码。

最理想的情况下,模板代码自然应该封装成库,就像 AC Library 那样,只需要一行 #include 就可以了。但是显然不是每个 OJ 都是 AtCoder,有一个现成的库给你用。并且 AtCoder 也只能用 OJ 指定的库,你没法使用自定义的库。

所以,我写了一个工具,给出了一个折中方案:在本地环境里,尽情使用 #include 来引入自定义库。但是准备提交到 OJ 的时候,就使用这个工具来将 #include 直接展开成源代码。虽然这不能该变提交上去的代码仍然有一堆长长的模板的事实,但是至少我们写代码的时候六根清净了。

我把这个工具命名为 Template Expand

Quickstart

我的代码已经在 Github 开源:https://github.com/ExplodingKonjac/OITools_Template-Expand

并且上线了 VSCode 扩展商店:https://marketplace.visualstudio.com/items?itemName=explodingkonjac.texpand-vscode

这个工具提供了两种使用形式:命令行程序和 VSCode 扩展。具体的用法参考仓库和扩展的 README 文件。除了模板展开之外,它还提供了 token 级别的压缩代码的功能:在不破坏语义的情况下把相邻 token 之间的空白尽可能消除。

CLI 工具展示

texpand-cli 的编译好的不同平台的二进制已经在 Github Release Page 中给出。假设你下载了二进制并将程序重命名为 texpand

运行 texpand --help 获取帮助文档:

$ texpand --help
Expand C/C++ #include dependencies into a single file

Usage: texpand-cli [OPTIONS] <INPUT>

Arguments:
  <INPUT>  Input C/C++ source file to expand

Options:
  -c, --compress           Enable output compression (overrides config)
      --no-compress        Disable output compression (overrides config)
  -i, --include <INCLUDE>  Add an include search path (repeatable, overrides config file)
  -o, --output <OUTPUT>    Write output to a file (instead of stdout)
  -C, --clipboard          Copy output to clipboard
      --config <CONFIG>    Path to config file (default: ~/.config/texpand.toml)
  -h, --help               Print help
  -V, --version            Print version

其中,配置文件的格式为

# 本地头文件搜索路径列表,按顺序查找
include_paths = [
    "./templates",
    "~/algo/cpp_lib"
]

# 默认是否开启代码压缩
default_compress = false

下面是一些示例用法

# 展开 main.cpp 并将结果输出到标准输出
texpand main.cpp

# 展开 main.cpp,开启代码压缩,并保存到 output.cpp
texpand main.cpp -c -o output.cpp

# 展开 main.cpp,使用自定义头文件搜索路径
texpand main.cpp -i ./templates -i ~/cp-lib

# 展开 main.cpp,开启压缩,并将结果复制到剪贴板
texpand main.cpp -c -C

# 从标准输入读取源代码
cat main.cpp | texpand - -c

# 使用自定义配置文件
texpand main.cpp --config /path/to/my-config.toml

VSCode 扩展展示

VSCode 扩展已经上线 VSCode Marketplace。你可以直接在扩展商店里搜索到。

安装扩展后,打开任意 C/C++ 文件,都有下面的功能:

  1. 编辑器标题栏:点击右上角的文件图标按钮,一键展开并复制到剪贴板。
  2. 右键菜单:右键点击编辑器,在 Texpand 子菜单中选择操作。
  3. 命令面板:按 Ctrl+Shift+P / Cmd+Shift+P,输入 Texpand 查看所有命令。
  4. 状态栏:点击右下角的 Texpand 按钮,快速切换压缩开关和输出模式。

给出一些效果图

如何实现

经过零秒钟的思考,我决定使用编程语言中的原神——Rust 来编写这个工具。这样我们既可以爽当调包侠,又能避免效率焦虑。

Agentic Coding?

正好赶上 DeepSeek V4 发布,所以我决定直接用这个项目试试水,测试一下 Claude Code 搭配 DeepSeek V4 的能力和性价比如何。

这里我采用了一个比较简单的上下文设计:.claude 目录下面放三个文件:

其中 CLAUDE.mdARCHITECTURE.md 都是长期不变的,其内容完整注入上下文。而 PROGRESS.md 不注入,agent 在需要的时候才读写。

配合上 DeepSeek 优秀的缓存算法,整体使用下来缓存命中率能达到 98\% 以上。

架构设计

我们需要支持命令行工具形式和 VSCode 扩展形式,那么一个很自然的设计就是使用一个公有的模块 texpand-core,处理代码展开的核心逻辑。然后各自再编写两个模块,实现相应的接口和交互逻辑。

不同端侧的主要差异是文件名的解析和读取。CLI 工具只需要处理本地文件系统,但是 VSCode 扩展需要处理不同情形的 workspace。

所以我们设计一个 FileResolver trait


pub trait FileResolver {
    fn resolve(&self, includer_path: &Path, include_path: &str) -> Result<PathBuf>;

    fn read_content(&self, resolved_path: &Path) -> Result<String>;
}

然后就是如何解析代码。Rust 中有一个很强大的 crate 叫作 tree-sitter。这是一个轻量高效的 AST 解析器框架。所以我们直接使用 tree-sittertree-sitter-cpp 解析出 AST 后,再从中提取 #include 语句以及进行代码压缩。

核心功能

首先是解析顺序。最直观的做法就是按照顺序扫描 AST,然后分析遇到每个语句:

此外,我们需要处理重复 #include 的问题。最直观的想法是直接开一个全局的 vis 记录哪些文件已经展开过,但是这存在问题。考虑下面的代码:

#ifdef FOO
#include "my_header.h"
#else
#include "my_header.h"
#endif

虽然很蠢,但是确实可能出现。此时如果只展开一次(比如在成功分支),那么如果进入了另一个分支,头文件内容就会丢失,然后编译错误。

所以,我们处理重复 #include 的时候,不能只存文件名,还要存这个语句的『预处理上下文』,也就是:

最后就是考虑代码压缩问题了。因为我们已经在遍历 AST,而 AST 叶子就是单个 token。因此已经不用考虑分词的问题了。大部分情况下,压缩逻辑就是:

然后就是臭名昭著的预处理语句。语句内部结构可以压缩,但是每个语句末尾必须有换行。这个逻辑过于繁琐,可以直接参考仓库内的 texpand-core/src/compressor.rs

CLI 侧

CLI 的功能相对简单。我们使用 serde crate 作为序列化/反序列化框架,使用 toml crate 实现 TOML 配置文件的读取,使用 clap crate 解析命令行参数。

我们只需要使用标准的文件系统实现 FileResolver 就行了:

struct FsResolver {
    include_paths: Vec<PathBuf>,
}

impl FsResolver {
    fn new(include_paths: Vec<PathBuf>) -> Self {
        Self { include_paths }
    }
}

impl FileResolver for FsResolver {
    fn resolve(&self, includer_path: &Path, include_path: &str) -> Result<PathBuf> {
        let path = Path::new(include_path);
        if path.is_absolute() {
            return std::fs::canonicalize(path).with_context(|| "failed to canonicalize path");
        }

        if let Some(includer_dir) = Path::new(includer_path).parent() {
            let candidate = includer_dir.join(path);
            if candidate.exists() {
                return std::fs::canonicalize(candidate)
                    .with_context(|| "failed to canonicalize path");
            }
        }

        for inc_path in &self.include_paths {
            let candidate = inc_path.join(path);
            if candidate.exists() {
                return std::fs::canonicalize(candidate)
                    .with_context(|| "failed to canonicalize path");
            }
        }

        anyhow::bail!("include file not found: {include_path}")
    }

    fn read_content(&self, resolved_path: &Path) -> Result<String> {
        std::fs::read_to_string(resolved_path).with_context(|| "failed to read content")
    }
}

不过剪贴板倒是有一点难搞。确实有一个 crate 叫作 arboard 封装了剪贴板的逻辑。但是剪贴板的行为在 Linux 和其它系统上并不一致:

其它操作系统上,我们的实现很符合直觉,只需要调用一行 clipboard.set_text(text) 然后退出就行了。但是在 Linux 中,这样做很可能导致内容丢失。但是如果我们一致阻塞等待,就会让 CLI 工具卡着不退出。虽然只要 paste 一下就自动退出了,但是看起来仍然体验不好。

所以在 Linux 上,我们采取另一种方案:生成完内容后,我们创建一个子进程,将剪贴板内容传递给子进程,让子进程操作剪贴板。然后父进程直接退出,这样子进程就会变成孤儿进程移到后台。最后在 paste 发生的时候,这个后台进程自动退出。

有两种实现方案:

比如下面就是使用 fork() 的代码(引入了 nix crate 作为 syscall 的封装):


#[cfg(target_os = "linux")]
fn copy_to_clipboard(text: &str) -> Result<()> {
    use arboard::SetExtLinux;
    use nix::unistd::{ForkResult, fork};

    let text = text.to_owned();

    match unsafe { fork() }.context("failed to fork clipboard daemon")? {
        ForkResult::Child => {
            if let Ok(mut clipboard) = arboard::Clipboard::new() {
                let _ = clipboard.set().wait().text(text);
            }
            std::process::exit(0);
        }
        ForkResult::Parent { child: _ } => Ok(()),
    }
}

#[cfg(not(target_os = "linux"))]
fn copy_to_clipboard(text: &str) -> Result<()> {
    let mut clipboard = arboard::Clipboard::new().context("failed to open clipboard")?;
    clipboard
        .set_text(text)
        .context("failed to set clipboard text")?;
    Ok(())
}

VSCode 扩展侧

VSCode 扩展使用 TypeScript 开发,因此我们必须把 Rust 编译到 WASM。

因为 tree-sitter-cpp 里面存在 C++ 实现的部分且用到了标准库,wasm-unknown-unknown target 无法处理。因此我们需要编译到 wasm-wasip1 target。

我们需要安装 WASI-SDK 然后在 .cargo/config.toml 里面配置:

# 或者改成相应的安装目录

[env]
CC_wasm32_wasip1 = "/opt/wasi-sdk/bin/clang"
CXX_wasm32_wasip1 = "/opt/wasi-sdk/bin/clang++"
AR_wasm32_wasip1 = "/opt/wasi-sdk/bin/llvm-ar"
CFLAGS_wasm32_wasip1 = "--sysroot=/opt/wasi-sdk/share/wasi-sysroot"
CXXFLAGS_wasm32_wasip1 = "--sysroot=/opt/wasi-sdk/share/wasi-sysroot"

[target.wasm32-wasip1]
runner = "wasmtime"

然后,我们创建一个二进制 crate,功能是从环境变量读取配置,然后从标准输入读取代码文件,最后结果输出到标准输出。

然后在扩展代码里面,可以直接利用 @vscode/wasm-wasi 包来创建一个 WASI 子进程。

import { Wasm } from '@vscode/wasm-wasi/v1';

const api = await Wasm.load()
const wasmUri = vscode.Uri.joinPath(context.extensionUri, 'pkg', 'texpand-vscode.wasm');
const module = await api.compile(wasmUri);

const proc = await api.createProcess('texpand', module, {
    env: {
        TEXPAND_ENTRY_PATH: wasiEntryPath,
        TEXPAND_COMPRESS: opts.compress ? 'true' : 'false',
        TEXPAND_INCLUDE_PATHS: wasiIncludePaths.join(','),
    },
    stdio: {
        out: { kind: 'pipeOut' },
        err: { kind: 'pipeOut' },
    },
    mountPoints: [
        { kind: 'workspaceFolder' },
    ],
});

createProcess 的调用中,我们把工作区目录挂载在 /workspace 路径下。这样就能方便地兼容 remote 之类的各种 VSCode Workspace 的情景了。

这样的话我们实现的 FileResolver 就可以非常简单:

struct WasiFsResolver {
    include_paths: Vec<String>,
}

impl FileResolver for WasiFsResolver {
    fn resolve(&self, includer_path: &Path, include_path: &str) -> Result<PathBuf> {
        let path = Path::new(include_path);

        if path.is_absolute() {
            return Ok(path.into());
        }

        if let Some(parent) = Path::new(includer_path).parent() {
            let candidate = parent.join(path);
            if candidate.exists() {
                return Ok(candidate);
            }
        }

        for prefix in &self.include_paths {
            let candidate = Path::new(prefix).join(path);
            if candidate.exists() {
                return Ok(candidate);
            }
        }

        anyhow::bail!("texpand: file not found in workspace: {}", include_path)
    }

    fn read_content(&self, resolved_path: &Path) -> Result<String> {
        std::fs::read_to_string(resolved_path).with_context(|| "failed to read content")
    }
}

相比于 CLI,去除了 canonicalize。因为 canonicalize 通过 realpath 系统调用解析符号链接,但是 /workspace 目录是一个虚拟文件系统,并不支持。

为什么要创建进程而不是直接封装成函数呢?很简单,纯粹是因为库支持不好……你需要写一堆 C ABI,还得手动操作内存分配。

完成这些核心功能后,就剩下标准的 UI 设计、打包脚本等等了。这里不再赘述。

后记

目前这个工具还在开发中,可能还有 bug,可能还有待添加的功能。欢迎给 repo 提 issue 或者 PR。

喜欢的话给 repo 点个 star 谢谢喵。