feat(cli): cph init / cph add 子命令(工程脚手架)

init 生成可 check 的工程根(manifest.toml + .cph-version + 默认 exports/student.typ + 空 kind 目录);
add 按 kind 建 part 目录/element.toml/必填内容文件并追加 [[parts]]。均纯本地,不涉及 hub 语义;
Engine 改惰性构造。cph-schema 新增 required_content_field_names() 作为 add 建文件合同。
This commit is contained in:
2026-08-05 21:07:50 +08:00
parent 7a0b4b0c4f
commit 5866b3d6cc
6 changed files with 444 additions and 9 deletions
+399 -9
View File
@@ -95,6 +95,32 @@ enum Command {
/// Which shell to generate completions for.
shell: CompletionTarget,
},
/// Scaffold a new, check-clean engineering-file root under `path`. Owns the
/// `manifest.toml` (with a generated `[project].id`), a pinning
/// `.cph-version` (ADR-0016), the stock `exports/student.typ` render
/// template, and the empty per-kind part folders. A local authoring
/// convenience — no hub semantics involved.
Init {
/// Directory to create the engineering file in. Created recursively if
/// missing; refused if it already holds a `manifest.toml`.
path: PathBuf,
/// Project name / lesson title. Defaults to the directory's base name.
#[arg(long, value_name = "NAME")]
name: Option<String>,
},
/// Add a new part to an engineering file: create its folder, its
/// element.toml, and the blank required content files, then append a
/// [[children]] entry to the root manifest.toml. A local authoring
/// convenience, not a hub write.
Add {
/// Engineering-file root (the folder with `manifest.toml`).
#[arg(long, default_value = ".", value_name = "DIR")]
root: PathBuf,
/// The element kind.
kind: String,
/// Display name of the new part (also its folder name).
name: String,
},
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
@@ -126,22 +152,34 @@ enum CompletionTarget {
fn main() -> ExitCode {
let cli = Cli::parse();
let engine = match &cli.render_dir {
Some(dir) => Engine::with_render_dir(dir.clone()),
None => Engine::new(),
};
match cli.command {
Command::Check { path } => run_check(&path, &engine),
Command::Build { path, targets, out } => run_build_command(&path, &engine, targets, out),
Command::Bundle { path, targets, out } => run_bundle_command(&path, &engine, targets, out),
Command::Check { path } => run_check(&path, &engine_from(&cli.render_dir)),
Command::Build { path, targets, out } => {
run_build_command(&path, &engine_from(&cli.render_dir), targets, out)
}
Command::Bundle { path, targets, out } => {
run_bundle_command(&path, &engine_from(&cli.render_dir), targets, out)
}
Command::Outline {
path,
format,
out,
force,
} => run_outline(&path, &engine, format, out, force),
} => run_outline(&path, &engine_from(&cli.render_dir), format, out, force),
Command::Completions { shell } => run_completions(shell),
Command::Init { path, name } => run_init(&path, name.as_deref()),
Command::Add { root, kind, name } => run_add(&root, &kind, &name),
}
}
/// Build the typst [`Engine`], honoring a `--render-dir` override. Constructed
/// lazily — only the render-touching commands (`check`, `build`, `bundle`,
/// `outline`) need it; the authoring ones (`init`, `add`, `completions`) skip
/// the render-package extraction cost entirely.
fn engine_from(render_dir: &Option<std::path::PathBuf>) -> Engine {
match render_dir {
Some(dir) => Engine::with_render_dir(dir.clone()),
None => Engine::new(),
}
}
@@ -164,6 +202,358 @@ fn run_completions(shell: CompletionTarget) -> ExitCode {
ExitCode::SUCCESS
}
// ===========================================================================
// Authoring subcommands (`init`, `add`): local engineering-file scaffolding.
// They never touch the hub and are deliberately local — they only create
// dirs/files and append to the local `manifest.toml`. `check` stays
// authoritative: whatever these write, `cph check` must accept.
/// The stock `exports/student.typ` written by `init` — the framework's default
/// render template (ADR-0011, outline-shape ADR-0029), the same file the
/// examples ship. Kept verbatim so a freshly scaffolded engineering file
/// renders out of the box; it imports `@local/cph-render:0.1.0`, which the
/// engine resolves from the embedded package. Presentation (heading numbering,
/// styling) is editable here per engineering file, not in the manifest.
const DEFAULT_STUDENT_TEMPLATE: &str = r##"// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011, outline shape ADR-0029).
//
// This is a *real, editable* file that lives in an engineering file at
// `exports/student.typ`. The framework compiles it AS THE MAIN FILE with the
// manifest injected:
// typst compile --root <eng-root> --input manifest=<path-rel-to-root> exports/student.typ <out>
//
// It is intentionally self-contained (no shared helper import) so it can be
// copied verbatim into a new engineering file's `exports/`. Presentation —
// heading numbering — lives HERE (editable per engineering file), not in the
// manifest and not hardcoded in the cph-render package.
//
// WHY THE INCLUDE LOOP IS HERE AND NOT IN cph-render: typst resolves a dynamic
// `include` path relative to the file it lexically appears in, and a package has
// its own virtual root — an include inside cph-render would resolve against the
// PACKAGE, not the engineering root. A `/<part.path>/<field>.typ` written HERE
// (this template lives under `--root`) resolves against `--root`. So the
// template loads content and hands cph-render an already-assembled `outline`
// array (elements interleaved with section headings, ADR-0029).
//
// OPEN CONTRACT POINT — optional-content presence. typst has no "does this file
// exist" primitive (a missing `include` is a hard compile error). So the
// template CANNOT probe disk the way the old Rust driver did for lemma `proof`.
// It relies on the manifest declaring which optional content fields are present,
// via a per-element `fields` array listing the content fields that exist on disk
// (the engine knows this — it walks the part dir). Required fields are loaded
// unconditionally; optional fields load only if listed in `fields`. If an element
// omits `fields`, optional content is skipped (conservative). The exact shape of
// this declaration is for the manifest/Rust contract to pin.
#import "@local/cph-render:0.1.0": render-lesson, part-fields, default-heading-numbering
// This template IS the student build, so the target is fixed.
#let target = "student"
// Read the injected manifest (a path string relative to typst --root).
#let manifest = toml(sys.inputs.manifest)
#let info = manifest.at("info", default: (:))
#let raw-outline = manifest.at("outline", default: ())
// Assemble each outline entry:
// - an "element" entry: include its content fields (computed absolute paths,
// resolved against --root) and read scalar fields from <path>/element.toml.
// `part-fields` (from cph-render) is the single source of truth for
// kind->fields.
// - a "section" entry (ADR-0029): pass its title/depth straight through — no
// content to load, it is a heading.
#let outline = raw-outline.map(raw => {
if raw.at("type", default: "element") == "section" {
(
entry-type: "section",
kind: raw.at("kind", default: none),
title: raw.at("title", default: ""),
depth: raw.at("depth", default: 1),
)
} else {
let kind = raw.at("kind", default: none)
let path = raw.at("path", default: none)
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ()))
// Which optional content fields are present on disk (manifest-declared).
let present = raw.at("fields", default: ())
let entry = (entry-type: "element", kind: kind)
// Required content fields: <path>/<field>.typ (absolute, root-relative).
for field in spec.content {
entry.insert(field, include "/" + path + "/" + field + ".typ")
}
// Optional content fields: only when the manifest says the file exists.
for field in spec.optional-content {
if field in present {
entry.insert(field, include "/" + path + "/" + field + ".typ")
}
}
// Scalar fields come from <path>/element.toml.
if spec.scalars.len() > 0 {
let element = toml("/" + path + "/element.toml")
for field in spec.scalars {
let v = element.at(field, default: none)
if v != none and v != "" { entry.insert(field, v) }
}
}
entry
}
})
// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes
// from cph-render; override here per engineering file if desired.
#render-lesson(
info: info,
target: target,
outline: outline,
heading-numbering: default-heading-numbering,
)
"##;
/// The on-disk folder a part of `kind` lives under (a convention shared by the
/// examples and the hub, not derivable from the kind schema, so it's pinned
/// here alongside the initializer). `None` for unknown kinds — the same set
/// `cph_schema::known_kinds()` reports.
fn kind_dir(kind: &str) -> Option<&'static str> {
match kind {
"segment" => Some("segments"),
"example" => Some("examples"),
"lemma" => Some("lemmas"),
"sop" => Some("sops"),
_ => None,
}
}
/// Encode `v` as lowercase base-36 for use in a generated project id.
fn encode_id(mut v: u64) -> String {
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
if v == 0 {
return "0".into();
}
let mut s = String::new();
while v > 0 {
s.push(ALPHABET[(v % 36) as usize] as char);
v /= 36;
}
s
}
/// Generate a `local-…` project id for a new engineering file (mirrors the
/// examples' `local-<a>-<b>` shape). Not security entropy — enough to be unique
/// per init, derived from time + pid + a per-process counter.
fn new_project_id() -> String {
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let counter = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mix = now.rotate_left(17)
^ (std::process::id() as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)
^ counter.wrapping_mul(0xBF58_476D_1CE4_E5B9);
format!("local-{}-{}", encode_id(mix), encode_id(counter))
}
/// Scaffold a new engineering-file root under `path`. Refuses to clobber an
/// existing `manifest.toml`, so a repeat run is safe.
fn run_init(path: &std::path::Path, name: Option<&str>) -> ExitCode {
if path.join("manifest.toml").exists() {
eprintln!(
"error: '{}' already contains manifest.toml; refusing to init over it",
path.display()
);
return ExitCode::FAILURE;
}
if let Err(e) = std::fs::create_dir_all(path) {
eprintln!("error: cannot create '{}': {e}", path.display());
return ExitCode::FAILURE;
}
let display_name = match name {
Some(n) => n.to_string(),
None => path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "untitled".into()),
};
let manifest = format!(
r#"[project]
id = "{id}"
name = "{name}"
[info]
title = "{name}"
# Export target (ADR-0009/0011): a typed build. The stock template at
# exports/student.typ imports @local/cph-render:0.1.0 (embedded in cph).
[targets.student]
artifact = {{ type = "single-file", filepath = "build/student.pdf" }}
[[targets.student.steps]]
type = "typst-compile"
template = "exports/student.typ"
"#,
id = new_project_id(),
name = display_name,
);
let files: &[(&str, &str)] = &[
("manifest.toml", &manifest),
(".cph-version", &format!("{}\n", env!("CARGO_PKG_VERSION"))),
("exports/student.typ", DEFAULT_STUDENT_TEMPLATE),
];
for (rel, content) in files {
let full = path.join(rel);
if let Some(parent) = full.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
eprintln!("error: cannot create '{}': {e}", parent.display());
return ExitCode::FAILURE;
}
}
if let Err(e) = std::fs::write(&full, content) {
eprintln!("error: cannot write '{}': {e}", full.display());
return ExitCode::FAILURE;
}
}
for dir in ["segments", "lemmas", "examples", "sops"] {
if let Err(e) = std::fs::create_dir_all(path.join(dir)) {
eprintln!("error: cannot create '{}': {e}", path.join(dir).display());
return ExitCode::FAILURE;
}
}
println!("initialized engineering file at {}", path.display());
println!(
" next: cph check {} | cph build {} --target student",
path.display(),
path.display()
);
ExitCode::SUCCESS
}
/// Add a new part to the engineering file at `root`: create its folder with
/// `element.toml` + blank required content files, then append its `[[children]]`
/// entry to the root `manifest.toml` (ADR-0029 root children). Rejects unknown
/// kinds, unsafe names, and anything that would double-register an existing part.
fn run_add(root: &std::path::Path, kind: &str, name: &str) -> ExitCode {
let dir = match kind_dir(kind) {
Some(d) => d,
None => {
eprintln!(
"error: unknown kind '{kind}'; expected one of: {}",
cph_schema::known_kinds().join(", ")
);
return ExitCode::FAILURE;
}
};
let trimmed = name.trim();
if trimmed.is_empty()
|| trimmed.contains('/')
|| trimmed.contains('\\')
|| trimmed.contains('"')
{
eprintln!("error: invalid part name {name:?}; use a plain folder name (no / \\ or quotes)");
return ExitCode::FAILURE;
}
let rel = format!("{dir}/{trimmed}");
let part_dir = root.join(&rel);
let manifest_path = root.join("manifest.toml");
let manifest_src = match std::fs::read_to_string(&manifest_path) {
Ok(s) => s,
Err(e) => {
eprintln!(
"error: cannot read '{}': {e} (run `cph init` here first?)",
manifest_path.display()
);
return ExitCode::FAILURE;
}
};
if part_dir.exists() {
eprintln!("error: '{}' already exists", part_dir.display());
return ExitCode::FAILURE;
}
if manifest_has_child(&manifest_src, &rel) {
eprintln!("error: manifest.toml already declares a part at '{rel}'");
return ExitCode::FAILURE;
}
if let Err(e) = std::fs::create_dir_all(&part_dir) {
eprintln!("error: cannot create '{}': {e}", part_dir.display());
return ExitCode::FAILURE;
}
let element_toml = format!("kind = \"{kind}\"\n");
if let Err(e) = std::fs::write(part_dir.join("element.toml"), element_toml) {
eprintln!("error: cannot write element.toml for '{rel}': {e}");
return ExitCode::FAILURE;
}
let required = cph_schema::schema_for(kind)
.map(|s| s.required_content_field_names())
.unwrap_or_default();
for field in &required {
let f = part_dir.join(format!("{field}.typ"));
if let Err(e) = std::fs::write(&f, "") {
eprintln!("error: cannot write '{}': {e}", f.display());
return ExitCode::FAILURE;
}
}
let updated = insert_child(&manifest_src, kind, &rel);
if let Err(e) = std::fs::write(&manifest_path, updated) {
eprintln!("error: cannot update '{}': {e}", manifest_path.display());
return ExitCode::FAILURE;
}
println!("added {kind} '{trimmed}' → {rel} (folder + [[children]] entry)");
if required.is_empty() {
println!(" note: kind '{kind}' declares no required content fields");
} else {
println!(" content files created: {}", required.join(", "));
}
ExitCode::SUCCESS
}
/// Whether `manifest` already declares a child whose `path` line equals `rel`.
fn manifest_has_child(manifest: &str, rel: &str) -> bool {
let needle = format!("path = \"{rel}\"");
manifest.lines().any(|l| l.trim() == needle)
}
/// Insert a new `[[children]]` block into `manifest`, keeping the array of
/// tables contiguous (a TOML requirement: all elements of `[[children]]` must be
/// adjacent). The block goes immediately before the first section header that is
/// neither `[project]`/`[info]` nor an existing `[[children]]` entry (i.e. before
/// `[targets.*]`), or at end-of-file if none — either way it lands at the tail
/// of the root-children run, after `[info]` and any existing children (ADR-0029).
/// Comment blocks are preserved.
fn insert_child(manifest: &str, kind: &str, rel: &str) -> String {
let block = format!("[[children]]\nkind = \"{kind}\"\npath = \"{rel}\"\n");
let lines: Vec<&str> = manifest.lines().collect();
let insert_at = lines
.iter()
.position(|l| {
let t = l.trim_start();
t.starts_with('[')
&& !t.starts_with("[[children]]")
&& t != "[project]"
&& t != "[info]"
})
.unwrap_or(lines.len());
let mut out = String::new();
for (i, line) in lines.iter().enumerate() {
if i == insert_at {
out.push_str(&block);
}
out.push_str(line);
out.push('\n');
}
if insert_at == lines.len() {
out.push_str(&block);
}
out
}
/// Print every diagnostic in `report` to stderr, followed by a summary line.
fn print_diagnostics(report: &CheckReport) {
for d in &report.diagnostics {