mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-08 13:01:52 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4db733ec68 |
+70
-4
@@ -19,6 +19,11 @@ pub enum Class {
|
||||
Testkit,
|
||||
/// Binaries and build tooling: exempt from the rules.
|
||||
Exempt,
|
||||
/// Crates other front ends use too, kept extractable while they live here: they may depend
|
||||
/// on other shared crates and on external crates (UI toolkits included) only. solidcore and
|
||||
/// solidcore-record are git dependencies, not workspace members, so they count as external.
|
||||
/// Any crate may depend on a shared crate; `cargo xtask shared` builds each on its own.
|
||||
Shared,
|
||||
}
|
||||
|
||||
impl Class {
|
||||
@@ -27,7 +32,8 @@ impl Class {
|
||||
Class::Layer(l) => Some(l),
|
||||
Class::Standalone => Some(0),
|
||||
Class::Testkit => Some(6),
|
||||
Class::Exempt => None,
|
||||
// Shared crates sit outside the layer order; `check` handles them before it asks.
|
||||
Class::Exempt | Class::Shared => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,6 +66,8 @@ pub const TABLE: &[(&str, Class)] = &[
|
||||
("gpu", Class::Layer(5)),
|
||||
("mcp", Class::Layer(5)),
|
||||
("testkit", Class::Testkit),
|
||||
// the widget handle and rect registry UI components publish into and scenarios/the control channel read (#12936)
|
||||
("ui-registry", Class::Shared),
|
||||
// apps and tooling
|
||||
("solidcraft", Class::Exempt),
|
||||
("cli", Class::Exempt),
|
||||
@@ -131,6 +139,7 @@ pub enum Violation {
|
||||
StandaloneHasWorkspaceDep { krate: String, dep: String },
|
||||
TestkitAsNormalDep { krate: String },
|
||||
UiBelowL5 { krate: String, dep: String, layer: u8 },
|
||||
SharedDependsOnApp { krate: String, dep: String },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Violation {
|
||||
@@ -151,12 +160,20 @@ impl std::fmt::Display for Violation {
|
||||
Violation::UiBelowL5 { krate, dep, layer } => {
|
||||
write!(f, "{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L5+")
|
||||
}
|
||||
Violation::SharedDependsOnApp { krate, dep } => {
|
||||
write!(f, "{krate}: shared crate must depend only on other shared crates, solidcore and external crates, not workspace crate {dep}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check all rules. Returns violations sorted for stable output.
|
||||
pub fn check(crates: &[Crate]) -> Vec<Violation> {
|
||||
check_with(crates, classify)
|
||||
}
|
||||
|
||||
/// `check` against any classification (the tests use one with crates TABLE does not have).
|
||||
fn check_with(crates: &[Crate], classify: impl Fn(&str) -> Option<Class>) -> Vec<Violation> {
|
||||
let mut out = Vec::new();
|
||||
for c in crates {
|
||||
let Some(class) = classify(&c.name) else {
|
||||
@@ -177,9 +194,19 @@ pub fn check(crates: &[Crate]) -> Vec<Violation> {
|
||||
out.push(Violation::StandaloneHasWorkspaceDep { krate: c.name.clone(), dep: d.name.clone() });
|
||||
continue;
|
||||
}
|
||||
if class == Class::Shared {
|
||||
// Every kind counts, dev included: its tests must build after extraction,
|
||||
// so not even testkit.
|
||||
match classify(&d.name) {
|
||||
// Unregistered deps are reported on their own entry.
|
||||
None | Some(Class::Shared) => {}
|
||||
Some(_) => out.push(Violation::SharedDependsOnApp { krate: c.name.clone(), dep: d.name.clone() }),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match classify(&d.name) {
|
||||
// Unregistered deps are reported on their own entry.
|
||||
None => {}
|
||||
// Unregistered deps are reported on their own entry; any crate may use a shared one.
|
||||
None | Some(Class::Shared) => {}
|
||||
Some(Class::Testkit) if d.kind != DepKind::Dev => {
|
||||
out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() });
|
||||
}
|
||||
@@ -197,7 +224,7 @@ pub fn check(crates: &[Crate]) -> Vec<Violation> {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if layer < UI_MIN_LAYER && is_ui_crate(&d.name) {
|
||||
} else if class != Class::Shared && layer < UI_MIN_LAYER && is_ui_crate(&d.name) {
|
||||
out.push(Violation::UiBelowL5 { krate: c.name.clone(), dep: d.name.clone(), layer });
|
||||
}
|
||||
}
|
||||
@@ -237,6 +264,7 @@ pub fn describe(class: Option<Class>) -> String {
|
||||
Some(Class::Standalone) => "L0 standalone".into(),
|
||||
Some(Class::Testkit) => "testkit".into(),
|
||||
Some(Class::Exempt) => "exempt".into(),
|
||||
Some(Class::Shared) => "shared".into(),
|
||||
None => "UNREGISTERED".into(),
|
||||
}
|
||||
}
|
||||
@@ -317,6 +345,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_crate_must_not_depend_on_layered_crates() {
|
||||
let v = check(&[c("solidcraft-ui-registry", &[("solidcraft-engine", Normal, true)])]);
|
||||
assert!(matches!(&v[..], [Violation::SharedDependsOnApp { krate, dep }] if krate == "solidcraft-ui-registry" && dep == "solidcraft-engine"));
|
||||
assert!(v[0].to_string().contains("shared crate must depend only on"));
|
||||
let v = check(&[c("solidcraft-ui-registry", &[("solidcraft-geom", Build, true)])]);
|
||||
assert!(matches!(v[..], [Violation::SharedDependsOnApp { .. }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_crate_may_use_a_shared_crate() {
|
||||
for user in ["solidcraft-geom", "solidcraft-engine", "solidcraft-ui-egui", "solidcraft-testkit"] {
|
||||
assert!(check(&[c(user, &[("solidcraft-ui-registry", Normal, true)])]).is_empty(), "{user}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_crate_may_use_shared_and_external_crates() {
|
||||
let classes = |pkg: &str| if short_name(pkg) == "ui-text" { Some(Class::Shared) } else { classify(pkg) };
|
||||
for kind in [Normal, Dev, Build] {
|
||||
let g = [c("solidcraft-ui-registry", &[("solidcraft-ui-text", kind, true), ("serde", Normal, false), ("solidcore", Normal, false)])];
|
||||
assert!(check_with(&g, classes).is_empty(), "{kind:?}: {:?}", check_with(&g, classes));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_crate_may_use_ui_crates() {
|
||||
for dep in ["egui", "eframe", "winit", "wgpu"] {
|
||||
assert!(check(&[c("solidcraft-ui-registry", &[(dep, Normal, false)])]).is_empty(), "{dep}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_crate_must_not_use_testkit_even_in_tests() {
|
||||
let v = check(&[c("solidcraft-ui-registry", &[("solidcraft-testkit", Dev, true)])]);
|
||||
assert!(matches!(v[..], [Violation::SharedDependsOnApp { .. }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_parsing() {
|
||||
let meta: Value = serde_json::from_str(
|
||||
|
||||
+15
-5
@@ -21,6 +21,7 @@ mod mesh_decisions;
|
||||
mod params;
|
||||
mod pending;
|
||||
mod render_assets;
|
||||
mod shared;
|
||||
mod skills;
|
||||
mod test;
|
||||
mod test_nextest;
|
||||
@@ -37,6 +38,10 @@ usage: cargo xtask <command>
|
||||
commands:
|
||||
ui-containers enforce the universal container for all UI hosts
|
||||
layers enforce the crate dependency layering (plan/architecture.md §3)
|
||||
shared check that each shared crate (layers' Shared class) has workspace lints, a licence,
|
||||
a README and no includes from outside its directory, then build and test it in a
|
||||
scratch workspace holding only it and the shared crates it uses
|
||||
(target/xtask-shared): it would build after extraction
|
||||
assets check that every non-code asset file is attributed in ATTRIBUTION.md
|
||||
app-icons regenerate shared app-icon exports for Linux, FreeBSD and Windows
|
||||
icons validate UI icon standards, discovery and review-tool behavior (Python 3.11+)
|
||||
@@ -129,8 +134,8 @@ commands:
|
||||
web-offline-test [--site DIR]
|
||||
check in headless Chrome that the web app works offline and updates to a new
|
||||
build (a stand-in app, or the web build in DIR such as dist/web)
|
||||
ci fmt --check, assets, icons, licences --check, lists, skills, layers, ui-containers,
|
||||
mesh-decisions, clippy -D warnings, then the tests
|
||||
ci fmt --check, assets, icons, licences --check, lists, skills, layers, shared,
|
||||
ui-containers, mesh-decisions, clippy -D warnings, then the tests
|
||||
with the wasm check alongside, then web-offline-test when Chrome is installed
|
||||
(stops at the first failing step)
|
||||
";
|
||||
@@ -140,6 +145,7 @@ fn main() -> ExitCode {
|
||||
let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("layers") => cmd_layers(),
|
||||
Some("shared") => shared::run(&root()),
|
||||
Some("ui-containers") => ui_containers::run(&root()),
|
||||
Some("assets") => assets::run(&root()),
|
||||
Some("icons") => icons::run(&root()),
|
||||
@@ -202,7 +208,7 @@ pub fn cargo() -> Command {
|
||||
c
|
||||
}
|
||||
|
||||
fn run(mut cmd: Command, what: &str) -> Result<(), String> {
|
||||
pub(crate) fn run(mut cmd: Command, what: &str) -> Result<(), String> {
|
||||
eprintln!("$ {what}");
|
||||
let status = cmd.status().map_err(|e| format!("{what}: failed to spawn: {e}"))?;
|
||||
if status.success() { Ok(()) } else { Err(format!("{what}: exited with {status}")) }
|
||||
@@ -425,8 +431,11 @@ fn cmd_wasm() -> Result<(), String> {
|
||||
/// scripts and proc macros built for clippy and the tests are reused.
|
||||
fn wasm_check() -> Result<(Command, usize), String> {
|
||||
let crates = layers::from_metadata(&metadata()?)?;
|
||||
let mut set: Vec<String> =
|
||||
crates.iter().filter(|c| matches!(layers::classify(&c.name), Some(layers::Class::Layer(_)))).map(|c| c.name.clone()).collect();
|
||||
let mut set: Vec<String> = crates
|
||||
.iter()
|
||||
.filter(|c| matches!(layers::classify(&c.name), Some(layers::Class::Layer(_) | layers::Class::Shared)))
|
||||
.map(|c| c.name.clone())
|
||||
.collect();
|
||||
set.push("solidcraft-web".into());
|
||||
let mut c = cargo();
|
||||
c.args(["check", "--target", "wasm32-unknown-unknown", "--profile", "ci"]);
|
||||
@@ -448,6 +457,7 @@ fn cmd_ci() -> Result<(), String> {
|
||||
lists::run(&root(), false)?;
|
||||
skills::run(&root())?;
|
||||
cmd_layers()?;
|
||||
shared::run(&root())?;
|
||||
ui_containers::run(&root())?;
|
||||
mesh_decisions::run(&root())?;
|
||||
let mut c = cargo();
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
//! `cargo xtask shared`: prove each shared crate (`layers::Class::Shared`) would build after
|
||||
//! extraction from this repository.
|
||||
//!
|
||||
//! For every shared crate it first checks the rules that keep a crate movable: its manifest uses
|
||||
//! the workspace lints (`[lints] workspace = true`) and states a licence, a README.md sits beside
|
||||
//! it, and no `include_str!`/`include_bytes!` reaches outside its own directory. Then it copies
|
||||
//! the crate and the shared crates it depends on (transitively) into a scratch workspace under
|
||||
//! `target/xtask-shared/<crate>/`, whose root manifest keeps this workspace's package fields,
|
||||
//! lints, profiles, patches (made absolute) and external dependencies but none of its path
|
||||
//! dependencies except the copied shared crates, and runs `cargo test --workspace` there with the
|
||||
//! repository's Cargo.lock. A dependency on any other workspace crate is then simply missing, so
|
||||
//! the build fails, which is the point.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use proc_macro2::{TokenStream, TokenTree};
|
||||
|
||||
use crate::layers::{self, Class};
|
||||
|
||||
/// Scratch workspaces, one per shared crate, under the target directory.
|
||||
const SCRATCH: &str = "target/xtask-shared";
|
||||
/// One target directory for every scratch workspace, so their builds share artefacts.
|
||||
const SCRATCH_TARGET: &str = "target/xtask-shared-target";
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
let meta = crate::metadata()?;
|
||||
let crates = layers::from_metadata(&meta)?;
|
||||
let dirs = manifest_dirs(&meta)?;
|
||||
let shared: BTreeSet<&str> = crates.iter().filter(|c| layers::classify(&c.name) == Some(Class::Shared)).map(|c| c.name.as_str()).collect();
|
||||
if shared.is_empty() {
|
||||
println!("shared: no shared crates (layers::Class::Shared) to check");
|
||||
return Ok(());
|
||||
}
|
||||
let dir_of = |name: &str| dirs.get(name).cloned().ok_or_else(|| format!("{name}: no manifest_path in cargo metadata"));
|
||||
|
||||
let mut problems = Vec::new();
|
||||
for &name in &shared {
|
||||
problems.extend(static_rules(name, &dir_of(name)?)?);
|
||||
}
|
||||
if !problems.is_empty() {
|
||||
println!("{} shared-crate rule violation(s):", problems.len());
|
||||
for p in &problems {
|
||||
println!(" - {p}");
|
||||
}
|
||||
return Err(format!("{} shared-crate rule violation(s)", problems.len()));
|
||||
}
|
||||
|
||||
let root_manifest = read_toml(&root.join("Cargo.toml"))?;
|
||||
let shared_dirs: BTreeMap<PathBuf, &str> = shared.iter().map(|&n| Ok((canonical(&dir_of(n)?)?, n))).collect::<Result<_, String>>()?;
|
||||
for &name in &shared {
|
||||
let closure = shared_closure(name, &crates);
|
||||
let scratch = root.join(SCRATCH).join(layers::short_name(name));
|
||||
if scratch.exists() {
|
||||
std::fs::remove_dir_all(&scratch).map_err(|e| format!("{}: {e}", scratch.display()))?;
|
||||
}
|
||||
std::fs::create_dir_all(&scratch).map_err(|e| format!("{}: {e}", scratch.display()))?;
|
||||
let mut members = Vec::new();
|
||||
for member in &closure {
|
||||
let dest = layers::short_name(member).to_string();
|
||||
copy_dir(&dir_of(member.as_str())?, &scratch.join(&dest))?;
|
||||
members.push(dest);
|
||||
}
|
||||
let manifest = scratch_manifest(root, &root_manifest, &members, &shared_dirs)?;
|
||||
let text = toml::to_string(&manifest).map_err(|e| format!("{name}: scratch Cargo.toml: {e}"))?;
|
||||
write(&scratch.join("Cargo.toml"), &text)?;
|
||||
let lock = root.join("Cargo.lock");
|
||||
if lock.is_file() {
|
||||
std::fs::copy(&lock, scratch.join("Cargo.lock")).map_err(|e| format!("{}: {e}", lock.display()))?;
|
||||
}
|
||||
let mut c = crate::cargo();
|
||||
c.current_dir(&scratch).env("CARGO_TARGET_DIR", root.join(SCRATCH_TARGET)).args(["test", "--workspace"]);
|
||||
crate::run(c, &format!("cargo test --workspace (shared crate {name} on its own: {})", members.join(", ")))
|
||||
.map_err(|e| format!("shared crate {name} does not build and test on its own: {e}"))?;
|
||||
}
|
||||
println!("shared: {} shared crate(s) build and test on their own", shared.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Package name to the directory holding its Cargo.toml.
|
||||
fn manifest_dirs(meta: &serde_json::Value) -> Result<BTreeMap<String, PathBuf>, String> {
|
||||
let pkgs = meta["packages"].as_array().ok_or("metadata: no packages array")?;
|
||||
let mut out = BTreeMap::new();
|
||||
for p in pkgs {
|
||||
let (Some(name), Some(manifest)) = (p["name"].as_str(), p["manifest_path"].as_str()) else { continue };
|
||||
if let Some(dir) = Path::new(manifest).parent() {
|
||||
out.insert(name.to_string(), dir.to_path_buf());
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The crate and every shared crate it reaches through workspace dependencies of any kind.
|
||||
fn shared_closure(name: &str, crates: &[layers::Crate]) -> BTreeSet<String> {
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut todo = vec![name.to_string()];
|
||||
while let Some(n) = todo.pop() {
|
||||
if !seen.insert(n.clone()) {
|
||||
continue;
|
||||
}
|
||||
let Some(c) = crates.iter().find(|c| c.name == n) else { continue };
|
||||
todo.extend(c.deps.iter().filter(|d| d.workspace && layers::classify(&d.name) == Some(Class::Shared)).map(|d| d.name.clone()));
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
/// The scratch workspace's root manifest (see the module comment).
|
||||
fn scratch_manifest(root: &Path, src: &toml::Table, members: &[String], shared_dirs: &BTreeMap<PathBuf, &str>) -> Result<toml::Table, String> {
|
||||
let empty = toml::Table::new();
|
||||
let ws = src.get("workspace").and_then(toml::Value::as_table).unwrap_or(&empty);
|
||||
|
||||
let mut workspace = toml::Table::new();
|
||||
workspace.insert("resolver".into(), toml::Value::String("3".into()));
|
||||
workspace.insert("members".into(), toml::Value::Array(members.iter().cloned().map(toml::Value::String).collect()));
|
||||
for key in ["package", "lints"] {
|
||||
if let Some(v) = ws.get(key) {
|
||||
workspace.insert(key.into(), v.clone());
|
||||
}
|
||||
}
|
||||
let mut deps = toml::Table::new();
|
||||
for (key, value) in ws.get("dependencies").and_then(toml::Value::as_table).unwrap_or(&empty) {
|
||||
let Some(path) = path_of(value) else {
|
||||
deps.insert(key.clone(), value.clone());
|
||||
continue;
|
||||
};
|
||||
// Path dependencies stay only when they are shared crates, pointed at their copies.
|
||||
let Ok(dir) = canonical(&root.join(path)) else { continue };
|
||||
// Only the copied ones: a shared crate outside this closure has no directory here.
|
||||
if let Some(pkg) = shared_dirs.get(&dir).filter(|pkg| members.iter().any(|m| m == layers::short_name(pkg))) {
|
||||
let mut entry = value.as_table().cloned().unwrap_or_default();
|
||||
entry.insert("path".into(), toml::Value::String(layers::short_name(pkg).to_string()));
|
||||
deps.insert(key.clone(), toml::Value::Table(entry));
|
||||
}
|
||||
}
|
||||
workspace.insert("dependencies".into(), toml::Value::Table(deps));
|
||||
|
||||
let mut out = toml::Table::new();
|
||||
out.insert("workspace".into(), toml::Value::Table(workspace));
|
||||
if let Some(profile) = src.get("profile") {
|
||||
out.insert("profile".into(), profile.clone());
|
||||
}
|
||||
if let Some(patches) = src.get("patch").and_then(toml::Value::as_table) {
|
||||
let mut patch = toml::Table::new();
|
||||
for (registry, entries) in patches {
|
||||
let mut table = toml::Table::new();
|
||||
for (key, value) in entries.as_table().unwrap_or(&empty) {
|
||||
let mut value = value.clone();
|
||||
if let (Some(path), Some(entry)) = (path_of(&value).map(str::to_string), value.as_table_mut()) {
|
||||
let abs = root.join(&path);
|
||||
let abs = abs.to_str().ok_or_else(|| format!("patch {registry}.{key}: path {} is not UTF-8", abs.display()))?;
|
||||
entry.insert("path".into(), toml::Value::String(abs.to_string()));
|
||||
}
|
||||
table.insert(key.clone(), value);
|
||||
}
|
||||
patch.insert(registry.clone(), toml::Value::Table(table));
|
||||
}
|
||||
out.insert("patch".into(), toml::Value::Table(patch));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn path_of(value: &toml::Value) -> Option<&str> {
|
||||
value.as_table()?.get("path")?.as_str()
|
||||
}
|
||||
|
||||
/// The manifest, README and include rules for one shared crate.
|
||||
fn static_rules(name: &str, dir: &Path) -> Result<Vec<String>, String> {
|
||||
let mut out = Vec::new();
|
||||
let manifest = read_toml(&dir.join("Cargo.toml"))?;
|
||||
let lints_from_workspace = manifest.get("lints").and_then(|l| l.get("workspace")).and_then(toml::Value::as_bool) == Some(true);
|
||||
if !lints_from_workspace {
|
||||
out.push(format!("{name}: Cargo.toml must use the workspace lints (`[lints] workspace = true`)"));
|
||||
}
|
||||
let licence = manifest.get("package").and_then(|p| p.get("license"));
|
||||
let has_licence = match licence {
|
||||
Some(toml::Value::String(s)) => !s.trim().is_empty(),
|
||||
Some(v) => v.get("workspace").and_then(toml::Value::as_bool) == Some(true),
|
||||
None => false,
|
||||
};
|
||||
if !has_licence {
|
||||
out.push(format!("{name}: Cargo.toml must state a licence (`license = \"...\"` or `license.workspace = true`)"));
|
||||
}
|
||||
if !dir.join("README.md").is_file() {
|
||||
out.push(format!("{name}: needs a README.md beside its Cargo.toml stating its public API"));
|
||||
}
|
||||
let crate_dir = canonical(dir)?;
|
||||
let mut files = Vec::new();
|
||||
rust_files(dir, &mut files)?;
|
||||
for file in files {
|
||||
let text = std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?;
|
||||
let tokens: TokenStream = text.parse().map_err(|e| format!("{}: does not tokenise: {e}", file.display()))?;
|
||||
let base = canonical(file.parent().unwrap_or(dir))?;
|
||||
let shown = file.strip_prefix(dir).unwrap_or(&file).display().to_string();
|
||||
for (mac, target) in includes(tokens, &base, &crate_dir) {
|
||||
let inside = match &target {
|
||||
Included::Relative(p) => normalise(p).starts_with(&crate_dir),
|
||||
Included::Absolute => false,
|
||||
};
|
||||
if !inside {
|
||||
out.push(format!("{name}: {shown}: `{mac}!` reads outside the crate's own directory; move the file into the crate"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
enum Included {
|
||||
/// The resolved path (not yet normalised).
|
||||
Relative(PathBuf),
|
||||
/// An absolute literal path, which never travels with the crate.
|
||||
Absolute,
|
||||
}
|
||||
|
||||
/// Every `include_str!`/`include_bytes!` whose path is a string literal, or
|
||||
/// `concat!(env!("CARGO_MANIFEST_DIR"), "...")`, with the file it names. Paths built any other way
|
||||
/// cannot be resolved here and are left alone.
|
||||
fn includes(tokens: TokenStream, base: &Path, crate_dir: &Path) -> Vec<(String, Included)> {
|
||||
let mut out = Vec::new();
|
||||
let trees: Vec<TokenTree> = tokens.into_iter().collect();
|
||||
for (i, tree) in trees.iter().enumerate() {
|
||||
match tree {
|
||||
TokenTree::Ident(id) if *id == "include_str" || *id == "include_bytes" => {
|
||||
let (Some(TokenTree::Punct(bang)), Some(TokenTree::Group(args))) = (trees.get(i + 1), trees.get(i + 2)) else { continue };
|
||||
if bang.as_char() != '!' {
|
||||
continue;
|
||||
}
|
||||
if let Some(target) = include_target(args.stream(), base, crate_dir) {
|
||||
out.push((id.to_string(), target));
|
||||
}
|
||||
}
|
||||
TokenTree::Group(g) => out.extend(includes(g.stream(), base, crate_dir)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn include_target(args: TokenStream, base: &Path, crate_dir: &Path) -> Option<Included> {
|
||||
let trees: Vec<TokenTree> = args.into_iter().collect();
|
||||
match &trees[..] {
|
||||
[TokenTree::Literal(lit)] => {
|
||||
let path = string_literal(lit)?;
|
||||
if Path::new(&path).is_absolute() { Some(Included::Absolute) } else { Some(Included::Relative(base.join(path))) }
|
||||
}
|
||||
[TokenTree::Ident(concat), TokenTree::Punct(bang), TokenTree::Group(inner)] if *concat == "concat" && bang.as_char() == '!' => {
|
||||
let parts: Vec<TokenTree> = inner.stream().into_iter().collect();
|
||||
let [TokenTree::Ident(env), TokenTree::Punct(b), TokenTree::Group(var), rest @ ..] = &parts[..] else { return None };
|
||||
let var: Vec<TokenTree> = var.stream().into_iter().collect();
|
||||
let from_manifest_dir = matches!(&var[..], [TokenTree::Literal(l)] if string_literal(l).as_deref() == Some("CARGO_MANIFEST_DIR"));
|
||||
if *env != "env" || b.as_char() != '!' || !from_manifest_dir {
|
||||
return None;
|
||||
}
|
||||
let mut tail = String::new();
|
||||
for t in rest {
|
||||
match t {
|
||||
TokenTree::Punct(p) if p.as_char() == ',' => {}
|
||||
TokenTree::Literal(l) => tail.push_str(&string_literal(l)?),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some(Included::Relative(crate_dir.join(tail.trim_start_matches(['/', '\\']))))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn string_literal(lit: &proc_macro2::Literal) -> Option<String> {
|
||||
syn::parse2::<syn::LitStr>(TokenStream::from(TokenTree::Literal(lit.clone()))).ok().map(|s| s.value())
|
||||
}
|
||||
|
||||
/// Resolve `.` and `..` without touching the file system (the file may not exist).
|
||||
fn normalise(p: &Path) -> PathBuf {
|
||||
let mut out = PathBuf::new();
|
||||
for c in p.components() {
|
||||
match c {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
out.pop();
|
||||
}
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every .rs file under `dir`, skipping build output.
|
||||
fn rust_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
|
||||
for entry in std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))? {
|
||||
let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let path = entry.path();
|
||||
let kind = entry.file_type().map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if kind.is_dir() {
|
||||
if entry.file_name() != "target" {
|
||||
rust_files(&path, out)?;
|
||||
}
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy `from` into `to`, skipping any `target` directory.
|
||||
fn copy_dir(from: &Path, to: &Path) -> Result<(), String> {
|
||||
std::fs::create_dir_all(to).map_err(|e| format!("{}: {e}", to.display()))?;
|
||||
for entry in std::fs::read_dir(from).map_err(|e| format!("{}: {e}", from.display()))? {
|
||||
let entry = entry.map_err(|e| format!("{}: {e}", from.display()))?;
|
||||
let src = entry.path();
|
||||
let dest = to.join(entry.file_name());
|
||||
if src.is_dir() {
|
||||
if entry.file_name() != "target" {
|
||||
copy_dir(&src, &dest)?;
|
||||
}
|
||||
} else {
|
||||
std::fs::copy(&src, &dest).map_err(|e| format!("{} -> {}: {e}", src.display(), dest.display()))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_toml(path: &Path) -> Result<toml::Table, String> {
|
||||
let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
toml::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
fn write(path: &Path, text: &str) -> Result<(), String> {
|
||||
std::fs::write(path, text).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
fn canonical(path: &Path) -> Result<PathBuf, String> {
|
||||
std::fs::canonicalize(path).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn found(src: &str, base: &str, crate_dir: &str) -> Vec<(String, bool)> {
|
||||
let crate_dir = Path::new(crate_dir);
|
||||
includes(src.parse().unwrap(), Path::new(base), crate_dir)
|
||||
.into_iter()
|
||||
.map(|(m, t)| {
|
||||
let inside = match t {
|
||||
Included::Relative(p) => normalise(&p).starts_with(crate_dir),
|
||||
Included::Absolute => false,
|
||||
};
|
||||
(m, inside)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_inside_the_crate_pass() {
|
||||
let src = r#"const A: &str = include_str!("../assets/a.txt"); fn f() { let b = include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/b.bin")); }"#;
|
||||
assert_eq!(found(src, "/w/crates/x/src", "/w/crates/x"), [("include_str".to_string(), true), ("include_bytes".to_string(), true)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_outside_the_crate_flagged() {
|
||||
let src = r#"
|
||||
const A: &str = include_str!("../../../assets/a.txt");
|
||||
const B: &[u8] = include_bytes!("/etc/b.bin");
|
||||
const C: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../other/c.txt"));
|
||||
const D: &[&str] = &[include_str!(r"../../d.txt")];
|
||||
"#;
|
||||
let v = found(src, "/w/crates/x/src", "/w/crates/x");
|
||||
assert_eq!(v.len(), 4);
|
||||
assert!(v.iter().all(|(_, inside)| !inside), "{v:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolvable_include_paths_are_left_alone() {
|
||||
assert!(found(r#"const A: &str = include_str!(env!("SOMEWHERE"));"#, "/w/x/src", "/w/x").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scratch_manifest_keeps_external_and_shared_dependencies_only() {
|
||||
let root = std::env::temp_dir().join(format!("xtask-shared-test-{}", std::process::id()));
|
||||
let shared_dir = root.join("crates/ui-registry");
|
||||
let other_dir = root.join("crates/engine");
|
||||
std::fs::create_dir_all(&shared_dir).unwrap();
|
||||
std::fs::create_dir_all(&other_dir).unwrap();
|
||||
let src: toml::Table = toml::from_str(
|
||||
r#"
|
||||
[workspace]
|
||||
members = ["crates/*"]
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
[workspace.dependencies]
|
||||
serde = "1"
|
||||
solidcraft-ui-registry = { path = "crates/ui-registry" }
|
||||
solidcraft-engine = { path = "crates/engine" }
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
[profile.ci]
|
||||
inherits = "dev"
|
||||
[patch.crates-io]
|
||||
winit = { path = "vendor/winit" }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let shared_dirs = BTreeMap::from([(std::fs::canonicalize(&shared_dir).unwrap(), "solidcraft-ui-registry")]);
|
||||
let m = scratch_manifest(&root, &src, &["ui-registry".into()], &shared_dirs).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let ws = m["workspace"].as_table().unwrap();
|
||||
assert_eq!(ws["resolver"].as_str(), Some("3"));
|
||||
let deps = ws["dependencies"].as_table().unwrap();
|
||||
assert!(deps.contains_key("serde") && !deps.contains_key("solidcraft-engine"));
|
||||
assert_eq!(path_of(&deps["solidcraft-ui-registry"]), Some("ui-registry"));
|
||||
assert!(ws.contains_key("package") && ws.contains_key("lints") && m.contains_key("profile"));
|
||||
let winit = path_of(&m["patch"]["crates-io"]["winit"]).unwrap();
|
||||
assert!(Path::new(winit).is_absolute() && winit.ends_with("vendor/winit"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user