forked from jess/Acord
1
0
Fork 0
Acord/viewport/src/export.rs

384 lines
12 KiB
Rust

//! Export a note as a standalone Rust crate. The crate mirrors the sidecar
//! ZIP's structure (src/blocks/*.cord + config.toml) but is written to a
//! user-chosen folder on disk with the full Cargo scaffolding (Cargo.toml,
//! build.sh, install.sh, README.md, src/main.rs, src/lib.rs).
//!
//! The main module (src/main.rs) runs a REPL using acord-core's interpreter.
//! Each `.cord` block is a submodule loaded into the REPL's scope at startup.
//! AOT codegen (Cordial → Rust source) is planned separately — build.sh is a
//! stub that currently just does `cargo build --release` of the REPL binary.
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::editor::EditorState;
use crate::heading_block::HeadingBlock;
use crate::text_block::TextBlock;
/// Convert a free-form string to hyphen-form for use as a crate/folder name.
/// Lowercase, spaces and underscores become `-`, non-alphanumeric stripped.
pub fn to_hyphen_name(s: &str) -> String {
s.trim()
.to_lowercase()
.chars()
.map(|c| if c == ' ' || c == '_' { '-' } else { c })
.filter(|c| c.is_alphanumeric() || *c == '-')
.collect::<String>()
.trim_matches('-')
.to_string()
}
/// Export the current note as a standalone Rust crate at `out_dir`. The
/// folder name is the crate name; spaces/underscores in the user-supplied
/// name get converted to hyphens. Returns Ok(path) on success.
pub fn export_crate(state: &EditorState, out_dir: &Path, name: &str) -> Result<PathBuf, String> {
let crate_name = to_hyphen_name(name);
if crate_name.is_empty() {
return Err("crate name is empty after normalization".into());
}
let crate_dir = out_dir.join(&crate_name);
let src_dir = crate_dir.join("src");
let blocks_dir = src_dir.join("blocks");
fs::create_dir_all(&blocks_dir)
.map_err(|e| format!("create {}: {}", blocks_dir.display(), e))?;
// Write per-block .cord files (reuses the same format as the sidecar)
let block_files = state.build_block_files();
for file in &block_files {
let path = blocks_dir.join(&file.filename);
write_file(&path, &file.content)?;
}
// Write the three scaffolding files: Cargo.toml, main.rs, lib.rs
write_file(&crate_dir.join("Cargo.toml"), &cargo_toml(&crate_name))?;
write_file(&src_dir.join("main.rs"), &main_rs(&crate_name, &block_files))?;
write_file(&src_dir.join("lib.rs"), &lib_rs(&block_files))?;
// Scripts + README + gitignore
let build_path = crate_dir.join("build.sh");
write_file(&build_path, &build_sh(&crate_name))?;
make_executable(&build_path)?;
let install_path = crate_dir.join("install.sh");
write_file(&install_path, &install_sh(&crate_name))?;
make_executable(&install_path)?;
write_file(&crate_dir.join("README.md"), &readme_md(state, &crate_name))?;
write_file(&crate_dir.join(".gitignore"), "target/\nCargo.lock\n")?;
Ok(crate_dir)
}
fn write_file(path: &Path, content: &str) -> Result<(), String> {
let mut f = fs::File::create(path)
.map_err(|e| format!("create {}: {}", path.display(), e))?;
f.write_all(content.as_bytes())
.map_err(|e| format!("write {}: {}", path.display(), e))?;
Ok(())
}
#[cfg(unix)]
fn make_executable(path: &Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)
.map_err(|e| format!("metadata {}: {}", path.display(), e))?
.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms)
.map_err(|e| format!("chmod {}: {}", path.display(), e))
}
#[cfg(not(unix))]
fn make_executable(_path: &Path) -> Result<(), String> { Ok(()) }
fn cargo_toml(name: &str) -> String {
format!(
r#"[package]
name = "{name}"
version = "0.1.0"
edition = "2024"
[lib]
path = "src/lib.rs"
[[bin]]
name = "{name}"
path = "src/main.rs"
[dependencies]
acord-core = {{ git = "https://git.else-if.org/jess/Acord.git", package = "acord-core" }}
"#,
)
}
fn main_rs(name: &str, block_files: &[crate::sidecar::BlockFile]) -> String {
let mut includes = String::new();
let mut init_lines = String::new();
for file in block_files {
let var = ident_from_filename(&file.filename);
includes.push_str(&format!(
"const {}: &str = include_str!(\"blocks/{}\");\n",
var.to_uppercase(),
file.filename
));
init_lines.push_str(&format!(
" load_block(&mut interp, {});\n",
var.to_uppercase()
));
}
format!(
r#"//! {name} — exported Acord note running as a REPL.
//!
//! `cargo run` drops you into an interactive Cordial prompt with every block
//! from the note pre-loaded. It's the notepad experience, minus the UI.
//!
//! Type expressions, call functions, reference tables. `:list` to inspect,
//! `:q` to quit.
use acord_core::interp::Interpreter;
use std::io::{{self, BufRead, Write}};
{includes}
fn main() {{
let mut interp = Interpreter::new();
{init_lines}
println!("{{}} REPL — :list to show bindings, :q to quit", env!("CARGO_PKG_NAME"));
let stdin = io::stdin();
let mut out = io::stdout().lock();
let mut line = String::new();
loop {{
write!(out, "> ").ok();
out.flush().ok();
line.clear();
if stdin.lock().read_line(&mut line).unwrap_or(0) == 0 {{ break; }}
let trimmed = line.trim();
if trimmed.is_empty() {{ continue; }}
if trimmed == ":q" || trimmed == ":quit" {{ break; }}
if trimmed == ":list" {{
list_bindings(&interp);
continue;
}}
match interp.exec_line(trimmed) {{
Ok(Some(v)) => println!("{{}}", v.display()),
Ok(None) => {{}}
Err(e) => eprintln!("error: {{}}", e),
}}
}}
}}
/// Strip the `.cord` front-matter (everything up to and including the first
/// standalone `---`) and evaluate the remaining source into `interp`.
/// Lines that fail to parse are silently skipped — same as in the notepad.
fn load_block(interp: &mut Interpreter, source: &str) {{
let body = strip_front_matter(source);
for line in body.lines() {{
let _ = interp.exec_line(line);
}}
}}
fn strip_front_matter(src: &str) -> &str {{
if !src.starts_with("---") {{ return src; }}
let mut lines = src.split_inclusive('\n');
// skip opening ---
lines.next();
// skip through closing ---
let mut consumed = 0;
for line in &mut lines {{
consumed += line.len();
if line.trim_end_matches('\n').trim() == "---" {{ break; }}
}}
&src[3 + consumed..]
}}
fn list_bindings(_interp: &Interpreter) {{
// TODO: when acord-core exposes a public bindings iterator, enumerate here.
// For now, users discover bindings by referencing them.
println!("(binding enumeration not yet implemented)");
}}
"#,
)
}
fn lib_rs(block_files: &[crate::sidecar::BlockFile]) -> String {
let mut includes = String::new();
let mut init_lines = String::new();
for file in block_files {
let var = ident_from_filename(&file.filename);
includes.push_str(&format!(
"const {}: &str = include_str!(\"blocks/{}\");\n",
var.to_uppercase(),
file.filename
));
init_lines.push_str(&format!(
" load_block(&mut interp, {});\n",
var.to_uppercase()
));
}
format!(
r#"//! Exposes this note's loaded interpreter for use from other Rust projects.
//!
//! Example:
//! ```no_run
//! let mut interp = my_note::load();
//! let v = interp.exec_line("my_fn(1, 2, 3)").unwrap();
//! ```
use acord_core::interp::Interpreter;
{includes}
pub fn load() -> Interpreter {{
let mut interp = Interpreter::new();
{init_lines}
interp
}}
fn load_block(interp: &mut Interpreter, source: &str) {{
let body = strip_front_matter(source);
for line in body.lines() {{
let _ = interp.exec_line(line);
}}
}}
fn strip_front_matter(src: &str) -> &str {{
if !src.starts_with("---") {{ return src; }}
let mut lines = src.split_inclusive('\n');
lines.next();
let mut consumed = 0;
for line in &mut lines {{
consumed += line.len();
if line.trim_end_matches('\n').trim() == "---" {{ break; }}
}}
&src[3 + consumed..]
}}
"#,
)
}
fn build_sh(_name: &str) -> String {
r#"#!/usr/bin/env bash
set -e
# TODO: AOT codegen — compile .cord sources to Rust source, produce a static
# binary with zero interpreter overhead. See the cordial-to-rust-codegen plan
# for the design. Until then, this builds the REPL binary in release mode.
cargo build --release
echo "Built target/release/$(basename "$PWD")"
"#
.into()
}
fn install_sh(name: &str) -> String {
format!(
r#"#!/usr/bin/env bash
set -e
NAME="{name}"
DEST="${{HOME}}/.acord/bin"
if [ ! -f "target/release/${{NAME}}" ]; then
echo "No release binary found. Running ./build.sh first..."
./build.sh
fi
mkdir -p "$DEST"
cp "target/release/${{NAME}}" "$DEST/${{NAME}}"
chmod +x "$DEST/${{NAME}}"
echo "Installed: ${{DEST}}/${{NAME}}"
echo ""
echo "Add ~/.acord/bin to your PATH if you haven't already:"
echo ""
echo " # zsh:"
echo " echo 'export PATH=\"\$HOME/.acord/bin:\$PATH\"' >> ~/.zshrc && source ~/.zshrc"
echo ""
echo " # bash:"
echo " echo 'export PATH=\"\$HOME/.acord/bin:\$PATH\"' >> ~/.bashrc && source ~/.bashrc"
"#,
)
}
fn readme_md(state: &EditorState, name: &str) -> String {
let mut inventory = String::new();
for block_id in state.layout.iter() {
let Some(block) = state.registry.get(block_id) else { continue };
let kind = block.kind_tag();
if let Some(hb) = block.as_any().downcast_ref::<HeadingBlock>() {
inventory.push_str(&format!(
"- **{kind}** (level {}) — `{}`\n",
hb.level as u8 + 1,
hb.text.trim()
));
} else if let Some(tb) = block.as_any().downcast_ref::<TextBlock>() {
let first_line = tb.content.text();
let preview = first_line.lines().next().unwrap_or("").trim();
if !preview.is_empty() {
inventory.push_str(&format!("- **{kind}** — {}\n", truncate(preview, 60)));
} else {
inventory.push_str(&format!("- **{kind}**\n"));
}
} else {
inventory.push_str(&format!("- **{kind}**\n"));
}
}
format!(
r#"# {name}
This is your Acord note, exported as a standalone Rust crate.
## Run
- `cargo run` — interactive Cordial REPL with every binding from your note pre-loaded. Call functions, reference tables, mutate variables — exactly like opening a new block below the existing ones in the notepad.
- `./build.sh` — build the release binary.
- `./install.sh` — install the binary to `~/.acord/bin` and print PATH setup instructions.
## Blocks
{inventory}
## Use from another Rust project
Add this crate to your `Cargo.toml` as a path dependency, then:
```rust
use {name}::load;
let mut interp = load();
let v = interp.exec_line("my_fn(1, 2, 3)").unwrap();
println!("{{}}", v.unwrap().display());
```
## Notes
- Future versions will AOT-compile your `.cord` sources into native Rust via `./build.sh`, producing binaries with zero interpreter overhead. Today the binary uses the interpreter at runtime.
- This crate depends on `acord-core` via git. Make sure the host has network access on first build, or switch to a path/vendored dep for offline environments.
"#,
)
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max { s.to_string() } else {
let mut out: String = s.chars().take(max).collect();
out.push_str("...");
out
}
}
fn ident_from_filename(filename: &str) -> String {
let stem = filename.trim_end_matches(".cord");
stem.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect::<String>()
.trim_start_matches(|c: char| !c.is_alphabetic() && c != '_')
.to_string()
}
#[allow(dead_code)]
fn derive_name_from_first_line(text: &str) -> String {
let first = text
.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("");
let cleaned = first.trim_start_matches('#').trim();
let first_two: Vec<&str> = cleaned.split_whitespace().take(2).collect();
to_hyphen_name(&first_two.join(" "))
}