feat(checker+typst): 嵌入 render 使 cph 可 cargo install;实现 shell step 执行器(ADR-0013)

三件事,服务于"把恒一小奥 KenKen 课作者化为合法 native 工程文件"这条主线
(工程文件本身在 repo 外的 sibling playground,不入此提交):

1. cph-typst: 把 render/ 编译期嵌入二进制,`cargo install` 后开箱即用
   - build.rs 把 render/ 拷进 OUT_DIR,剔除 dev 自指符号链接 vendor/local-packages
     (否则 include_dir! 无限递归)
   - embedded.rs 用 include_dir! 嵌入,运行时按版本解压到 per-user cache dir
     (CPH_RENDER_DIR 仍优先作 dev override)
   - default_render_dir 不再用编译期 CARGO_MANIFEST_DIR 写死路径

2. cph-check + cph-cli: 实现 Step::Shell 执行器(此前仅占位)
   - run_shell_target: 跑 check 门控 → 以工程根为 cwd 执行 shell step,首非零退出即停
   - target_is_shell + CLI run_shell_build 路由;`check` 不跑 shell(结构校验只看 lesson)
   - compile_targets 只保留含 TypstCompile 的 target,纯 shell target 不走 typst 引擎
   - 语义:失败属 build-过程错误,不入 6 类诊断(taxonomy 保持 6,显式拒绝加 ShellStep 码)

3. spec + ADR: 在 Export/Render.lean 钉 shell step 执行语义 prose;
   ADR-0013(已实现)、ADR-0014(md/HTML 导出后端方向,Proposed/设计先行,未实现)

