#![warn(missing_docs)]
use std::io::Write;
use std::path::{Path, PathBuf};
#[path = "commands/build.rs"]
mod build_command;
#[path = "commands/check.rs"]
mod check_command;
#[path = "explain.rs"]
mod explain;
#[path = "grafts.rs"]
mod grafts;
#[path = "commands/new.rs"]
mod new_command;
#[path = "commands/snippets.rs"]
mod snippets_command;
const USAGE: &str = "\
nichlink — NichLink command-line interface
USAGE:
nichlink new <name> [--lib] [--path <workspace> | --git <url>]
nichlink check [path] [--json]
nichlink build [path] [cargo options]
nichlink snippets [path] [--editor vscode|nvim|blink|auto] [--stdout]
nichlink explain <node-id|logical/path> [--path <dir>] [--json]
nichlink explain --overlay [--path <dir>] [--json]
nichlink grafts [path] [--json]
nichlink studio
nichlink mcp
COMMANDS:
new Create a NichLink host project in ./<name>
check Run the registration discovery and validation pass without compiling
build Validate the registration tree, then run cargo build
snippets Inject the face-field editor snippets (VS Code project file, or
the LuaSnip file Neovim loads)
explain Resolve a node id or logical path and report its identity, build
scope, pruning, and the declared graft cuts that name it; with
--overlay, render the static overlay projection of every slot
grafts List every .nichlink/external-grafts/*/graft.plan, with its
selector, target path, graft, full flag, and whether the host
entry declares that slot (read-only)
studio Launch the Studio TUI for the current project
mcp Run the read-only MCP stdio bridge
OPTIONS:
--lib Create a library project instead of a binary
--path <dir> Source nichlink-core/build from a local checkout; for
explain, the host project to inspect (default: .)
--git <url> Source nichlink-core/build from a Git repository
--json Emit one JSON document on stdout instead of human text
(check, explain, grafts); check still exits non-zero on a
failed validation
--overlay With explain, render the static overlay projection of the
build's scope and declared cuts instead of one node
--editor <name> Editor to write snippets for: vscode (default), nvim
(LuaSnip), blink (blink.cmp) or auto (every editor
installed on this machine, in its user-level location;
fuzzy-matching engines need to be named explicitly)
--stdout Print the snippets instead of writing them (any editor)
";
pub fn main() -> Result<(), String> {
run(std::env::args())
}
pub fn run(argv: impl IntoIterator<Item = String>) -> Result<(), String> {
run_to(argv, &mut std::io::stdout())
}
pub fn run_to(argv: impl IntoIterator<Item = String>, out: &mut dyn Write) -> Result<(), String> {
let mut args = argv.into_iter().skip(1);
match args.next().as_deref() {
None | Some("--help") | Some("-h") | Some("help") => {
write!(out, "{USAGE}").map_err(|error| format!("cannot write usage: {error}"))?;
Ok(())
}
Some("new") => new_command::new(&mut args),
Some("check") => check_command::check(&mut args, out),
Some("build") => build_command::build(&mut args),
Some("snippets") => snippets_command::snippets(&mut args),
Some("explain") => explain::explain(&mut args, out),
Some("grafts") => grafts::grafts(&mut args, out),
Some("studio") => nichlink_studio::launch().map_err(|error| error.to_string()),
Some("mcp") => nichlink_mcp::run().map_err(|error| format!("mcp: {error}")),
Some(other) => Err(format!("unknown command '{other}' (see --help)")),
}
}
fn split_build_args(args: &[String]) -> (Option<String>, Vec<String>) {
match args.first() {
Some(first) if !first.starts_with('-') => (Some(first.clone()), args[1..].to_vec()),
_ => (None, args.to_vec()),
}
}
pub(crate) fn resolve_package(directory: &str) -> Result<(PathBuf, String), String> {
let manifest = std::fs::canonicalize(directory)
.map_err(|error| format!("cannot resolve {directory}: {error}"))?;
if !manifest.join("Cargo.toml").is_file() {
return Err(format!("{} has no Cargo.toml", manifest.display()));
}
let package = package_name(&manifest)?;
Ok((manifest, package))
}
pub(crate) fn build_out_dir(manifest: &Path) -> PathBuf {
manifest.join("target/nichlink/out")
}
fn registration_check(directory: &str) -> Result<String, String> {
let (manifest, package) = resolve_package(directory)?;
let out_dir = build_out_dir(&manifest);
nichlink_build_method::run_for(&manifest, &out_dir, &package)?;
Ok(package)
}
fn package_name(manifest: &Path) -> Result<String, String> {
let output = std::process::Command::new("cargo")
.args(["metadata", "--format-version", "1", "--no-deps"])
.arg("--manifest-path")
.arg(manifest.join("Cargo.toml"))
.output()
.map_err(|error| format!("cannot run cargo metadata: {error}"))?;
if !output.status.success() {
return Err(format!(
"cargo metadata failed for {}: {}",
manifest.display(),
String::from_utf8_lossy(&output.stderr).trim()
));
}
let metadata: serde_json::Value = serde_json::from_slice(&output.stdout)
.map_err(|error| format!("cannot read cargo metadata output: {error}"))?;
let packages = metadata["packages"]
.as_array()
.ok_or_else(|| "cargo metadata reported no packages".to_owned())?;
let package = packages
.iter()
.find(|package| {
package["manifest_path"].as_str().is_some_and(|path| {
Path::new(path)
.parent()
.is_some_and(|parent| same_directory(parent, manifest))
})
})
.ok_or_else(|| {
format!(
"{} is not a package; cargo metadata listed {} workspace member(s)",
manifest.display(),
packages.len()
)
})?;
package["name"].as_str().map(str::to_owned).ok_or_else(|| {
format!(
"cargo metadata reported no package name for {}",
manifest.display()
)
})
}
fn same_directory(left: &Path, right: &Path) -> bool {
let canonical =
|path: &Path| std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
canonical(left) == canonical(right)
}
#[cfg(test)]
#[path = "lib_tests.rs"]
mod tests;