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
22 changes: 17 additions & 5 deletions claude-notes/designs/path-resolution-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,23 @@ Three provenance-correct mechanisms coexist today. New code should prefer
not in the per-document `SourceContext` is **stale** since
bd-x113wg9v / bd-fu16z22k / bd-r64mj1aa: `MetadataMergeStage`
registers `_quarto.yml`, the profile overlays, every `_metadata.yml`
and every `_extension.yml` (`metadata_merge.rs:214-266`). Remaining
caveat: a value rebased by `rebase_fragment_paths` keeps its manifest
span while its string is already project-root-relative (bd-0x1znhgb;
the fix decided 2026-10-28 is the leading-`/` spelling, option (a) in
`claude-notes/plans/2026-10-28-config-path-late-binding.md` §4.4).
and every `_extension.yml` (`metadata_merge.rs:214-266`). The former
caveat that a value rebased by `rebase_fragment_paths` kept its
manifest span while its string was already project-root-relative is
**resolved** (bd-0x1znhgb, 2026-10-28): rebased `website.*` strings
carry the site-rooted `/…` spelling (self-describing in URL space),
and an extension's project fragment (`contributes.project`,
`contributes.metadata.project`) is a *virtual file* registered under
`config_paths::project_fragment_file_id` with the manifest's path and
bytes, so `config_paths::file_base_dir` — shared by
`resolve_metadata_path`, `declaring_dir` and the glob provenance —
resolves its values against the project root from the id alone,
while the manifest's other fragments keep the extension dir and
diagnostics still render into `_extension.yml`. (A provenance
wrapper on the value — `Generated` + invocation anchor — was tried
first and rejected: the published error-reporting crate reads
`start_offset()`, which is 0 for `Generated`, so every such
diagnostic lost its snippet.)
2. **`ConfigValueKind::Path` + `adjust_paths_to_project_root`**
(`crates/quarto-core/src/project/mod.rs`; `adjust_paths_to_document_dir`
until bd-okqrjh8v) — merge-time rewrite of `Path`-kind values from the
Expand Down
50 changes: 50 additions & 0 deletions claude-notes/plans/2026-10-28-config-path-late-binding.md
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,56 @@ The `config-path-base` xtask lint with the blessed-module list

Separate strands, after Phase 1 shows which rows still earn their place.

#### Phase 3 handoff (2026-10-28, branch `braid/bd-0x1znhgb-defer-metadata-path-resolution`, stacked on PR #958)

- [x] 3.1 §4.4 option (a): `rebase_fragment_paths` spells a rebased
`FRAGMENT_SITE_PATH_PATTERNS` value (`website.favicon`,
`navbar.logo`, `navbar.logo-href`, `sidebar.logo`) as
`/_extensions/<ext>/…` — self-describing in URL space; every
consumer (`resolve_metadata_path` → `resolve_to_project_root`,
`normalize_favicon_path`, `copy_navbar_logo`,
`resolve_href_for_html`) already strips the `/`. A file outside
the project keeps its OS-absolute form. Visible in `get-config`.
- [x] 3.2 The `_extension.yml` file-name rule in `resolve_metadata_path`
is gone. What replaces it is not a per-value recorded base (see
the note below) but a **virtual file**: the project fragment
(`contributes.project`, `contributes.metadata.project`) is
re-keyed at config time to `config_paths::project_fragment_file_id`
(`rekey_project_fragment`, called from `rebase_fragment_paths`),
and the merge stage / every config-source binder
(`config_sources::config_file_ids`, `register_config_source`,
`bind_config_source`, `project_resources`, `compile_theme_css`)
registers that id with the manifest's path and bytes. One rule,
`config_paths::file_base_dir(id, path, project_dir)`, gives the
project root for a fragment id and the file's directory otherwise;
`declaring_dir`, `resolve_metadata_path` and
`glob::provenance::BaseDirContext::base_dir_for` all call it. So a
fragment `href: index.qmd` (bd-74ktvzv7 finding 2) and a fragment
`listing.contents` glob anchor at the project root, while the
manifest's `contributes.metadata` / `contributes.formats` values
keep resolving against the extension directory.
- [x] 3.3 The Phase-2 half of bd-74ktvzv7 (`Path` values
doc-relativized then page-relativized) is moot since Phase 1b:
marked values are the project-root pivot and are never
document-relativized.
- [ ] 3.4 §4.5 mechanism-3 row review — not started; separate strand
when a row is measured to break a cache key or block a consumer.