测试:全 workspace 42 passed(新增 4 个 shell 执行器测试);lake build 25 jobs 绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 12:09:49 +08:00
parent c6b4d439ac
commit 9590e15236
12 changed files with 809 additions and 18 deletions
+104
View File
@@ -0,0 +1,104 @@
//! Embedded `cph-render` package — makes an installed `cph` self-contained.
//!
//! The typst [`crate::world::LessonWorld`] reads the render package
//! (`@local/cph-render` + the vendored `@preview/numbly`) **from disk**, by
//! `render_dir.join(vpath.realize)`. In a source checkout that dir is the repo's
//! `render/`. But a `cargo install`-ed binary has no repo around it — the old
//! `env!("CARGO_MANIFEST_DIR")/../../render` path points into the build sandbox
//! and does not exist at runtime.
//!
//! So we **embed the whole `render/` tree into the binary** at compile time
//! (`include_dir!`) and, on first use, **extract it to a per-user cache dir**
//! keyed by the crate version. `default_render_dir` returns that extracted path.
//! The tree is tiny (~140 KiB, ~33 files), so embedding is cheap and extraction
//! is a one-time cost per version.
//!
//! `render/vendor/local-packages/` (a dev-only self-referential symlink,
//! gitignored) is excluded by the build script when it stages the tree for
//! embedding — see `build.rs`. The embedded copy never contains it, which is
//! correct: the World resolves `@local/cph-render` from `render_dir` directly,
//! and `@preview/*` from `vendor/typst-packages/`, neither via that symlink.
use std::path::{Path, PathBuf};
use include_dir::{include_dir, Dir};
/// The `render/` tree, embedded at compile time from the **staged** copy the
/// build script wrote to `OUT_DIR/render` (which omits the dev-only symlink that
/// would otherwise make `include_dir!` recurse forever). `CPH_STAGED_RENDER_DIR`
/// is set by `build.rs`.
static RENDER_DIR: Dir<'_> = include_dir!("$CPH_STAGED_RENDER_DIR");
/// Resolve the directory the typst World should read the render package from,
/// extracting the embedded copy to a per-user cache dir if needed.
///
/// Resolution order:
/// 1. `CPH_RENDER_DIR` env var — an explicit override (dev convenience: point
/// at the live repo `render/`).
/// 2. The extracted embedded copy under the user cache dir
/// (`<cache>/cph/render-<version>/`). Extracted once per crate version;
/// subsequent runs reuse it.
///
/// On any failure to locate a cache dir or extract, falls back to a temp-dir
/// location so the engine still works (just re-extracting per process).
pub fn resolve_render_dir() -> PathBuf {
if let Ok(dir) = std::env::var("CPH_RENDER_DIR") {
return PathBuf::from(dir);
}
ensure_extracted().unwrap_or_else(|_| {
// Last-resort: extract under the OS temp dir. Still correct, just not
// cached across processes.
let fallback = std::env::temp_dir().join(format!("cph-render-{}", env!("CARGO_PKG_VERSION")));
let _ = extract_to(&fallback);
fallback
})
}
/// The version-keyed cache location and a guarantee the embedded tree is present
/// there. Returns the directory the World should use.
fn ensure_extracted() -> std::io::Result<PathBuf> {
let base = dirs::cache_dir()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no user cache dir"))?;
let dest = base
.join("cph")
.join(format!("render-{}", env!("CARGO_PKG_VERSION")));
// A sentinel marks a complete extraction; if present, reuse as-is. (Keyed by
// version, so a new `cph` version re-extracts into a fresh dir.)
let sentinel = dest.join(".extracted");
if sentinel.is_file() {
return Ok(dest);
}
extract_to(&dest)?;
std::fs::write(&sentinel, env!("CARGO_PKG_VERSION"))?;
Ok(dest)
}
/// Write the embedded `render/` tree under `dest` (creating dirs as needed).
///
/// Each embedded entry's `.path()` is already relative to the embed root (e.g.
/// `src/elements/segment.typ`), so we join the **full** path under `dest` and
/// create parents — no per-level mirroring needed.
fn extract_to(dest: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dest)?;
write_dir(&RENDER_DIR, dest)
}
/// Recursively write an embedded [`Dir`]'s files under `dest`, preserving the
/// full relative path of each file.
fn write_dir(dir: &Dir<'_>, dest: &Path) -> std::io::Result<()> {
for entry in dir.entries() {
match entry {
include_dir::DirEntry::Dir(sub) => write_dir(sub, dest)?,
include_dir::DirEntry::File(file) => {
let file_dest = dest.join(file.path());
if let Some(parent) = file_dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&file_dest, file.contents())?;
}
}
}
Ok(())
}
+13 -11
View File
@@ -35,6 +35,7 @@
//! VirtualPath)`, and a diagnostic span is a `DiagSpan` (not a bare `Span`).
mod diag;
mod embedded;
mod manifest;
mod world;
@@ -68,8 +69,9 @@ pub struct Engine {
impl Engine {
/// Construct an engine, discovering system + embedded fonts. The render
/// package directory is taken from the `CPH_RENDER_DIR` environment variable
/// when set, otherwise defaults to `<repo>/render` resolved relative to this
/// crate (`CARGO_MANIFEST_DIR/../../render`).
/// when set, otherwise the **embedded** render package is extracted to a
/// per-user cache dir and used (see [`default_render_dir`]). This keeps an
/// installed `cph` self-contained — no source checkout required at runtime.
pub fn new() -> Self {
Self::with_render_dir(default_render_dir())
}
@@ -234,14 +236,14 @@ fn map_all(world: &LessonWorld, diags: &[typst::diag::SourceDiagnostic]) -> Vec<
.collect()
}
/// `<crate>/../../render` — the repo's render package, used when `CPH_RENDER_DIR`
/// is unset.
/// The render-package directory the engine resolves `@local/cph-render` (and the
/// vendored `@preview/*`) from when no explicit dir is given.
///
/// Honors `CPH_RENDER_DIR` first (dev override → live repo `render/`); otherwise
/// returns a per-user cache dir holding the **embedded** render tree, extracted
/// on demand (see [`embedded`]). This is what makes a `cargo install`-ed `cph`
/// self-contained: it no longer depends on a source checkout living at a
/// compile-time path.
fn default_render_dir() -> PathBuf {
if let Ok(dir) = std::env::var("CPH_RENDER_DIR") {
return PathBuf::from(dir);
}
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("render")
embedded::resolve_render_dir()
}