diff --git a/Cargo.lock b/Cargo.lock index 11e5355..4300e27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -411,6 +411,7 @@ dependencies = [ "cph-check", "cph-diag", "cph-model", + "cph-schema", "cph-typst", "serde_json", ] diff --git a/README.md b/README.md index 9192400..1c1fea8 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ cargo install --path crates/cph-cli --locked ```sh cph --version # cph 0.0.2 +cph init <工程目录> # 脚手架:manifest.toml + .cph-version + 默认 exports/student.typ + 空 kind 目录 +cph add --root <工程目录> <名称> # 新增 part(segment/example/lemma/sop):建目录+空白内容文件+追加 [[children]] cph check <工程目录> # 校验合法性(7 类诊断) cph build <工程目录> --target student -o build/student.pdf # 渲讲义 PDF ``` @@ -29,6 +31,12 @@ cph outline <工程目录> --format pdf --force # 明确允许覆盖已有 o 大纲节点来自根及各级容器 `manifest.toml` 的 `[[children]]`;可在 child 上填写多行 `notes = """…"""` 作为教师备课提示。它会进入 outline 的 JSON/Markdown, 并在 PDF 中以独立的“教学提示”区域呈现,不会混入学生/教师讲义正文。 +`init` / `add` 是纯本地的创作脚手架(与 ADR-0013 的 `completions` 同类,不涉及 +hub 语义):`init` 产出一个 `cph check` 可过的工程根;`add` 按 kind 建 +`<子目录>/<名称>/` + `element.toml` + 必填内容字段(`segment→textbook.typ`、 +`example→problem/solution.typ`、`lemma→stmt.typ`、`sop→sop.typ`),并把配套 +`[[children]]` 追加进根 `manifest.toml`(保持数组连续,不破坏注释;ADR-0029)。缺省 +`--root` 为当前目录。 **版本契约(ADR-0016):** 教研工程文件根放一个 `.cph-version` 文件,内容为它面向的 cph 版本(如 `0.0.2`)。`cph` 加载时比对自身版本,不相容则报 `E-CPH-VERSION` error 并拒绝(当前判定为版本完全相等;后续可放宽为 semver 区间,只改一处谓词)。`examples/` 与本仓 fixture 已带该文件作为迁移起点;缺文件的工程暂时跳过此检查(OPEN)。 diff --git a/crates/cph-cli/Cargo.toml b/crates/cph-cli/Cargo.toml index 2466ff1..a887e56 100644 --- a/crates/cph-cli/Cargo.toml +++ b/crates/cph-cli/Cargo.toml @@ -12,6 +12,7 @@ path = "src/main.rs" cph-check = { path = "../cph-check" } cph-diag = { workspace = true } cph-model = { workspace = true } +cph-schema = { path = "../cph-schema" } cph-typst = { path = "../cph-typst" } clap = { version = "4", features = ["derive"] } clap_complete = "4" diff --git a/crates/cph-cli/src/main.rs b/crates/cph-cli/src/main.rs index f4f93a0..8133962 100644 --- a/crates/cph-cli/src/main.rs +++ b/crates/cph-cli/src/main.rs @@ -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, + }, + /// 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) -> 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 --input manifest= exports/student.typ +// +// 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 `//.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 /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: /.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 /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--` 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 { diff --git a/crates/cph-schema/src/lib.rs b/crates/cph-schema/src/lib.rs index 5dbe819..82fe919 100644 --- a/crates/cph-schema/src/lib.rs +++ b/crates/cph-schema/src/lib.rs @@ -155,6 +155,19 @@ impl KindSchema { .collect() } + /// The names of the **required** content fields (those in the schema's + /// `required` list), in schema order. The `cph-cli add` authoring surface + /// uses this to scaffold the sibling `.typ` files a new part must + /// have (ADR-0008): an optional content field (e.g. a lemma's `proof`) is + /// not created, so a freshly added part stays schema-legal. + pub fn required_content_field_names(&self) -> Vec<&str> { + self.content_fields + .iter() + .filter(|f| f.required) + .map(|f| f.name.as_str()) + .collect() + } + /// The names of the scalar fields (those living in `element.toml`), in /// schema order. pub fn scalar_field_names(&self) -> Vec<&str> { diff --git a/crates/cph-schema/tests/validate.rs b/crates/cph-schema/tests/validate.rs index f742d2e..20c2164 100644 --- a/crates/cph-schema/tests/validate.rs +++ b/crates/cph-schema/tests/validate.rs @@ -74,3 +74,25 @@ fn example_source_non_string_is_schema_violation() { assert!(diags[0].message.contains("source")); assert!(diags[0].message.contains("string")); } + +/// `required_content_field_names` is the `cph-cli add` authoring contract: the +/// set of sibling `.typ` files a new part of a kind must have to be +/// schema-legal (ADR-0008). It must be the schema `required` content fields — +/// not the optional ones (e.g. a lemma's `proof`) and not the scalar fields. +#[test] +fn required_content_field_names_match_schema_required() { + let case = |kind: &str, expected: Vec<&str>| { + let mut got: Vec<&str> = cph_schema::schema_for(kind) + .unwrap() + .required_content_field_names(); + got.sort_unstable(); + let mut want = expected; + want.sort_unstable(); + assert_eq!(got, want, "required content fields for '{kind}'"); + }; + + case("segment", vec!["textbook"]); + case("lemma", vec!["stmt"]); + case("example", vec!["problem", "solution"]); + case("sop", vec!["sop"]); +}