From e4b0c8f30141a6b92bed07546d9ef38793e0bed2 Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Tue, 27 Oct 2026 17:05:07 -0500 Subject: [PATCH] bd-irkfpip1: env shortcode reads the per-render environment snapshot The env shortcode handler merged QUARTO_PROFILE, the real environment and _environment file values at every lookup, while Lua filters and shortcode scripts read os.getenv from the snapshot StageContext::new takes once per document (GH #939). Two sources for one environment can drift and the handler's std::env::var ran at resolve time on a worker thread. ShortcodeResolveTransform now carries the snapshot: with_lua_support and the two pipeline builders take an Option> in place of the project_env + quarto_profile pair, the env handler holds the same Arc, and the Lua engine's HostContext takes it from the transform. That made the RenderContext.env_snapshot bridge unnecessary, so it is gone. Without a snapshot (custom pipelines, unit tests) the handler reads the real process environment, as before. All 47 builder call sites had the same trailing-argument shape and were rewritten mechanically. Handler tests now drive the snapshot directly instead of mutating the process environment, except the one that pins the no-snapshot fallback. Co-Authored-By: Claude Fable 5.1 --- ...2026-10-27-lua-api-q1-parity-gh-939-942.md | 13 +- crates/quarto-core/src/pipeline.rs | 42 +-- crates/quarto-core/src/render.rs | 8 - .../src/stage/stages/ast_transforms.rs | 13 +- .../src/transforms/shortcode_resolve.rs | 326 ++++++++---------- .../tests/integration/pandoc_profile_cut.rs | 2 - docs/guides/authoring/lua-filters.qmd | 4 +- 7 files changed, 155 insertions(+), 253 deletions(-) diff --git a/claude-notes/plans/2026-10-27-lua-api-q1-parity-gh-939-942.md b/claude-notes/plans/2026-10-27-lua-api-q1-parity-gh-939-942.md index 0bd535197b..26e8fe1f19 100644 --- a/claude-notes/plans/2026-10-27-lua-api-q1-parity-gh-939-942.md +++ b/claude-notes/plans/2026-10-27-lua-api-q1-parity-gh-939-942.md @@ -368,9 +368,6 @@ branch off one integration branch (`feature/lua-api-q1-parity`). - **The rest of `PANDOC_STATE`** (`resource_path`, `verbosity`, `log`, `trace`, `user_data_dir`, `request_headers`, `source_url`). The minimal two-field version is in #940; the rest has no q2 equivalent to report. -- **`EnvShortcodeHandler` on the environment snapshot** instead of its - own lookup-time merge (one consistent environment for shortcodes and - filters alike). - **Exposing the full version string** (nightlies lose `-nightly.YYYYMMDD` in the numeric Version). Q1 does not expose it either. @@ -399,6 +396,16 @@ branch off one integration branch (`feature/lua-api-q1-parity`). Flag for a Windows run in the PR. - Five quarto-core unit tests fail under plain `cargo test` but pass under nextest (process isolation); unrelated to this change. +- **Follow-up, bd-irkfpip1 (stacked PR):** `EnvShortcodeHandler` reads + the same snapshot. `ShortcodeResolveTransform::with_lua_support` and + the two pipeline builders take an `Option>` in place + of the `project_env` + `quarto_profile` pair (47 call sites, all with + the same trailing `Default::default(), None` shape, rewritten by + script); the transform hands the snapshot to both the `env` handler + and the Lua engine's `HostContext`, which made the `RenderContext. + env_snapshot` bridge from the first PR unnecessary, so it was removed. + Without a snapshot (custom pipelines, unit tests) the handler reads the + real process environment, as before. ## Risks diff --git a/crates/quarto-core/src/pipeline.rs b/crates/quarto-core/src/pipeline.rs index 31c7ad792d..0260c4f74a 100644 --- a/crates/quarto-core/src/pipeline.rs +++ b/crates/quarto-core/src/pipeline.rs @@ -2266,8 +2266,7 @@ pub fn build_transform_pipeline( target_format: String, pipeline_profile: crate::format::PipelineProfile, variables: Option, - project_env: hashlink::LinkedHashMap, - quarto_profile: Option, + env_snapshot: Option>, ) -> TransformPipeline { let mut pipeline: TransformPipeline = TransformPipeline::new(); @@ -2326,8 +2325,7 @@ pub fn build_transform_pipeline( runtime.clone(), lua_format, variables, - project_env, - quarto_profile, + env_snapshot, ))); // Heading ids follow the *final* text: shortcode expansion just above // and lmd cells (`lmd-execute`, before the transforms) both rewrite @@ -3213,8 +3211,7 @@ pub fn build_q2_preview_transform_pipeline( target_format: String, pipeline_profile: crate::format::PipelineProfile, variables: Option, - project_env: hashlink::LinkedHashMap, - quarto_profile: Option, + env_snapshot: Option>, ) -> TransformPipeline { let mut pipeline = build_transform_pipeline( shortcode_paths, @@ -3223,8 +3220,7 @@ pub fn build_q2_preview_transform_pipeline( target_format, pipeline_profile, variables, - project_env, - quarto_profile, + env_snapshot, ); pipeline.retain_excluding(Q2_PREVIEW_TRANSFORM_EXCLUDED); pipeline @@ -4472,7 +4468,6 @@ mod tests { "html".to_string(), crate::format::PipelineProfile::HtmlRender, None, - Default::default(), None, ); let html_names: Vec<&str> = html.iter().map(|t| t.name()).collect(); @@ -5194,7 +5189,6 @@ mod tests { "q2-preview".to_string(), crate::format::PipelineProfile::HtmlPreview, None, - Default::default(), None, ); let names: Vec<&str> = pipeline.iter().map(|t| t.name()).collect(); @@ -5220,7 +5214,6 @@ mod tests { "q2-preview".to_string(), crate::format::PipelineProfile::HtmlPreview, None, - Default::default(), None, ); let names: Vec<&str> = pipeline.iter().map(|t| t.name()).collect(); @@ -5275,7 +5268,6 @@ mod tests { "html".to_string(), crate::format::PipelineProfile::HtmlRender, None, - Default::default(), None, ); let names: Vec<&str> = pipeline.iter().map(|t| t.name()).collect(); @@ -5333,7 +5325,6 @@ mod tests { "html".to_string(), crate::format::PipelineProfile::HtmlRender, None, - Default::default(), None, ); let found = pipeline @@ -5366,7 +5357,6 @@ mod tests { "html".to_string(), crate::format::PipelineProfile::HtmlRender, None, - Default::default(), None, ); let names: Vec<&str> = pipeline.iter().map(|t| t.name()).collect(); @@ -5408,7 +5398,6 @@ mod tests { "html".to_string(), crate::format::PipelineProfile::HtmlRender, None, - Default::default(), None, ); let names: Vec<&str> = pipeline.iter().map(|t| t.name()).collect(); @@ -5485,7 +5474,6 @@ mod tests { format.to_string(), crate::format::PipelineProfile::from_format(format), None, - Default::default(), None, ); let steps: Vec<(&str, TransformPhase)> = @@ -5567,7 +5555,6 @@ mod tests { "html".to_string(), crate::format::PipelineProfile::HtmlRender, None, - Default::default(), None, ); @@ -5599,7 +5586,6 @@ mod tests { "html".to_string(), crate::format::PipelineProfile::HtmlRender, None, - Default::default(), None, ); let expected: Vec<&str> = pipeline @@ -5632,7 +5618,6 @@ mod tests { "html".to_string(), crate::format::PipelineProfile::HtmlRender, None, - Default::default(), None, ); let expected: Vec<&str> = pipeline @@ -5677,7 +5662,6 @@ mod tests { "html".to_string(), crate::format::PipelineProfile::HtmlRender, None, - Default::default(), None, ); let project = make_test_project(); @@ -5846,7 +5830,6 @@ mod tests { "typst".to_string(), crate::format::PipelineProfile::from_format("typst"), None, - Default::default(), None, ); let expected: Vec<&str> = pipeline @@ -6355,7 +6338,6 @@ mod tests { "html".to_string(), crate::format::PipelineProfile::HtmlRender, None, - Default::default(), None, ); let names: Vec<&str> = pipeline.iter().map(|t| t.name()).collect(); @@ -6394,7 +6376,6 @@ mod tests { "q2-preview".to_string(), crate::format::PipelineProfile::HtmlPreview, None, - Default::default(), None, ); let names: Vec<&str> = pipeline.iter().map(|t| t.name()).collect(); @@ -6422,7 +6403,6 @@ mod tests { format.to_string(), crate::format::PipelineProfile::from_format(format), None, - Default::default(), None, ); let names: Vec<&str> = pipeline.iter().map(|t| t.name()).collect(); @@ -6460,7 +6440,6 @@ mod tests { "html".to_string(), crate::format::PipelineProfile::HtmlRender, None, - Default::default(), None, ); let html_names: Vec<&str> = html_pipeline.iter().map(|t| t.name()).collect(); @@ -6480,7 +6459,6 @@ mod tests { "docx".to_string(), crate::format::PipelineProfile::Pandoc("docx".to_string()), None, - Default::default(), None, ); let docx_names: Vec<&str> = docx_pipeline.iter().map(|t| t.name()).collect(); @@ -6513,7 +6491,6 @@ mod tests { "docx".to_string(), crate::format::PipelineProfile::Pandoc("docx".to_string()), None, - Default::default(), None, ); let docx_names: Vec<&str> = docx_pipeline.iter().map(|t| t.name()).collect(); @@ -6545,7 +6522,6 @@ mod tests { format.to_string(), crate::format::PipelineProfile::from_format(format), None, - Default::default(), None, ); let names: Vec<&str> = pipeline.iter().map(|t| t.name()).collect(); @@ -6570,7 +6546,6 @@ mod tests { "html".to_string(), crate::format::PipelineProfile::HtmlRender, None, - Default::default(), None, ); let names: Vec<&str> = pipeline.iter().map(|t| t.name()).collect(); @@ -6667,7 +6642,6 @@ mod tests { "revealjs".to_string(), crate::format::PipelineProfile::RevealjsRender, None, - Default::default(), None, ); let names: Vec<&str> = pipeline.iter().map(|t| t.name()).collect(); @@ -6777,7 +6751,6 @@ mod tests { "html".to_string(), crate::format::PipelineProfile::HtmlRender, None, - Default::default(), None, ); let html_names: Vec<&str> = html.iter().map(|t| t.name()).collect(); @@ -6816,7 +6789,6 @@ mod tests { "html".to_string(), crate::format::PipelineProfile::HtmlRender, None, - Default::default(), None, ); let nav_names: Vec<&str> = html @@ -6858,7 +6830,6 @@ mod tests { "docx".to_string(), crate::format::PipelineProfile::Pandoc("docx".to_string()), None, - Default::default(), None, ); let names: Vec<&str> = pipeline.iter().map(|t| t.name()).collect(); @@ -6925,7 +6896,6 @@ mod tests { "html".to_string(), crate::format::PipelineProfile::HtmlRender, None, - Default::default(), None, ); let names: Vec<&str> = pipeline.iter().map(|t| t.name()).collect(); @@ -6988,7 +6958,6 @@ mod tests { "docx".to_string(), crate::format::PipelineProfile::Pandoc("docx".to_string()), None, - Default::default(), None, ); @@ -7035,7 +7004,6 @@ mod tests { "q2-preview".to_string(), crate::format::PipelineProfile::HtmlPreview, None, - Default::default(), None, ); let names: Vec<&str> = pipeline.iter().map(|t| t.name()).collect(); @@ -7047,7 +7015,6 @@ mod tests { "q2-preview".to_string(), crate::format::PipelineProfile::HtmlPreview, None, - Default::default(), None, ); let preview_names: Vec<&str> = preview_pipeline.iter().map(|t| t.name()).collect(); @@ -7523,7 +7490,6 @@ mod tests { "html".to_string(), crate::format::PipelineProfile::HtmlRender, None, - Default::default(), None, ); let transform_names: Vec<&str> = html_transforms.iter().map(|t| t.name()).collect(); diff --git a/crates/quarto-core/src/render.rs b/crates/quarto-core/src/render.rs index 3cbf225d43..bf05f7b526 100644 --- a/crates/quarto-core/src/render.rs +++ b/crates/quarto-core/src/render.rs @@ -243,13 +243,6 @@ pub struct RenderContext<'a> { /// Bridged to/from `StageContext` by `AstTransformsStage`. pub includes: PandocIncludes, - /// The environment Lua shortcode handlers see through `os.getenv` - /// (GH #939): [`crate::stage::StageContext::env_snapshot`], bridged - /// one-way by `AstTransformsStage`. `None` when a `RenderContext` is - /// built directly (unit tests, out-of-band drivers); the Lua `os` - /// table is then left as the stdlib / runtime provides it. - pub env_snapshot: Option>, - /// Diagnostics (warnings, errors, info) collected during transforms pub diagnostics: Vec, @@ -613,7 +606,6 @@ impl<'a> RenderContext<'a> { binaries, options: RenderOptions::default(), includes: PandocIncludes::default(), - env_snapshot: None, diagnostics: Vec::new(), ref_type_registry: None, crossref_index: None, diff --git a/crates/quarto-core/src/stage/stages/ast_transforms.rs b/crates/quarto-core/src/stage/stages/ast_transforms.rs index 36f3110b29..a524aac484 100644 --- a/crates/quarto-core/src/stage/stages/ast_transforms.rs +++ b/crates/quarto-core/src/stage/stages/ast_transforms.rs @@ -181,10 +181,7 @@ impl PipelineStage for AstTransformsStage { ctx.format.target_format.clone(), pipeline_profile, ctx.variables.clone(), - ctx.project_env.clone(), - crate::project::project_profile::quarto_profile_env_value( - &ctx.project.config.active_config_profiles, - ), + Some(ctx.env_snapshot.clone()), ) } _ => build_transform_pipeline( @@ -194,10 +191,7 @@ impl PipelineStage for AstTransformsStage { ctx.format.target_format.clone(), pipeline_profile, ctx.variables.clone(), - ctx.project_env.clone(), - crate::project::project_profile::quarto_profile_env_value( - &ctx.project.config.active_config_profiles, - ), + Some(ctx.env_snapshot.clone()), ), }; &jit_pipeline @@ -222,9 +216,6 @@ impl PipelineStage for AstTransformsStage { // Transfer mutable state to the RenderContext render_ctx.artifacts = std::mem::take(&mut ctx.artifacts); render_ctx.includes = std::mem::take(&mut ctx.includes); - // The environment Lua shortcodes read via os.getenv (GH #939); - // read-only, so the Arc is cloned rather than moved. - render_ctx.env_snapshot = Some(ctx.env_snapshot.clone()); render_ctx.ref_type_registry = ctx.ref_type_registry.take(); render_ctx.crossref_index = ctx.crossref_index.take(); render_ctx.observer = ctx.observer.clone(); diff --git a/crates/quarto-core/src/transforms/shortcode_resolve.rs b/crates/quarto-core/src/transforms/shortcode_resolve.rs index 5316e412d1..edc5a8ae9a 100644 --- a/crates/quarto-core/src/transforms/shortcode_resolve.rs +++ b/crates/quarto-core/src/transforms/shortcode_resolve.rs @@ -54,6 +54,7 @@ use quarto_analysis::AnalysisContext; use crate::Result; use crate::extension::types::Extension; +use crate::project::environment::EnvSnapshot; use crate::render::RenderContext; use crate::transform::{AstTransform, TransformPhase}; use quarto_system_runtime::SystemRuntime; @@ -182,34 +183,26 @@ fn positional_arg_to_string(arg: &ShortcodeArg) -> Option { /// Built-in handler for `{{< env NAME >}}` / `{{< env NAME fallback >}}`. /// -/// Resolution order: the real process environment, then the project's -/// `_environment` files (loaded into `project_env` by -/// [`crate::project::environment::load_project_environment`] — q2 -/// never mutates the process env, so file values arrive as data), then -/// the optional second positional argument (Q1 1.5, #8316). Unset -/// everywhere with no fallback is a Q-16-5 warning (Q1 warned and -/// emitted `?env`). On wasm32 `std::env::var` always errs, so only the -/// project env map (loaded through the VFS) and fallbacks apply there. +/// Reads the render's environment snapshot +/// ([`crate::stage::StageContext::env_snapshot`], built once per document +/// by [`crate::project::environment::snapshot_environment`]): the real +/// process environment, then the project's `_environment` files for keys +/// the real environment lacks, then `QUARTO_PROFILE` forced to the +/// normalized active profile list (bd-fu16z22k) — the same map Lua +/// filters and shortcode scripts see through `os.getenv`, so the two can +/// never disagree. The optional second positional argument is the +/// fallback (Q1 1.5, #8316); unset everywhere with no fallback is a +/// Q-16-5 warning (Q1 warned and emitted `?env`). +/// +/// Without a snapshot (a pipeline built outside a `StageContext`, as in +/// unit tests) the handler reads the real process environment directly. pub struct EnvShortcodeHandler { - project_env: hashlink::LinkedHashMap, - /// Normalized active project-profile list for `QUARTO_PROFILE` - /// lookups (bd-fu16z22k). Checked BEFORE the real environment: - /// after `--profile b` replaced `QUARTO_PROFILE=a`, the shortcode - /// must resolve `b` — Q1 guaranteed this by writing the - /// normalized list back into the env; q2 never mutates its env, - /// so the handler carries the value as data instead. - quarto_profile: Option, + env: Option>, } impl EnvShortcodeHandler { - pub fn new( - project_env: hashlink::LinkedHashMap, - quarto_profile: Option, - ) -> Self { - Self { - project_env, - quarto_profile, - } + pub fn new(env: Option>) -> Self { + Self { env } } } @@ -240,17 +233,16 @@ impl ShortcodeHandler for EnvShortcodeHandler { }); }; - let value = (name == crate::project::project_profile::QUARTO_PROFILE_VAR) - .then(|| self.quarto_profile.clone()) - .flatten() - .or_else(|| std::env::var(&name).ok()) - .or_else(|| self.project_env.get(&name).cloned()) - .or_else(|| { - shortcode - .positional_args - .get(1) - .and_then(positional_arg_to_string) - }); + let value = match &self.env { + Some(env) => env.get(&name).cloned(), + None => std::env::var(&name).ok(), + } + .or_else(|| { + shortcode + .positional_args + .get(1) + .and_then(positional_arg_to_string) + }); match value { Some(text) => ShortcodeResult::Inlines(vec![Inline::Str(Str { @@ -470,6 +462,10 @@ pub struct ShortcodeResolveTransform { extensions: Vec, /// System runtime for reading Lua files runtime: Option>, + /// The render's environment snapshot, for the Lua shortcode engine's + /// `os.getenv` (through `HostContext`). The `env` handler holds the + /// same `Arc`. `None` outside a `StageContext`-driven render. + env_snapshot: Option>, /// Target format string (e.g., "html") for Lua FORMAT global target_format: String, } @@ -479,25 +475,25 @@ impl ShortcodeResolveTransform { /// Used in tests that don't need Lua support. pub fn new() -> Self { Self { - handlers: Self::builtin_handlers(None, hashlink::LinkedHashMap::new(), None), + handlers: Self::builtin_handlers(None, None), lua_shortcode_paths: Vec::new(), extensions: Vec::new(), runtime: None, target_format: String::new(), + env_snapshot: None, } } - /// The built-in Rust handlers: `meta`, `env` (fed by the project's - /// `_environment` files), and `var` (fed by the project's + /// The built-in Rust handlers: `meta`, `env` (fed by the render's + /// environment snapshot), and `var` (fed by the project's /// `_variables.yml`, when present). fn builtin_handlers( variables: Option, - project_env: hashlink::LinkedHashMap, - quarto_profile: Option, + env: Option>, ) -> Vec> { vec![ Box::new(MetaShortcodeHandler), - Box::new(EnvShortcodeHandler::new(project_env, quarto_profile)), + Box::new(EnvShortcodeHandler::new(env)), Box::new(VarShortcodeHandler::new(variables)), ] } @@ -512,15 +508,15 @@ impl ShortcodeResolveTransform { runtime: Arc, target_format: String, variables: Option, - project_env: hashlink::LinkedHashMap, - quarto_profile: Option, + env_snapshot: Option>, ) -> Self { Self { - handlers: Self::builtin_handlers(variables, project_env, quarto_profile), + handlers: Self::builtin_handlers(variables, env_snapshot.clone()), lua_shortcode_paths, extensions, runtime: Some(runtime), target_format, + env_snapshot, } } @@ -1233,7 +1229,7 @@ impl AstTransform for ShortcodeResolveTransform { runtime.as_ref(), ctx.document, ctx.output_path(), - ctx.env_snapshot.clone(), + self.env_snapshot.clone(), ); match pampa::lua::LuaShortcodeEngine::new_with( &self.target_format, @@ -2454,53 +2450,63 @@ mod tests { } } - #[test] - fn test_env_shortcode_handler_set_and_fallback() { - let handler = EnvShortcodeHandler::new(Default::default(), None); - let meta = ConfigValue::new_map(vec![], dummy_source_info()); + fn snapshot(pairs: &[(&str, &str)]) -> Option> { + Some(Arc::new( + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + )) + } - // Set variable wins over fallback. - // SAFETY: test-local variable name; nextest runs each test in its - // own process, so no cross-test env races. - unsafe { std::env::set_var("QUARTO_TEST_ENV_SC", "env-value") }; - let shortcode = make_shortcode("env", vec!["QUARTO_TEST_ENV_SC", "fallback"]); + fn resolve_env(handler: &EnvShortcodeHandler, args: Vec<&str>) -> ShortcodeResult { + let meta = ConfigValue::new_map(vec![], dummy_source_info()); + let shortcode = make_shortcode("env", args); let ctx = ShortcodeContext { metadata: &meta, source_info: &shortcode.source_info, }; - match handler.resolve(&shortcode, &ctx, ResolutionContext::Inline) { - ShortcodeResult::Inlines(inlines) => { - let Inline::Str(s) = &inlines[0] else { - panic!("expected Str"); - }; - assert_eq!(s.text, "env-value"); - } - other => panic!("Expected Inlines, got {:?}", std::mem::discriminant(&other)), - } + handler.resolve(&shortcode, &ctx, ResolutionContext::Inline) + } - // Unset variable falls back to the second positional arg (Q1 1.5). - let shortcode = make_shortcode("env", vec!["QUARTO_TEST_ENV_SC_UNSET", "fallback"]); - let ctx = ShortcodeContext { - metadata: &meta, - source_info: &shortcode.source_info, - }; - match handler.resolve(&shortcode, &ctx, ResolutionContext::Inline) { + fn expect_str(result: ShortcodeResult) -> String { + match result { ShortcodeResult::Inlines(inlines) => { let Inline::Str(s) = &inlines[0] else { panic!("expected Str"); }; - assert_eq!(s.text, "fallback"); + s.text.clone() } - _ => panic!("Expected Inlines"), + other => panic!("Expected Inlines, got {:?}", std::mem::discriminant(&other)), } + } - // Unset without fallback: Q-16-5 error. - let shortcode = make_shortcode("env", vec!["QUARTO_TEST_ENV_SC_UNSET"]); - let ctx = ShortcodeContext { - metadata: &meta, - source_info: &shortcode.source_info, - }; - match handler.resolve(&shortcode, &ctx, ResolutionContext::Inline) { + #[test] + fn test_env_shortcode_handler_set_and_fallback() { + // The snapshot is the whole environment: a key in it wins over the + // fallback, a key missing from it falls back (Q1 1.5), and missing + // with no fallback is the Q-16-5 error. The real process + // environment is never consulted when a snapshot is present. + let handler = EnvShortcodeHandler::new(snapshot(&[("QUARTO_TEST_ENV_SC", "env-value")])); + assert_eq!( + expect_str(resolve_env( + &handler, + vec!["QUARTO_TEST_ENV_SC", "fallback"] + )), + "env-value" + ); + assert_eq!( + expect_str(resolve_env( + &handler, + vec!["QUARTO_TEST_ENV_SC_UNSET", "fallback"] + )), + "fallback" + ); + assert_eq!( + expect_str(resolve_env(&handler, vec!["PATH", "not-from-the-process"])), + "not-from-the-process" + ); + match resolve_env(&handler, vec!["QUARTO_TEST_ENV_SC_UNSET"]) { ShortcodeResult::Error(err) => { assert_eq!(err.key, "env:QUARTO_TEST_ENV_SC_UNSET"); assert!(format!("{:?}", err.diagnostic).contains("Q-16-5")); @@ -2510,113 +2516,58 @@ mod tests { } #[test] - fn test_env_shortcode_quarto_profile_beats_real_env() { - // bd-fu16z22k: `{{< env QUARTO_PROFILE >}}` must resolve the - // NORMALIZED active list, even when the real environment - // still holds the raw pre-`--profile` value. - // SAFETY: test-local; nextest runs each test in its own process. - unsafe { std::env::set_var("QUARTO_PROFILE", "stale-raw-value") }; - let handler = - EnvShortcodeHandler::new(Default::default(), Some("advanced,production".to_string())); - let meta = ConfigValue::new_map(vec![], dummy_source_info()); - let shortcode = make_shortcode("env", vec!["QUARTO_PROFILE"]); - let ctx = ShortcodeContext { - metadata: &meta, - source_info: &shortcode.source_info, - }; - match handler.resolve(&shortcode, &ctx, ResolutionContext::Inline) { - ShortcodeResult::Inlines(inlines) => { - let Inline::Str(s) = &inlines[0] else { - panic!("expected Str"); - }; - assert_eq!(s.text, "advanced,production"); - } - _ => panic!("Expected Inlines"), - } - - // With no active profiles the special case is inert: the - // real environment value shows through unchanged. - let handler = EnvShortcodeHandler::new(Default::default(), None); - let shortcode = make_shortcode("env", vec!["QUARTO_PROFILE"]); - let ctx = ShortcodeContext { - metadata: &meta, - source_info: &shortcode.source_info, - }; - match handler.resolve(&shortcode, &ctx, ResolutionContext::Inline) { - ShortcodeResult::Inlines(inlines) => { - let Inline::Str(s) = &inlines[0] else { - panic!("expected Str"); - }; - assert_eq!(s.text, "stale-raw-value"); - } - _ => panic!("Expected Inlines"), - } + fn test_env_shortcode_snapshot_carries_profile_and_file_values() { + // The layering (real env beats `_environment`, `QUARTO_PROFILE` is + // the normalized active list) is `snapshot_environment`'s job and + // is tested there; the handler just reads what it was given. + let snap = Arc::new(crate::project::environment::snapshot_environment( + [("QUARTO_PROFILE".to_string(), "stale-raw-value".to_string())] + .into_iter() + .collect(), + &[( + "QUARTO_TEST_ENVFILE_ONLY".to_string(), + "from-file".to_string(), + )] + .into_iter() + .collect(), + Some("advanced,production"), + )); + let handler = EnvShortcodeHandler::new(Some(snap)); + assert_eq!( + expect_str(resolve_env(&handler, vec!["QUARTO_PROFILE"])), + "advanced,production" + ); + assert_eq!( + expect_str(resolve_env( + &handler, + vec!["QUARTO_TEST_ENVFILE_ONLY", "fallback"] + )), + "from-file" + ); } #[test] - fn test_env_shortcode_handler_project_env() { - let mut project_env = hashlink::LinkedHashMap::new(); - project_env.insert( - "QUARTO_TEST_ENVFILE_ONLY".to_string(), - "from-file".to_string(), + fn test_env_shortcode_without_snapshot_reads_the_process_environment() { + // No `StageContext` (unit tests, custom pipelines): the real + // environment is the only source, as before the snapshot existed. + // SAFETY: test-local variable name; nextest runs each test in its + // own process, so no cross-test env races. + unsafe { std::env::set_var("QUARTO_TEST_ENV_SC_NOSNAP", "from-real-env") }; + let handler = EnvShortcodeHandler::new(None); + assert_eq!( + expect_str(resolve_env( + &handler, + vec!["QUARTO_TEST_ENV_SC_NOSNAP", "fallback"] + )), + "from-real-env" ); - project_env.insert( - "QUARTO_TEST_ENVFILE_SHADOWED".to_string(), - "from-file".to_string(), + assert_eq!( + expect_str(resolve_env( + &handler, + vec!["QUARTO_TEST_ENV_SC_NOSNAP_UNSET", "fb"] + )), + "fb" ); - let handler = EnvShortcodeHandler::new(project_env, None); - let meta = ConfigValue::new_map(vec![], dummy_source_info()); - - // Defined only in the project env map: map value used (even - // when a fallback arg is present — the map is a definition, - // not a fallback). - let shortcode = make_shortcode("env", vec!["QUARTO_TEST_ENVFILE_ONLY", "fallback"]); - let ctx = ShortcodeContext { - metadata: &meta, - source_info: &shortcode.source_info, - }; - match handler.resolve(&shortcode, &ctx, ResolutionContext::Inline) { - ShortcodeResult::Inlines(inlines) => { - let Inline::Str(s) = &inlines[0] else { - panic!("expected Str"); - }; - assert_eq!(s.text, "from-file"); - } - other => panic!("Expected Inlines, got {:?}", std::mem::discriminant(&other)), - } - - // Defined in both the real env and the map: the real - // environment wins (q2 never lets file values shadow it). - // SAFETY: test-local variable name; nextest runs each test in - // its own process, so no cross-test env races. - unsafe { std::env::set_var("QUARTO_TEST_ENVFILE_SHADOWED", "from-real-env") }; - let shortcode = make_shortcode("env", vec!["QUARTO_TEST_ENVFILE_SHADOWED"]); - let ctx = ShortcodeContext { - metadata: &meta, - source_info: &shortcode.source_info, - }; - match handler.resolve(&shortcode, &ctx, ResolutionContext::Inline) { - ShortcodeResult::Inlines(inlines) => { - let Inline::Str(s) = &inlines[0] else { - panic!("expected Str"); - }; - assert_eq!(s.text, "from-real-env"); - } - _ => panic!("Expected Inlines"), - } - - // Absent everywhere: still the Q-16-5 error. - let shortcode = make_shortcode("env", vec!["QUARTO_TEST_ENVFILE_ABSENT"]); - let ctx = ShortcodeContext { - metadata: &meta, - source_info: &shortcode.source_info, - }; - match handler.resolve(&shortcode, &ctx, ResolutionContext::Inline) { - ShortcodeResult::Error(err) => { - assert!(format!("{:?}", err.diagnostic).contains("Q-16-5")); - } - _ => panic!("Expected Error"), - } } #[test] @@ -2966,6 +2917,7 @@ mod tests { extensions: Vec::new(), runtime: None, target_format: String::new(), + env_snapshot: None, }; let mut ast = Pandoc { @@ -3001,6 +2953,7 @@ mod tests { extensions: Vec::new(), runtime: None, target_format: String::new(), + env_snapshot: None, }; // Para with text + shortcode — not a block-context shortcode @@ -3070,6 +3023,7 @@ mod tests { extensions: Vec::new(), runtime: None, target_format: String::new(), + env_snapshot: None, }; // Para with text + shortcode — inline context, handler returns Blocks @@ -3116,6 +3070,7 @@ mod tests { extensions: Vec::new(), runtime: None, target_format: String::new(), + env_snapshot: None, }; // Escaped shortcode alone in Para — should preserve as literal @@ -3515,7 +3470,6 @@ mod tests { runtime, "html".to_string(), None, - hashlink::LinkedHashMap::new(), None, ); @@ -3593,7 +3547,6 @@ mod tests { runtime, "html".to_string(), None, - Default::default(), None, ); @@ -3647,7 +3600,6 @@ mod tests { make_runtime(), "html".to_string(), None, - Default::default(), None, ); let mut ast = Pandoc { @@ -3705,7 +3657,6 @@ mod tests { runtime, "html".to_string(), None, - Default::default(), None, ); @@ -3754,7 +3705,6 @@ mod tests { runtime, "html".to_string(), None, - Default::default(), None, ); @@ -3803,7 +3753,6 @@ mod tests { runtime, "html".to_string(), None, - Default::default(), None, ); @@ -3858,7 +3807,6 @@ mod tests { runtime, "html".to_string(), None, - Default::default(), None, ); @@ -3905,7 +3853,6 @@ mod tests { runtime, "html".to_string(), None, - Default::default(), None, ); @@ -3962,7 +3909,6 @@ mod tests { runtime, "html".to_string(), None, - Default::default(), None, ); diff --git a/crates/quarto-core/tests/integration/pandoc_profile_cut.rs b/crates/quarto-core/tests/integration/pandoc_profile_cut.rs index 67617ac66c..4e87d179c4 100644 --- a/crates/quarto-core/tests/integration/pandoc_profile_cut.rs +++ b/crates/quarto-core/tests/integration/pandoc_profile_cut.rs @@ -193,7 +193,6 @@ async fn t3_1_panel_tabset_runs_under_pandoc_docx_profile() { "docx".to_string(), ctx.pipeline_profile.clone(), None, - Default::default(), None, ); @@ -300,7 +299,6 @@ async fn run_pandoc_docx_pipeline(ast: &mut Pandoc, ctx: &mut RenderContext<'_>) "docx".to_string(), ctx.pipeline_profile.clone(), None, - Default::default(), None, ); let names = pipeline.transform_names(); diff --git a/docs/guides/authoring/lua-filters.qmd b/docs/guides/authoring/lua-filters.qmd index 5dcde211af..5ad4cb0b03 100644 --- a/docs/guides/authoring/lua-filters.qmd +++ b/docs/guides/authoring/lua-filters.qmd @@ -387,7 +387,9 @@ if quarto.project.profile:includes("drafts") then ... end ``` The environment is read once, before any filter runs, and every filter and -shortcode in the render sees the same values. +shortcode in the render sees the same values. The `{{< env NAME >}}` +shortcode reads the same snapshot, so it and a filter's `os.getenv` can +never disagree. ## Checking versions