diff --git a/docs/specs/extensions.md b/docs/specs/extensions.md index 3c8f288..9de76e2 100644 --- a/docs/specs/extensions.md +++ b/docs/specs/extensions.md @@ -227,15 +227,15 @@ An `expand` reply MAY carry `files` alongside `text`: ```json {"v":1,"id":"e1","text":"…","introduced":["tables"], "files":[{"name":"customer_row.dart","text":"…"}, - {"name":"order_row.dart","text":"…"}]} + {"name":"lib/src/generated/models.dart","text":"…"}]} ``` The annotated declaration is the **seed**: its fragment fills its region as ever, and each entry in `files` becomes a complete Dart file beside the seed's own file. Normatively: -- **Naming.** `name` MUST be a bare file name ending in `.dart` — no path separator, no leading dot, a non-empty stem. Anything else is `DMX7007`. The macro controls the name; the seed's directory anchors where it lands. -- **Ownership marker.** The driver — never the macro — prepends line 1 to every file it writes: `// dmx: generated from — do not edit.` The marker is the whole ownership protocol: a file that carries it is machine-owned outright, no regions, no author bytes, and byte-exactness ([emission.inline-backend.byte-exactness]) has nothing in it to protect. +- **Naming.** `name` MUST end in `.dart` and contain only ordinary relative path components: no absolute root, `.` or `..`, backslash, or hidden component. A bare name lands beside the seed. A path-shaped name is relative to the nearest ancestor carrying `pubspec.yaml`; without that package root it is `DMX7007`. A target that escapes the package through a symbolic link is also `DMX7007`. +- **Ownership marker.** The driver — never the macro — prepends line 1 to every file it writes. A sibling records the seed file name; a package-relative output records the seed's package-relative path: `// dmx: generated from — do not edit.` The marker is the whole ownership protocol: a file that carries it is machine-owned outright, no regions, no author bytes, and byte-exactness ([emission.inline-backend.byte-exactness]) has nothing in it to protect. - **Never overwrite a human.** A target path that already exists without a dmx marker is somebody's hand-written file, and the driver MUST refuse with `DMX7008` rather than touch it. The same code covers two macro files claiming one name in a single pass, and a macro naming the seed's own file. -- **Stale collection.** After a pass over a seed **in which a macro actually expanded**, any `.dart` file in the seed's directory whose marker names **this seed** and which the pass did not produce MUST be deleted. A dropped table means a dropped file — the generated tree tracks the source of truth in both directions. +- **Stale collection.** After a pass over a seed **in which a macro actually expanded**, any sibling whose marker names the seed file, and any Dart file inside its package whose marker names the package-relative seed, which the pass did not produce MUST be deleted. A dropped table means a dropped file — the generated tree tracks the source of truth in both directions. - **Nothing ran, nothing is collected.** A pass where no macro expanded MUST NOT write or collect any file, even when the source carries an annotation. An absent worker, an uninstalled `dart`, a crashed process and a checkout without `tool/` all expand nothing, and reading that as "the source of truth dropped everything" would delete a generated tree over a broken toolchain. Deletion requires a macro that ran and did not produce the file. - **Editable in the ordinary sense.** A macro-authored file is still generated code someone will edit, delete, or revert, and its marker names the seed that produces it. Under `watch` ([execution.modes]), a change to a file carrying the marker MUST re-run the seed the marker names, and a marked file that is deleted MUST be written again. Re-running the seed is the only answer available: the authored file carries no annotation of its own, so a pass over it alone can only ever report "unchanged". - **Same bar.** Each file's text passes through the one normalizer and MUST parse; an unparseable file fails the build and nothing is written ([dartmacros.pipeline], [validation]). Writes are atomic and no-op-aware ([emission.inline-backend.no-op-writes]), so `watch` does not loop on its own output. @@ -243,4 +243,3 @@ The annotated declaration is the **seed**: its fragment fills its region as ever - **Inert as input.** A macro-authored file carries no `@dmx`, so later passes leave it untouched; generated output is never re-scanned for triggers ([rendering]). --- - diff --git a/src/dart_packages/dmx/lib/src/macros/api.dart b/src/dart_packages/dmx/lib/src/macros/api.dart index b6147fe..aab4b4c 100644 --- a/src/dart_packages/dmx/lib/src/macros/api.dart +++ b/src/dart_packages/dmx/lib/src/macros/api.dart @@ -200,8 +200,9 @@ sealed class DmxOutput { /// One whole Dart file this expansion authors, named by the macro /// [dartmacros.files]. final class DmxGeneratedFile { - /// A bare file name ending in `.dart` — the driver anchors it beside the - /// annotated declaration's own file and refuses anything path-like. + /// A `.dart` file name or safe package-relative path. Bare names are anchored + /// beside the annotated declaration; paths are anchored at its nearest + /// `pubspec.yaml`. Absolute paths, traversal and hidden components are refused. final String name; /// The file's complete Dart source. The driver prepends its ownership @@ -220,7 +221,7 @@ final class DmxFragment extends DmxOutput { /// Every identifier the text binds, for hygiene [hygiene]. final List introduced; - /// Whole sibling files this expansion also authors, one per name the + /// Whole files this expansion also authors, one per name or package path the /// macro chooses [dartmacros.files]. final List files; diff --git a/src/dmx/src/dartmacros.rs b/src/dmx/src/dartmacros.rs index b4a0bc0..4863e91 100644 --- a/src/dmx/src/dartmacros.rs +++ b/src/dmx/src/dartmacros.rs @@ -295,8 +295,8 @@ fn render_reply(request: &Value) -> Value { } } -/// The `files` a reply carries, names validated as bare `*.dart` file names -/// [dartmacros.files]. +/// The `files` a reply carries, with safe Dart output paths validated before +/// they enter the shared pipeline [dartmacros.files]. fn macro_files(reply: &Value) -> Result> { let mut files = Vec::new(); for file in reply @@ -311,12 +311,9 @@ fn macro_files(reply: &Value) -> Result> { ) else { bail!("DMX7002: each entry in `files` needs a string `name` and `text`"); }; - let stem_ok = name - .strip_suffix(".dart") - .is_some_and(|stem| !stem.is_empty() && !stem.starts_with('.')); - if !stem_ok || name.contains(['/', '\\']) { + if !valid_macro_file_path(name) { bail!( - "DMX7007: macro file name `{name}` must be a bare `*.dart` file name [dartmacros.files]" + "DMX7007: macro file name `{name}` must be a safe package-relative `*.dart` path [dartmacros.files]" ); } files.push(GeneratedFile { @@ -327,6 +324,20 @@ fn macro_files(reply: &Value) -> Result> { Ok(files) } +/// Whether a macro output is a relative Dart path with no hidden or escaping +/// component [dartmacros.files]. +fn valid_macro_file_path(name: &str) -> bool { + if name.contains('\\') || Path::new(name).extension().and_then(|v| v.to_str()) != Some("dart") { + return false; + } + Path::new(name) + .components() + .all(|component| match component { + std::path::Component::Normal(part) => !part.to_string_lossy().starts_with('.'), + _ => false, + }) +} + impl Drop for Worker { fn drop(&mut self) { let _ = self.child.kill(); diff --git a/src/dmx/src/emit.rs b/src/dmx/src/emit.rs index 9527e22..0d2b4f6 100644 --- a/src/dmx/src/emit.rs +++ b/src/dmx/src/emit.rs @@ -20,11 +20,18 @@ use std::path::{Path, PathBuf}; use crate::frontend::{REGION_END, REGION_START, RawDecl, is_region_end, region_opener}; +#[cfg(not(target_arch = "wasm32"))] +#[path = "emit/macro_files.rs"] +mod macro_files; +#[cfg(not(target_arch = "wasm32"))] +pub use macro_files::emit_macro_files; + /// One whole file a macro authored and named [dartmacros.files]. #[derive(Clone, Debug, Eq, PartialEq)] pub struct GeneratedFile { - /// Where it goes: a bare sibling file name for a macro authored in Dart, - /// validated on receipt from the worker [dartmacros.files]; a + /// Where it goes: a sibling name or package-relative path for a macro + /// authored in Dart, validated on receipt from the worker + /// [dartmacros.files]; a /// workspace-relative path for a Markdown generation group, validated by /// its emitter [typediagram.output]. pub name: String, @@ -214,57 +221,105 @@ pub fn seed_of(path: &Path) -> Option { .trim_end_matches('\n') .strip_prefix(FILE_MARKER_PREFIX)? .strip_suffix(FILE_MARKER_SUFFIX)?; - // Beside the generated file for a Dart macro's sibling [dartmacros.files]; - // against the working directory for a Markdown document, whose marker - // names a workspace-relative path [typediagram.output]. + // A legacy sibling marker, a package-relative macro marker, or a + // workspace-relative Markdown marker can all identify the seed. let beside = path.parent().unwrap_or_else(|| Path::new(".")).join(name); let from_workspace = PathBuf::from(name); - [beside, from_workspace] - .into_iter() + let from_package = package_root(path).map(|root| root.join(name)); + std::iter::once(beside) + .chain(from_package) + .chain(std::iter::once(from_workspace)) .find(|candidate| candidate.is_file()) } -/// Emits every macro-authored file beside `seed`, and collects the ones a -/// previous pass wrote from this seed that this pass no longer produces -/// [dartmacros.files]. Returns whether anything changed (or, under `check`, -/// would change). -/// -/// # Errors -/// -/// Fails when a target exists without a dmx marker (`DMX7008` — that is a -/// human's file), when a name collides with the seed's own, or on I/O. +/// The directory beside an annotated seed. #[cfg(not(target_arch = "wasm32"))] -pub fn emit_macro_files(seed: &Path, files: &[GeneratedFile], opts: &Options) -> Result { - let dir = match seed.parent() { - Some(parent) if !parent.as_os_str().is_empty() => parent, - _ => Path::new("."), - }; - let seed_name = seed - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_default(); - let marker = file_marker(&seed_name); - let mut changed = false; - for file in files { - if file.name == seed_name { - bail!( - "DMX7008: macro file `{}` would overwrite the annotated file itself \ - [dartmacros.files]", - file.name - ); +fn seed_dir(seed: &Path) -> &Path { + seed.parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")) +} + +/// The original bare-name ownership marker for sibling outputs. +#[cfg(not(target_arch = "wasm32"))] +fn sibling_marker(seed: &Path) -> String { + file_marker(&seed.file_name().unwrap_or_default().to_string_lossy()) +} + +/// The nearest enclosing directory containing a pubspec. +#[cfg(not(target_arch = "wasm32"))] +fn package_root(path: &Path) -> Option { + path.ancestors() + .skip(1) + .find(|dir| dir.join("pubspec.yaml").is_file()) + .map(|dir| { + if dir.as_os_str().is_empty() { + PathBuf::from(".") + } else { + dir.to_owned() + } + }) +} + +/// A stable forward-slash path relative to the package root. +#[cfg(not(target_arch = "wasm32"))] +fn relative_name(root: &Path, path: &Path) -> String { + let root = resolved(root); + let path = resolved(path); + path.strip_prefix(root) + .unwrap_or(&path) + .to_string_lossy() + .replace('\\', "/") +} + +/// All Dart files under a package without following symlinks. +#[cfg(not(target_arch = "wasm32"))] +fn dart_files_under(root: &Path) -> Result> { + let mut found = Vec::new(); + collect_dart_files(root, &mut found)?; + Ok(found) +} + +/// Recurses through visible package directories to find owned output candidates. +#[cfg(not(target_arch = "wasm32"))] +fn collect_dart_files(dir: &Path, found: &mut Vec) -> Result<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let kind = entry.file_type()?; + if kind.is_dir() { + collect_dart_files(&entry.path(), found)?; + } else if kind.is_file() + && entry + .path() + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("dart")) + { + found.push(entry.path()); } - let target = dir.join(&file.name); - let content = format!("{marker}\n\n{}\n", file.text); - changed |= write_owned( - &target, - &content, - opts.check, - "DMX7008", - "[dartmacros.files]", - )?; } - let kept: Vec = files.iter().map(|file| dir.join(&file.name)).collect(); - Ok(collect_stale(&dart_files_in(dir)?, &marker, &kept, opts.check)? || changed) + Ok(()) +} + +/// Refuses a path whose nearest existing ancestor resolves outside its root. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn refuse_symlink_escape(root: &Path, target: &Path) -> Result<(), String> { + let Ok(root) = root.canonicalize() else { + return Ok(()); + }; + let existing = target + .ancestors() + .skip(1) + .find(|path| path.exists()) + .unwrap_or(&root); + match existing.canonicalize() { + Ok(real) if real.starts_with(&root) => Ok(()), + Ok(real) => Err(format!( + "reaches outside the output root through {} -> {}", + existing.display(), + real.display() + )), + Err(_) => Ok(()), + } } /// Writes one file dmx owns, and says whether that changed anything. diff --git a/src/dmx/src/emit/macro_files.rs b/src/dmx/src/emit/macro_files.rs new file mode 100644 index 0000000..b8f61c2 --- /dev/null +++ b/src/dmx/src/emit/macro_files.rs @@ -0,0 +1,138 @@ +//! Whole files authored by a Dart macro [dartmacros.files]. + +use std::path::{Path, PathBuf}; + +use anyhow::{Result, bail}; + +use super::{ + GeneratedFile, Options, collect_stale, dart_files_in, dart_files_under, file_marker, + package_root, refuse_symlink_escape, relative_name, resolved, seed_dir, sibling_marker, + write_owned, +}; + +/// A resolved destination with the ownership marker and validated Dart source. +struct MacroTarget<'a> { + /// The destination within the seed's sibling directory or package. + path: PathBuf, + /// The marker identifying the annotated seed that owns this output. + marker: String, + /// Complete source returned by the Dart macro. + text: &'a str, +} + +/// Emits every macro-authored sibling or package-relative file, then collects +/// files this seed no longer produces [dartmacros.files]. +/// +/// # Errors +/// +/// Fails before writing on collisions, unsafe paths, or human-owned targets. +pub fn emit_macro_files(seed: &Path, files: &[GeneratedFile], opts: &Options) -> Result { + let targets = macro_targets(seed, files)?; + let written = targets.iter().try_fold(false, |changed, target| { + Ok::(write_macro_target(target, opts.check)? || changed) + })?; + Ok(collect_macro_stale(seed, &targets, opts.check)? || written) +} + +/// Resolves the whole output set and refuses aliases of the same destination. +fn macro_targets<'a>(seed: &Path, files: &'a [GeneratedFile]) -> Result>> { + let mut targets = Vec::new(); + for file in files { + let target = macro_target(seed, file)?; + refuse_duplicate(&targets, &target)?; + targets.push(target); + } + Ok(targets) +} + +/// Rejects two output names that resolve to the same file. +fn refuse_duplicate(prior: &[MacroTarget<'_>], target: &MacroTarget<'_>) -> Result<()> { + if prior + .iter() + .any(|item| resolved(&item.path) == resolved(&target.path)) + { + bail!( + "DMX7008: two macro outputs resolve to `{}` [dartmacros.files]", + target.path.display() + ); + } + Ok(()) +} + +/// Resolves one output, preserving bare-name sibling behavior. +fn macro_target<'a>(seed: &Path, file: &'a GeneratedFile) -> Result> { + let (path, marker) = if file.name.contains('/') { + package_target(seed, &file.name)? + } else { + (seed_dir(seed).join(&file.name), sibling_marker(seed)) + }; + if resolved(&path) == resolved(seed) { + bail!( + "DMX7008: macro file `{}` would overwrite the annotated file itself [dartmacros.files]", + file.name + ); + } + Ok(MacroTarget { + path, + marker, + text: &file.text, + }) +} + +/// Anchors a path at the nearest package and refuses symlink escapes. +fn package_target(seed: &Path, name: &str) -> Result<(PathBuf, String)> { + let root = package_root(seed).ok_or_else(|| { + anyhow::anyhow!( + "DMX7007: package-relative macro output `{name}` needs a pubspec.yaml [dartmacros.files]" + ) + })?; + let target = root.join(name); + refuse_symlink_escape(&root, &target).map_err(|detail| { + anyhow::anyhow!("DMX7007: macro output `{name}` {detail} [dartmacros.files]") + })?; + Ok((target, file_marker(&relative_name(&root, seed)))) +} + +/// Writes or checks one owned output with the canonical marker framing. +fn write_macro_target(target: &MacroTarget<'_>, check: bool) -> Result { + let content = format!("{}\n\n{}\n", target.marker, target.text); + write_owned( + &target.path, + &content, + check, + "DMX7008", + "[dartmacros.files]", + ) +} + +/// Collects obsolete outputs owned by this seed in both supported scopes. +fn collect_macro_stale(seed: &Path, targets: &[MacroTarget<'_>], check: bool) -> Result { + let sibling = sibling_marker(seed); + let kept = kept_with_marker(targets, &sibling); + let mut changed = collect_stale(&dart_files_in(seed_dir(seed))?, &sibling, &kept, check)?; + if let Some(root) = package_root(seed) { + changed |= collect_package_stale(seed, &root, targets, check)?; + } + Ok(changed) +} + +/// Collects obsolete package-relative outputs, never another seed's files. +fn collect_package_stale( + seed: &Path, + root: &Path, + targets: &[MacroTarget<'_>], + check: bool, +) -> Result { + let marker = file_marker(&relative_name(root, seed)); + let kept = kept_with_marker(targets, &marker); + collect_stale(&dart_files_under(root)?, &marker, &kept, check) +} + +/// The current destinations sharing one ownership marker. +fn kept_with_marker(targets: &[MacroTarget<'_>], marker: &str) -> Vec { + targets + .iter() + .filter(|target| target.marker == marker) + .map(|target| target.path.clone()) + .collect() +} diff --git a/src/dmx/src/typediagram/emit.rs b/src/dmx/src/typediagram/emit.rs index 9e35613..886ae02 100644 --- a/src/dmx/src/typediagram/emit.rs +++ b/src/dmx/src/typediagram/emit.rs @@ -40,37 +40,10 @@ pub fn resolve_output(workspace: &Path, declared: &str) -> Result { } } let resolved = workspace.join(relative); - refuse_symlink_escape(workspace, &resolved).map_err(|detail| fault(&detail))?; + crate::emit::refuse_symlink_escape(workspace, &resolved).map_err(|detail| fault(&detail))?; Ok(resolved) } -/// Refuses a path whose nearest existing ancestor resolves outside the root. -/// -/// A directory in the middle of an output path may be a symbolic link; the -/// question is only ever whether following it still lands inside the tree dmx -/// was asked to manage. Canonicalizing the deepest ancestor that exists answers -/// exactly that, and a path whose directories do not exist yet cannot have been -/// redirected by one. -fn refuse_symlink_escape(workspace: &Path, resolved: &Path) -> Result<(), String> { - let Ok(root) = workspace.canonicalize() else { - return Ok(()); - }; - let existing = resolved - .ancestors() - .skip(1) - .find(|ancestor| ancestor.exists()) - .unwrap_or(workspace); - match existing.canonicalize() { - Ok(real) if real.starts_with(&root) => Ok(()), - Ok(real) => Err(format!( - "reaches outside the workspace through {} -> {}", - existing.display(), - real.display() - )), - Err(_) => Ok(()), - } -} - /// Refuses an output path a document may not claim at all. /// /// # Errors diff --git a/src/dmx/tests/dartmacros.rs b/src/dmx/tests/dartmacros.rs index fe77707..85f79be 100644 --- a/src/dmx/tests/dartmacros.rs +++ b/src/dmx/tests/dartmacros.rs @@ -31,6 +31,12 @@ mod watch; // Likewise: the render suite [dartmacros.render] shares these fixtures. #[path = "dartmacros/render.rs"] mod render; +// Whole-file emission has its own module so this integration target stays +// below the repository's 500-line ceiling [dartmacros.files]. +#[path = "dartmacros/files.rs"] +mod files; + +use files::{SEED_MARKER, files_project}; use std::fs; use std::process::{Command, Output}; @@ -312,178 +318,3 @@ fn without_a_worker_an_unknown_macro_stays_inert() { let untouched = fs::read_to_string(dir.path.join("lib/order.dart")).expect("read output"); assert_eq!(untouched, source, "the file must be byte-identical"); } - -/// A seed class whose macro authors sibling files [dartmacros.files]. -const SEED: &str = "@dmx('tables')\nclass Schema {\n}\n"; - -/// An expand hook returning one manifest line for the seed's region. -const MANIFEST_EXPAND: &str = - "String expand(Map invocation) =>\n ' static const int tables = 2;\\n';"; - -/// A project whose worker authors `files` beside the seed [dartmacros.files]. -fn files_project(files: &str) -> TempDirectory { - project( - &worker_with_files(&["tables"], MANIFEST_EXPAND, files), - "schema.dart", - SEED, - ) -} - -/// The exact ownership marker files generated from the seed carry -/// [dartmacros.files]. -const SEED_MARKER: &str = "// dmx: generated from schema.dart — do not edit."; - -/// [dartmacros.files]: one annotation, and the macro authors whole sibling -/// files it names itself — marker line prepended, content validated, and a -/// second pass writing nothing. -#[test] -fn a_macro_authors_whole_sibling_files() { - let dir = files_project( - "List> files(Map invocation) => [ - {'name': 'customer_row.dart', 'text': 'final class CustomerRow {\\n const CustomerRow();\\n}\\n'}, - {'name': 'order_row.dart', 'text': 'final class OrderRow {\\n const OrderRow();\\n}\\n'}, - ];", - ); - - let seed = build_and_read(&dir, "schema.dart"); - assert!( - seed.contains("static const int tables = 2;"), - "the seed's own region must still fill:\n{seed}" - ); - for (name, class) in [ - ("customer_row.dart", "final class CustomerRow {"), - ("order_row.dart", "final class OrderRow {"), - ] { - let sibling = fs::read_to_string(dir.path.join("lib").join(name)).expect("sibling"); - assert!( - sibling.starts_with(&format!("{SEED_MARKER}\n\n")), - "`{name}` must open with the ownership marker:\n{sibling}" - ); - assert!(sibling.contains(class), "`{name}` must hold its class"); - } - - let second = dmx(&dir, &["build", "lib", "--insert-regions"]); - assert!( - String::from_utf8_lossy(&second.stdout).contains("0 of 3 file(s) updated"), - "an up-to-date pass must write nothing [emission.inline-backend.no-op-writes]" - ); -} - -/// [dartmacros.files]: a file this seed wrote before and no longer produces is -/// collected — a dropped table means a dropped file — while a hand-written -/// neighbour without the marker is untouchable. -#[test] -fn stale_macro_files_are_collected_and_hand_written_ones_kept() { - let dir = files_project( - "List> files(Map invocation) => - [{'name': 'customer_row.dart', 'text': 'final class CustomerRow {\\n const CustomerRow();\\n}\\n'}];", - ); - let stale = format!("{SEED_MARKER}\n\nfinal class DroppedRow {{\n const DroppedRow();\n}}\n"); - let _ = dir.write("lib/dropped_row.dart", &stale).expect("stale"); - let hand = "class Hand {\n const Hand();\n}\n"; - let _ = dir.write("lib/hand.dart", hand).expect("hand"); - - let _ = build_and_read(&dir, "schema.dart"); - assert!( - !dir.path.join("lib/dropped_row.dart").exists(), - "a sibling this pass no longer produces must be collected" - ); - assert_eq!( - fs::read_to_string(dir.path.join("lib/hand.dart")).expect("hand kept"), - hand, - "an unmarked neighbour is not dmx's to touch" - ); -} - -/// [dartmacros.files]: the refusals — a name that would overwrite a -/// hand-written file, a path-shaped name, an unparseable file, and two claims -/// on one name all fail the build with nothing written. -#[test] -fn dangerous_macro_files_are_refused() { - let overwrite = files_project( - "List> files(Map invocation) => - [{'name': 'customer_row.dart', 'text': 'final class CustomerRow {\\n const CustomerRow();\\n}\\n'}];", - ); - let hand = "class CustomerRow {\n const CustomerRow();\n}\n"; - let _ = overwrite - .write("lib/customer_row.dart", hand) - .expect("hand"); - let refused = dmx(&overwrite, &["build", "lib", "--insert-regions"]); - assert!( - !refused.status.success(), - "overwriting a human's file must fail" - ); - assert!( - String::from_utf8_lossy(&refused.stderr).contains("DMX7008"), - "the refusal must carry its code" - ); - assert_eq!( - fs::read_to_string(overwrite.path.join("lib/customer_row.dart")).expect("kept"), - hand, - "the hand-written file must survive byte-identically" - ); - - for (files, code) in [ - ( - "List> files(Map invocation) => - [{'name': '../escape.dart', 'text': 'class Escape {}\\n'}];", - "DMX7007", - ), - ( - "List> files(Map invocation) => - [{'name': 'broken_row.dart', 'text': 'final class {\\n'}];", - "macro-authored file", - ), - ( - "List> files(Map invocation) => [ - {'name': 'twice_row.dart', 'text': 'final class TwiceRow {}\\n'}, - {'name': 'twice_row.dart', 'text': 'final class TwiceRow {}\\n'}, - ];", - "DMX7008", - ), - ] { - let dir = files_project(files); - let output = dmx(&dir, &["build", "lib", "--insert-regions"]); - assert!( - !output.status.success(), - "the reply must be refused: {code}" - ); - assert!( - String::from_utf8_lossy(&output.stderr).contains(code), - "diagnostic `{code}` missing:\n{}", - String::from_utf8_lossy(&output.stderr) - ); - for name in ["escape.dart", "broken_row.dart", "twice_row.dart"] { - assert!( - !dir.path.join(name).exists() && !dir.path.join("lib").join(name).exists(), - "a refused pass must write nothing" - ); - } - } -} - -/// [dartmacros.files] + [execution]: `--check` reports sibling drift on the -/// seed without writing, and a generated tree passes it clean. -#[test] -fn check_reports_sibling_drift_without_writing() { - let dir = files_project( - "List> files(Map invocation) => - [{'name': 'customer_row.dart', 'text': 'final class CustomerRow {\\n const CustomerRow();\\n}\\n'}];", - ); - - let drift = dmx(&dir, &["build", "lib", "--insert-regions", "--check"]); - assert_eq!(drift.status.code(), Some(2), "missing siblings are drift"); - assert!( - !dir.path.join("lib/customer_row.dart").exists(), - "`--check` must not write the sibling" - ); - - let _ = build_and_read(&dir, "schema.dart"); - let clean = dmx(&dir, &["build", "lib", "--insert-regions", "--check"]); - assert_eq!( - clean.status.code(), - Some(0), - "a generated tree must pass `--check`:\n{}", - String::from_utf8_lossy(&clean.stderr) - ); -} diff --git a/src/dmx/tests/dartmacros/files.rs b/src/dmx/tests/dartmacros/files.rs new file mode 100644 index 0000000..80f77ec --- /dev/null +++ b/src/dmx/tests/dartmacros/files.rs @@ -0,0 +1,194 @@ +//! Whole-file outputs authored by Dart macros [dartmacros.files]. + +use super::*; + +const SEED: &str = "@dmx('tables')\nclass Schema {\n}\n"; +const MANIFEST_EXPAND: &str = + "String expand(Map invocation) =>\n ' static const int tables = 2;\\n';"; +pub(super) const SEED_MARKER: &str = "// dmx: generated from schema.dart — do not edit."; + +pub(super) fn files_project(files: &str) -> TempDirectory { + project( + &worker_with_files(&["tables"], MANIFEST_EXPAND, files), + "schema.dart", + SEED, + ) +} + +/// [dartmacros.files]: safe package-relative output paths are owned by the +/// Dart macro, so one seed can produce one ordinary public library file. +#[test] +fn a_macro_authors_a_package_relative_file() { + let dir = files_project( + "List> files(Map invocation) => + [{'name': 'lib/src/generated/models.dart', 'text': 'final class Models {}\\n'}];", + ); + let _ = dir + .write("pubspec.yaml", "name: fixture\n") + .expect("pubspec"); + + let _ = build_and_read(&dir, "schema.dart"); + let output = fs::read_to_string(dir.path.join("lib/src/generated/models.dart")) + .expect("package-relative output"); + assert!(output.starts_with("// dmx: generated from lib/schema.dart — do not edit.\n\n")); + assert!(output.contains("final class Models {}")); +} + +/// [dartmacros.files]: package-relative outputs participate in the same +/// ownership cleanup as siblings when a later macro pass drops them. +#[test] +fn stale_package_relative_files_are_collected() { + let dir = files_project( + "List> files(Map invocation) => + [{'name': 'lib/src/generated/models.dart', 'text': 'final class Models {}\\n'}];", + ); + let _ = dir + .write("pubspec.yaml", "name: fixture\n") + .expect("pubspec"); + let _ = build_and_read(&dir, "schema.dart"); + assert!(dir.path.join("lib/src/generated/models.dart").is_file()); + + let worker = worker_with_files(&["tables"], MANIFEST_EXPAND, NO_FILES); + let _ = dir.write("tool/dmx/macros.dart", &worker).expect("worker"); + let _ = build_and_read(&dir, "schema.dart"); + assert!(!dir.path.join("lib/src/generated/models.dart").exists()); +} + +/// [dartmacros.files]: whole sibling files carry markers and settle after one +/// generation pass. +#[test] +fn a_macro_authors_whole_sibling_files() { + let dir = files_project( + "List> files(Map invocation) => [ + {'name': 'customer_row.dart', 'text': 'final class CustomerRow {\\n const CustomerRow();\\n}\\n'}, + {'name': 'order_row.dart', 'text': 'final class OrderRow {\\n const OrderRow();\\n}\\n'}, + ];", + ); + + let seed = build_and_read(&dir, "schema.dart"); + assert!(seed.contains("static const int tables = 2;")); + for (name, class) in [ + ("customer_row.dart", "final class CustomerRow {"), + ("order_row.dart", "final class OrderRow {"), + ] { + let sibling = fs::read_to_string(dir.path.join("lib").join(name)).expect("sibling"); + assert!(sibling.starts_with(&format!("{SEED_MARKER}\n\n"))); + assert!(sibling.contains(class), "`{name}` must hold its class"); + } + + let second = dmx(&dir, &["build", "lib", "--insert-regions"]); + assert!( + String::from_utf8_lossy(&second.stdout).contains("0 of 3 file(s) updated"), + "an up-to-date pass must write nothing" + ); +} + +/// [dartmacros.files]: stale owned siblings are collected while human files +/// remain untouched. +#[test] +fn stale_macro_files_are_collected_and_hand_written_ones_kept() { + let dir = files_project( + "List> files(Map invocation) => + [{'name': 'customer_row.dart', 'text': 'final class CustomerRow {\\n const CustomerRow();\\n}\\n'}];", + ); + let stale = format!("{SEED_MARKER}\n\nfinal class DroppedRow {{\n const DroppedRow();\n}}\n"); + let _ = dir.write("lib/dropped_row.dart", &stale).expect("stale"); + let hand = "class Hand {\n const Hand();\n}\n"; + let _ = dir.write("lib/hand.dart", hand).expect("hand"); + + let _ = build_and_read(&dir, "schema.dart"); + assert!(!dir.path.join("lib/dropped_row.dart").exists()); + assert_eq!( + fs::read_to_string(dir.path.join("lib/hand.dart")).expect("hand kept"), + hand + ); +} + +/// [dartmacros.files]: overwrites, traversal, invalid Dart, and duplicate +/// claims all fail with nothing written. +#[test] +fn dangerous_macro_files_are_refused() { + let overwrite = files_project( + "List> files(Map invocation) => + [{'name': 'customer_row.dart', 'text': 'final class CustomerRow {\\n const CustomerRow();\\n}\\n'}];", + ); + let hand = "class CustomerRow {\n const CustomerRow();\n}\n"; + let _ = overwrite + .write("lib/customer_row.dart", hand) + .expect("hand"); + let refused = dmx(&overwrite, &["build", "lib", "--insert-regions"]); + assert!(!refused.status.success()); + assert!(String::from_utf8_lossy(&refused.stderr).contains("DMX7008")); + assert_eq!( + fs::read_to_string(overwrite.path.join("lib/customer_row.dart")).expect("kept"), + hand + ); + + for (files, code) in [ + ( + "List> files(Map invocation) => + [{'name': '../escape.dart', 'text': 'class Escape {}\\n'}];", + "DMX7007", + ), + ( + "List> files(Map invocation) => + [{'name': '/tmp/escape.dart', 'text': 'class Escape {}\\n'}];", + "DMX7007", + ), + ( + "List> files(Map invocation) => + [{'name': 'lib/../escape.dart', 'text': 'class Escape {}\\n'}];", + "DMX7007", + ), + ( + "List> files(Map invocation) => + [{'name': 'broken_row.dart', 'text': 'final class {\\n'}];", + "macro-authored file", + ), + ( + "List> files(Map invocation) => [ + {'name': 'twice_row.dart', 'text': 'final class TwiceRow {}\\n'}, + {'name': 'twice_row.dart', 'text': 'final class TwiceRow {}\\n'}, + ];", + "DMX7008", + ), + ] { + let dir = files_project(files); + let output = dmx(&dir, &["build", "lib", "--insert-regions"]); + assert!( + !output.status.success(), + "the reply must be refused: {code}" + ); + assert!( + String::from_utf8_lossy(&output.stderr).contains(code), + "diagnostic `{code}` missing:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + for name in ["escape.dart", "broken_row.dart", "twice_row.dart"] { + assert!(!dir.path.join(name).exists() && !dir.path.join("lib").join(name).exists()); + } + } +} + +/// [dartmacros.files] + [execution]: check reports drift without writing and +/// accepts a generated tree. +#[test] +fn check_reports_sibling_drift_without_writing() { + let dir = files_project( + "List> files(Map invocation) => + [{'name': 'customer_row.dart', 'text': 'final class CustomerRow {\\n const CustomerRow();\\n}\\n'}];", + ); + + let drift = dmx(&dir, &["build", "lib", "--insert-regions", "--check"]); + assert_eq!(drift.status.code(), Some(2), "missing siblings are drift"); + assert!(!dir.path.join("lib/customer_row.dart").exists()); + + let _ = build_and_read(&dir, "schema.dart"); + let clean = dmx(&dir, &["build", "lib", "--insert-regions", "--check"]); + assert_eq!( + clean.status.code(), + Some(0), + "a generated tree must pass `--check`:\n{}", + String::from_utf8_lossy(&clean.stderr) + ); +}