Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions docs/specs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,20 +227,19 @@ 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 <seed file name> — 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 <seed> — 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.
- **Drift.** Under `check` ([execution]), a sibling that would be created, rewritten, or collected is drift on the seed's pass: reported, exit non-zero, nothing written.
- **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]).

---

7 changes: 4 additions & 3 deletions src/dart_packages/dmx/lib/src/macros/api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -220,7 +221,7 @@ final class DmxFragment extends DmxOutput {
/// Every identifier the text binds, for hygiene [hygiene].
final List<String> 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<DmxGeneratedFile> files;

Expand Down
25 changes: 18 additions & 7 deletions src/dmx/src/dartmacros.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<GeneratedFile>> {
let mut files = Vec::new();
for file in reply
Expand All @@ -311,12 +311,9 @@ fn macro_files(reply: &Value) -> Result<Vec<GeneratedFile>> {
) 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 {
Expand All @@ -327,6 +324,20 @@ fn macro_files(reply: &Value) -> Result<Vec<GeneratedFile>> {
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();
Expand Down
145 changes: 100 additions & 45 deletions src/dmx/src/emit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -214,57 +221,105 @@ pub fn seed_of(path: &Path) -> Option<PathBuf> {
.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<bool> {
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<PathBuf> {
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<Vec<PathBuf>> {
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<PathBuf>) -> 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<PathBuf> = 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.
Expand Down
Loading
Loading