use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Component, Path, PathBuf};
include!(concat!(env!("OUT_DIR"), "/templates.rs"));
pub(crate) fn resolve_target(target: &Path) -> Result<PathBuf, String> {
let escapes = |reason: &str| {
format!(
"error[SCAFFOLD_TARGET_ESCAPES]: `noxid new {}` {reason}; a scaffold writes a whole \
project tree, so its target must be a directory inside the one you are in. Write a \
plain name like `noxid new my-app`, or `cd` to the parent directory first.",
target.display()
)
};
if target
.components()
.any(|component| matches!(component, Component::ParentDir))
{
return Err(escapes("names a target through `..`"));
}
let working = std::env::current_dir()
.map_err(|error| format!("cannot resolve the current directory: {error}"))?;
let canonical_working = fs::canonicalize(&working)
.map_err(|error| format!("cannot resolve {}: {error}", working.display()))?;
let joined = if target.is_absolute() {
target.to_path_buf()
} else {
working.join(target)
};
let mut absolute = PathBuf::new();
for component in joined.components() {
if !matches!(component, Component::CurDir) {
absolute.push(component);
}
}
let resolved = canonical_with_missing_tail(&absolute)?;
if !resolved.starts_with(&canonical_working) {
return Err(escapes(&format!(
"resolves to {}, which is outside {}",
resolved.display(),
canonical_working.display()
)));
}
Ok(absolute)
}
fn canonical_with_missing_tail(path: &Path) -> Result<PathBuf, String> {
let mut tail = Vec::new();
let mut existing = path.to_path_buf();
loop {
if existing.exists() {
let mut resolved = fs::canonicalize(&existing)
.map_err(|error| format!("cannot resolve {}: {error}", existing.display()))?;
for component in tail.iter().rev() {
resolved.push(component);
}
return Ok(resolved);
}
let Some(name) = existing.file_name().map(|name| name.to_os_string()) else {
return Ok(path.to_path_buf());
};
tail.push(name);
let Some(parent) = existing.parent().map(Path::to_path_buf) else {
return Ok(path.to_path_buf());
};
existing = parent;
}
}
pub(crate) const TEMPLATE_NAMES: [&str; 2] = ["app", "counter"];
const IGNORED_EXISTING_ENTRIES: [&str; 2] = [".git", "node_modules"];
fn template_directory(template: &str, render: Option<&str>) -> Result<&'static str, String> {
match (template, render) {
("app", None) => Ok("app"),
("app", Some(_)) => Err(
"error[SCAFFOLD_RENDER_NOT_APPLICABLE]: `--render` selects the counter template's \
rendering and cannot be combined with `--template app`, which is already a \
server-rendered full-stack project; drop `--render`, or use `--template counter \
--render universal`"
.into(),
),
("counter", None | Some("client")) => Ok("counter"),
("counter", Some("universal")) => Ok("counter-universal"),
("counter", Some(other)) => Err(format!(
"error[SCAFFOLD_RENDER_UNKNOWN]: `--render {other}` is not a rendering mode; write \
`--render client` for a browser-only project or `--render universal` for a \
server-rendered one"
)),
(other, _) => Err(format!(
"error[SCAFFOLD_TEMPLATE_UNKNOWN]: `--template {other}` is not a template; write \
`--template app` for the full-stack starting shape (a typed form, one typed \
endpoint, scoped tables, a migration, a sign-in door and session middleware that \
refuses every unauthenticated request, three requirements with passing scenarios, \
and a live resource) or `--template counter` for the minimal single-page project"
)),
}
}
pub(crate) fn output_label<'a>(template: &'a str, render: Option<&'a str>) -> &'a str {
match (template, render) {
("counter", None) => "client",
("counter", Some(render)) => render,
(other, _) => other,
}
}
fn source(directory: &str) -> Result<&'static TemplateSource, String> {
TEMPLATE_SOURCES
.iter()
.find(|candidate| candidate.directory == directory)
.ok_or_else(|| {
format!(
"error[SCAFFOLD_TEMPLATE_MISSING]: this build embeds no template \
`examples/templates/{directory}`; rebuild the compiler from a checkout that \
contains it"
)
})
}
const APP_ID_MAX_CHARS: usize = 32;
fn project_name(root: &Path, separator: char) -> String {
let raw = root
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
let mut name = String::new();
for character in raw.chars() {
if character.is_ascii_alphanumeric() {
name.push(character.to_ascii_lowercase());
} else if !name.ends_with(separator) && !name.is_empty() {
name.push(separator);
}
}
let name = name.trim_matches(separator).to_string();
let name = if name.is_empty() || name.starts_with(|character: char| character.is_ascii_digit())
{
format!("noxid{separator}{name}")
.trim_matches(separator)
.to_string()
} else {
name
};
let truncated = name
.char_indices()
.nth(APP_ID_MAX_CHARS)
.map_or(name.as_str(), |(index, _)| &name[..index]);
truncated.trim_end_matches(separator).to_string()
}
fn rename(relative: &str, contents: &str, root: &Path) -> String {
match relative {
"Noxid.toml" => replace_line_value(contents, "id = \"", &project_name(root, '_'), "\""),
"package.json" => {
replace_line_value(contents, "\"name\": \"", &project_name(root, '-'), "\"")
}
_ => contents.to_string(),
}
}
fn replace_line_value(contents: &str, prefix: &str, value: &str, suffix: &str) -> String {
let mut lines = Vec::new();
let mut replaced = false;
for line in contents.lines() {
let trimmed = line.trim_start();
if !replaced
&& trimmed.starts_with(prefix)
&& let Some(rest) = trimmed[prefix.len()..].find(suffix)
{
let indent = &line[..line.len() - trimmed.len()];
let tail = &trimmed[prefix.len() + rest + suffix.len()..];
lines.push(format!("{indent}{prefix}{value}{suffix}{tail}"));
replaced = true;
continue;
}
lines.push(line.to_string());
}
let mut out = lines.join("\n");
if contents.ends_with('\n') {
out.push('\n');
}
out
}
fn refuse_non_empty(root: &Path) -> Result<(), String> {
if !root.exists() {
return Ok(());
}
let entries = root
.read_dir()
.map_err(|error| format!("cannot read {}: {error}", root.display()))?;
let mut existing = entries
.filter_map(Result::ok)
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| !IGNORED_EXISTING_ENTRIES.contains(&name.as_str()))
.collect::<Vec<_>>();
if existing.is_empty() {
return Ok(());
}
existing.sort();
existing.truncate(5);
Err(format!(
"error[SCAFFOLD_TARGET_NOT_EMPTY]: {} already contains {}; `noxid new` only writes into \
an empty directory and never overwrites a file. Scaffold into a new directory instead, \
or empty this one first.",
root.display(),
existing.join(", ")
))
}
const DRIZZLE_ADAPTER_SPECIFIER: &str = "plugins/drizzle-orm/adapter.js";
pub(crate) const PLUGIN_LEDGER: &str = ".noxid-plugins.json";
fn declares_drizzle_adapter(source: &TemplateSource) -> bool {
source
.files
.iter()
.any(|file| file.contents.contains(DRIZZLE_ADAPTER_SPECIFIER))
}
fn vetted_pin(contents: &str) -> Option<(String, String)> {
let mut version = None;
let mut integrity = None;
for line in contents.lines() {
let line = line.trim();
if version.is_none()
&& let Some(value) = line.strip_prefix("version:")
{
version = Some(value.trim().to_string());
} else if integrity.is_none()
&& let Some(value) = line.strip_prefix("integrity:")
{
integrity = Some(value.trim().to_string());
}
}
Some((version?, integrity?))
}
pub(crate) struct VendoredPlugins {
pub(crate) commit: String,
pub(crate) files: Vec<(&'static str, String)>,
}
pub(crate) fn embedded_plugins() -> VendoredPlugins {
VendoredPlugins {
commit: VENDORED_PLUGIN_COMMIT.to_string(),
files: VENDORED_PLUGIN_FILES
.iter()
.map(|file| (file.path, file.contents.to_string()))
.collect(),
}
}
fn scaffolded_plugins() -> VendoredPlugins {
#[cfg(debug_assertions)]
if let Some(older) = older_cli_plugins() {
return older;
}
embedded_plugins()
}
#[cfg(debug_assertions)]
fn older_cli_plugins() -> Option<VendoredPlugins> {
let target = std::env::var("NOXID_TEST_OLDER_VENDORED_PLUGIN").ok()?;
let mut plugins = embedded_plugins();
let entry = plugins
.files
.iter_mut()
.find(|(path, _)| *path == target)
.unwrap_or_else(|| {
panic!("NOXID_TEST_OLDER_VENDORED_PLUGIN names no vendored file: {target}")
});
entry.1.push_str("// vendored by an older noxid\n");
plugins.commit = "0000000000000000000000000000000000000000".to_string();
Some(plugins)
}
pub(crate) fn plugin_ledger(plugins: &VendoredPlugins) -> Result<String, String> {
let mut files = Vec::new();
let mut pins = Vec::new();
for (path, contents) in &plugins.files {
files.push(format!(
" {{ \"path\": \"{path}\", \"sha256\": \"{}\" }}",
crate::sha256::hex_digest(contents.as_bytes())
));
let Some(package) = path
.strip_prefix("plugins/")
.and_then(|rest| rest.strip_suffix("/VETTING.md"))
else {
continue;
};
let (version, integrity) = vetted_pin(contents).ok_or_else(|| {
format!(
"error[PLUGIN_VETTING_RECORD_INCOMPLETE]: {path} has no version/integrity header; \
a scaffold cannot pin a package the record does not identify"
)
})?;
pins.push(format!(
" {{ \"package\": \"{package}\", \"version\": \"{version}\", \"integrity\": \
\"{integrity}\", \"record\": \"{path}\" }}"
));
}
Ok(format!(
"{{\n \"schemaVersion\": 1,\n \"sourceCommit\": \"{}\",\n \
\"files\": [\n{}\n ],\n \"pins\": [\n{}\n ]\n}}\n",
plugins.commit,
files.join(",\n"),
pins.join(",\n")
))
}
fn write_new(path: &Path, contents: &str) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("cannot create {}: {error}", parent.display()))?;
}
let mut handle = OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.map_err(|error| {
format!(
"error[SCAFFOLD_TARGET_NOT_EMPTY]: cannot create {} without overwriting it \
({error}); `noxid new` never overwrites a file. Scaffold into a new directory \
instead.",
path.display()
)
})?;
handle
.write_all(contents.as_bytes())
.map_err(|error| format!("cannot write {}: {error}", path.display()))
}
pub(crate) fn scaffold_project(
root: &Path,
template: &str,
render: Option<&str>,
) -> Result<(), String> {
let directory = template_directory(template, render)?;
let source = source(directory)?;
refuse_non_empty(root)?;
fs::create_dir_all(root)
.map_err(|error| format!("cannot create {}: {error}", root.display()))?;
if declares_drizzle_adapter(source) {
let plugins = scaffolded_plugins();
for (path, contents) in &plugins.files {
write_new(&root.join(path), contents)?;
}
write_new(&root.join(PLUGIN_LEDGER), &plugin_ledger(&plugins)?)?;
}
for file in source.files {
write_new(
&root.join(file.path),
&rename(file.path, file.contents, root),
)?;
}
for relative in source.directories {
let path = root.join(relative);
fs::create_dir_all(&path)
.map_err(|error| format!("cannot create {}: {error}", path.display()))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{TEMPLATE_SOURCES, project_name, rename, template_directory};
use std::path::Path;
#[test]
fn every_template_name_resolves_to_an_embedded_project() {
for directory in ["app", "counter", "counter-universal"] {
assert!(
TEMPLATE_SOURCES
.iter()
.any(|source| source.directory == directory),
"examples/templates/{directory} is not embedded"
);
}
}
#[test]
fn render_selects_only_the_counter_template() {
assert_eq!(template_directory("app", None).unwrap(), "app");
assert_eq!(template_directory("counter", None).unwrap(), "counter");
assert_eq!(
template_directory("counter", Some("universal")).unwrap(),
"counter-universal"
);
assert!(
template_directory("app", Some("universal"))
.unwrap_err()
.contains("SCAFFOLD_RENDER_NOT_APPLICABLE")
);
assert!(
template_directory("agent", None)
.unwrap_err()
.contains("SCAFFOLD_TEMPLATE_UNKNOWN")
);
}
#[test]
fn project_names_are_sanitized_from_the_directory() {
assert_eq!(project_name(Path::new("/tmp/My App!"), '_'), "my_app");
assert_eq!(project_name(Path::new("/tmp/My App!"), '-'), "my-app");
assert_eq!(project_name(Path::new("/tmp/2048"), '_'), "noxid_2048");
assert_eq!(
project_name(Path::new("/tmp/noxid-bench-fullstack-endpoint-9rUWQN"), '_'),
"noxid_bench_fullstack_endpoint_9"
);
assert_eq!(
project_name(Path::new("/tmp/noxid-bench-fullstack-endpoint-9rUWQN"), '-'),
"noxid-bench-fullstack-endpoint-9"
);
assert_eq!(
project_name(Path::new("/tmp/abcdefghij-abcdefghij-abcdefghij"), '_'),
"abcdefghij_abcdefghij_abcdefghij"
);
assert_eq!(
project_name(Path::new("/tmp/abcdefghij-abcdefghij-abcdefghi-x"), '_'),
"abcdefghij_abcdefghij_abcdefghi"
);
}
#[test]
fn renaming_touches_only_the_identity_field() {
let manifest = "[app]\nid = \"noxid_app_template\"\ntitle = \"Noxid App\"\n";
let renamed = rename("Noxid.toml", manifest, Path::new("/tmp/demo"));
assert_eq!(renamed, "[app]\nid = \"demo\"\ntitle = \"Noxid App\"\n");
let package = "{\n \"name\": \"noxid-app\",\n \"private\": true\n}\n";
assert_eq!(
rename("package.json", package, Path::new("/tmp/demo")),
"{\n \"name\": \"demo\",\n \"private\": true\n}\n"
);
let untouched = "component Home {}\n";
assert_eq!(
rename("src/routes/+page.nox", untouched, Path::new("/tmp/demo")),
untouched
);
}
}