**Why not "carry the base on the value".** The first cut wrapped each
fragment leaf's `SourceInfo` in `Generated { by: config-base {base},
from: [Invocation anchor = the authored span] }`. `resolve_byte_range`
and `root_file_id` follow the anchor, but the published
`quarto-error-reporting` renders a location from `start_offset()` /
`length()`, which are `0` for `Generated`, so three CLI tests lost their
`_extension.yml` snippet (`extension_config_spans`,
`render_scripts_cli`). `ConfigValue` has ~216 struct-literal sites, so a
new field was out too. The virtual-file id keeps every span `Original`
and needs no plumbing: the base is a function of the id. If the
source-map crate later gains a first-class resolution base (or
error-reporting learns to render anchored `Generated` spans), the
per-value form becomes available again; `file_base_dir` is the one
place to switch.

### Not in scope

Include-shortcode nested retargeting (bd-udrn0q47; markdown space), listing
Expand Down
182 changes: 168 additions & 14 deletions crates/quarto-core/src/config_paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,22 @@
//! 4. An OS-absolute path (a Windows drive or UNC prefix; on Unix this
//! case is already step 3) is used as written.
//! 5. Otherwise the base is the declaring file's directory, recovered
//! from the value's `SourceInfo` ([`declaring_dir`]). When there is
//! no usable span (a programmatic or runtime-layer value, a file not
//! registered in the context, no context at all) the base is the
//! **document directory**, which is what every consumer assumed
//! before this module existed (plan decision 2, 2026-10-28).
//! from the value's `SourceInfo` ([`declaring_dir`]): the registered
//! file's directory, except for an extension manifest's **project
//! fragment** (`contributes.project`, `contributes.metadata.project`),
//! which is `_quarto.yml` content and resolves against the project
//! root. The fragment is a *virtual file* of its own: at config time
//! its spans are re-keyed to [`project_fragment_file_id`]
//! ([`rekey_project_fragment`]) and the merge registers that id with
//! the manifest's path and content, so diagnostics still render into
//! `_extension.yml` while [`file_base_dir`] knows the base from the
//! id alone (bd-0x1znhgb; before this `resolve_metadata_path`
//! special-cased the manifest's file name, bd-74ktvzv7, which was
//! wrong for the manifest's other fragments). When there is no usable
//! span (a programmatic or runtime-layer value, a file not registered
//! in the context, no context at all) the base is the **document
//! directory**, which is what every consumer assumed before this
//! module existed (plan decision 2, 2026-10-28).
//!
//! The result is normalized lexically (`.`/`..` collapsed) and never
//! canonicalized: the file may not exist, and a missing-file diagnostic
Expand Down Expand Up @@ -343,8 +354,77 @@ fn file(path: PathBuf) -> ResolvedConfigPath {
ResolvedConfigPath::File(quarto_util::normalize_lexically(&path))
}

/// The directory of the file a value was written in, when `source`
/// points at a registered file with a usable (rooted) path.
/// The [`FileId`] of an extension manifest's **project fragment**: the
/// `contributes.project` / `contributes.metadata.project` content of
/// `manifest`, which is merged into the project config and means what
/// it would mean in `_quarto.yml`.
///
/// The fragment is registered as a virtual file with the manifest's
/// path and content (so a diagnostic anchored in it renders a snippet
/// into `_extension.yml`), but under this id rather than the
/// manifest's own `quarto_yaml::file_id_for_filename`, so that
/// [`file_base_dir`] can tell the fragment's values (base: the project
/// root) from the manifest's `contributes.metadata` /
/// `contributes.formats` values (base: the extension directory).
pub fn project_fragment_file_id(manifest: &Path) -> FileId {
quarto_yaml::file_id_for_filename(&format!(
"{}#project",
quarto_util::to_forward_slashes(manifest)
))
}

/// Re-key every span in a project fragment from the manifest's own
/// id to [`project_fragment_file_id`]. Called once, when the fragment
/// is merged into the project config (`project/mod.rs`).
pub fn rekey_project_fragment(fragment: &mut ConfigValue, manifest: &Path) {
let from = quarto_yaml::file_id_for_filename(&manifest.to_string_lossy());
let to = project_fragment_file_id(manifest);
if from == to {
return;
}
let map = move |id: FileId| if id == from { to } else { id };
rekey_config_value(fragment, &map);
}

fn rekey_config_value(value: &mut ConfigValue, map: &impl Fn(FileId) -> FileId) {
value.source_info.remap_file_ids(map);
match &mut value.value {
ConfigValueKind::Map(entries) => {
for entry in entries {
entry.key_source.remap_file_ids(map);
rekey_config_value(&mut entry.value, map);
}
}
ConfigValueKind::Array(items) => {
for item in items {
rekey_config_value(item, map);
}
}
_ => {}
}
}

/// The directory the values of the registered file `id` (at `path`)
/// resolve against: the project root for a project fragment
/// ([`project_fragment_file_id`]), else the file's own directory.
///
/// `None` when the path has no usable (rooted) directory — an
/// in-memory `<anonymous>` document, a bare file name.
pub fn file_base_dir(id: FileId, path: &str, project_dir: &Path) -> Option<PathBuf> {
let path = Path::new(path);
if id == project_fragment_file_id(path) {
return Some(project_dir.to_path_buf());
}
let dir = path.parent()?;
if dir.as_os_str().is_empty() || !quarto_util::is_rooted(dir) {
return None;
}
Some(dir.to_path_buf())
}

/// The directory a value resolves against, from the file its span
/// points into ([`file_base_dir`]): the file's directory, or the
/// project root for an extension's project fragment.
///
/// Returns `None` for programmatic sentinels (`config-default`,
/// `programmatic-config`, `unknown`), for spans with no single byte
Expand All @@ -359,13 +439,9 @@ pub fn declaring_dir(source: &SourceInfo, ctx: &PathCtx<'_>) -> Option<PathBuf>
return None;
}
let (file_id, _, _) = source.resolve_byte_range()?;
let source_file = ctx.source_context?.get_file(FileId(file_id))?;
let path = Path::new(&source_file.path);
let dir = path.parent()?;
if dir.as_os_str().is_empty() || !quarto_util::is_rooted(dir) {
return None;
}
Some(dir.to_path_buf())
let file_id = FileId(file_id);
let source_file = ctx.source_context?.get_file(file_id)?;
file_base_dir(file_id, &source_file.path, ctx.project_dir)
}

#[cfg(test)]
Expand Down Expand Up @@ -520,6 +596,84 @@ mod tests {
}
}

/// bd-0x1znhgb: an extension manifest's project fragment is a
/// virtual file of its own — same path and bytes, its own id — whose
/// values resolve against the project root, while the manifest's
/// other fragments keep resolving against the extension directory.
#[test]
fn project_fragment_resolves_against_the_project_root() {
let f = fixture();
let d = dirs("/project", "/project/docs/deep");
let c = ctx(&f, &d);
let manifest = "/project/_extensions/x/_extension.yml";
let plain = quarto_yaml::file_id_for_filename(manifest);
let fragment = project_fragment_file_id(Path::new(manifest));
assert_ne!(plain, fragment);
let mut sc = f.sc.clone();
sc.add_file_with_id(plain, manifest.to_string(), Some(String::new()));
sc.add_file_with_id(fragment, manifest.to_string(), Some(String::new()));
let c = PathCtx {
source_context: Some(&sc),
..c
};
assert_eq!(
resolve("index.qmd", span(fragment), &c),
ResolvedConfigPath::File(p("/project/index.qmd")),
"a project-fragment value means what it would in _quarto.yml"
);
assert_eq!(
resolve("scripts/x.lua", span(plain), &c),
ResolvedConfigPath::File(p("/project/_extensions/x/scripts/x.lua")),
"a contributes.metadata value names a bundled file"
);
}

#[test]
fn rekey_project_fragment_moves_every_span_to_the_fragment_id() {
let manifest = Path::new("/project/_extensions/x/_extension.yml");
let plain = quarto_yaml::file_id_for_filename(&manifest.to_string_lossy());
let other = FileId(7);
let leaf = |id: FileId| ConfigValue::new_string("v", SourceInfo::original(id, 0, 1));
let mut value = ConfigValue::new_map(
vec![
quarto_pandoc_types::ConfigMapEntry {
key: "a".into(),
key_source: SourceInfo::original(plain, 0, 1),
value: leaf(plain),
},
quarto_pandoc_types::ConfigMapEntry {
key: "b".into(),
key_source: SourceInfo::original(other, 0, 1),
value: ConfigValue {
value: ConfigValueKind::Array(vec![leaf(plain), leaf(other)]),
source_info: SourceInfo::original(plain, 0, 1),
merge_op: Default::default(),
},
},
],
SourceInfo::original(plain, 0, 1),
);
rekey_project_fragment(&mut value, manifest);
let fragment = project_fragment_file_id(manifest);
let id_of = |v: &ConfigValue| v.source_info.root_file_id().unwrap();
assert_eq!(id_of(&value), fragment);
assert_eq!(id_of(value.get("a").unwrap()), fragment);
let ConfigValueKind::Map(entries) = &value.value else {
panic!("map")
};
assert_eq!(entries[0].key_source.root_file_id(), Some(fragment));
assert_eq!(
entries[1].key_source.root_file_id(),
Some(other),
"other files untouched"
);
let ConfigValueKind::Array(items) = &value.get("b").unwrap().value else {
panic!("array")
};
assert_eq!(id_of(&items[0]), fragment);
assert_eq!(id_of(&items[1]), other);
}

#[test]
fn programmatic_sentinel_falls_back_to_the_document_dir() {
let f = fixture();
Expand Down
34 changes: 26 additions & 8 deletions crates/quarto-core/src/config_sources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,24 @@ pub fn bind_config_source<'a>(
bind_source_candidates(
source_context,
info,
candidates.into_iter().map(|p| {
let fid = quarto_yaml::file_id_for_filename(&p.to_string_lossy());
(fid, p)
}),
candidates.into_iter().flat_map(config_file_ids),
)
}

/// The ids a config file's spans may carry: its own
/// `quarto_yaml::file_id_for_filename`, and for an extension manifest
/// also its project fragment's
/// ([`crate::config_paths::project_fragment_file_id`], bd-0x1znhgb).
/// Each is paired with the path so a binder can register it.
pub fn config_file_ids(path: &Path) -> impl Iterator<Item = (FileId, &Path)> {
let own = quarto_yaml::file_id_for_filename(&path.to_string_lossy());
let fragment = path
.file_name()
.is_some_and(|name| name == crate::project::EXTENSION_MANIFEST)
.then(|| crate::config_paths::project_fragment_file_id(path));
std::iter::once((own, path)).chain(fragment.map(|id| (id, path)))
}

/// Generalization of [`bind_config_source`] for candidate lists that
/// span **both** FileId schemes: quarto-yaml filename-hash ids for
/// standalone config files, and dense parse-context ids for documents
Expand Down Expand Up @@ -181,15 +192,22 @@ pub fn rebase_source_candidates<'a>(
/// candidates by a diagnostic's resolved id). Returns `true` when the
/// file was registered (or already present).
pub fn register_config_source(source_context: &mut SourceContext, path: &Path) -> bool {
let name = path.to_string_lossy();
let fid = quarto_yaml::file_id_for_filename(&name);
if source_context.get_file(fid).is_some() {
// Every id the file's spans may carry ([`config_file_ids`]): an
// extension manifest's project fragment is registered alongside the
// manifest itself, under the same path and content.
let ids: Vec<FileId> = config_file_ids(path).map(|(id, _)| id).collect();
if ids.iter().all(|id| source_context.get_file(*id).is_some()) {
return true;
}
let Ok(content) = std::fs::read_to_string(path) else {
return false;
};
source_context.add_file_with_id(fid, name.into_owned(), Some(content));
let name = path.to_string_lossy().into_owned();
for id in ids {
if source_context.get_file(id).is_none() {
source_context.add_file_with_id(id, name.clone(), Some(content.clone()));
}
}
true
}

Expand Down
Loading
Loading