From 8a143c2b96291bf493152381ea90f103a78cfd3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20Gr=C3=B6nke?= Date: Sun, 19 Jul 2026 18:58:02 +0000 Subject: [PATCH] feat: resolve xsl:import and xsl:include through a pluggable resolver Imports and includes load through a StylesheetResolver, so a caller can supply stylesheet source from memory instead of the filesystem. The filesystem stays the default via FileSystemResolver, and an InMemoryResolver reads from a map keyed by href. xsl:import and xsl:include then resolve on targets without a filesystem, such as wasm32. Refs #5 --- xee-xslt-compiler/src/ast_ir.rs | 79 ++++++++++++++-------------- xee-xslt-compiler/src/lib.rs | 9 +++- xee-xslt-compiler/src/resolver.rs | 65 +++++++++++++++++++++++ xee-xslt-compiler/src/run.rs | 20 ++++++- xee-xslt-compiler/tests/test_xslt.rs | 72 ++++++++++++++++++++++++- 5 files changed, 201 insertions(+), 44 deletions(-) create mode 100644 xee-xslt-compiler/src/resolver.rs diff --git a/xee-xslt-compiler/src/ast_ir.rs b/xee-xslt-compiler/src/ast_ir.rs index e2eec59aa..b84240dbb 100644 --- a/xee-xslt-compiler/src/ast_ir.rs +++ b/xee-xslt-compiler/src/ast_ir.rs @@ -2,7 +2,6 @@ use ahash::{HashMap, HashMapExt, HashSetExt}; use xee_name::{Name, Namespaces, FN_NAMESPACE}; use std::collections::HashSet; -use std::path::PathBuf; use xee_interpreter::{ context::StaticContext, error, @@ -19,6 +18,7 @@ use xee_xslt_ast::{ use xot::xmlname::{NameStrInfo, OwnedName}; use crate::priority::default_priority; +use crate::resolver::{FileSystemResolver, StylesheetResolver}; struct IrConverter<'a> { variables: Variables, @@ -69,6 +69,24 @@ pub fn parse_with_base_dir_and_initial_mode( xslt: &str, base_dir: Option, initial_mode: Option, +) -> error::SpannedResult { + let resolver = FileSystemResolver::new(base_dir); + parse_with_resolver_and_initial_mode(static_context, xslt, &resolver, initial_mode) +} + +pub fn parse_with_resolver( + static_context: StaticContext, + xslt: &str, + resolver: &dyn StylesheetResolver, +) -> error::SpannedResult { + parse_with_resolver_and_initial_mode(static_context, xslt, resolver, None) +} + +pub fn parse_with_resolver_and_initial_mode( + static_context: StaticContext, + xslt: &str, + resolver: &dyn StylesheetResolver, + initial_mode: Option, ) -> error::SpannedResult { let transform = parse_transform(xslt); // TODO: better error handling @@ -82,7 +100,7 @@ pub fn parse_with_base_dir_and_initial_mode( // Process xsl:import and xsl:include directives let mut visited = HashSet::new(); transform.declarations = - process_imports_and_includes(transform.declarations, base_dir, &mut visited)?; + process_imports_and_includes(transform.declarations, resolver, &mut visited)?; let initial_mode = parse_initial_mode_value(initial_mode)?; if matches!(initial_mode, ast::ApplyTemplatesModeValue::Unnamed) { @@ -163,8 +181,8 @@ fn map_parse_error(xslt: &str, error: ElementError) -> error::SpannedError { fn process_imports_and_includes( declarations: ast::Declarations, - base_dir: Option, - visited: &mut HashSet, + resolver: &dyn StylesheetResolver, + visited: &mut HashSet, ) -> error::SpannedResult { let mut local_declarations = Vec::new(); let mut imports = Vec::new(); // Collect imports in order @@ -173,36 +191,34 @@ fn process_imports_and_includes( match &decl { ast::Declaration::Import(import) => { // Load and parse the imported stylesheet - let (imported_decls, resolved_path) = - load_stylesheet(&import.href.to_string(), base_dir.as_ref())?; - if visited.contains(&resolved_path) { + let (imported_decls, resolved_key) = + load_stylesheet(&import.href.to_string(), resolver)?; + if visited.contains(&resolved_key) { return Err(error::Error::Unsupported(format!( "Circular import detected: '{}'", - resolved_path.display() + resolved_key )) .into()); } - visited.insert(resolved_path); + visited.insert(resolved_key); // Recursively process imports in the imported stylesheet - let processed = - process_imports_and_includes(imported_decls, base_dir.clone(), visited)?; + let processed = process_imports_and_includes(imported_decls, resolver, visited)?; imports.push(processed); } ast::Declaration::Include(include) => { // Load and parse the included stylesheet - let (included_decls, resolved_path) = - load_stylesheet(&include.href.to_string(), base_dir.as_ref())?; - if visited.contains(&resolved_path) { + let (included_decls, resolved_key) = + load_stylesheet(&include.href.to_string(), resolver)?; + if visited.contains(&resolved_key) { return Err(error::Error::Unsupported(format!( "Circular include detected: '{}'", - resolved_path.display() + resolved_key )) .into()); } - visited.insert(resolved_path); + visited.insert(resolved_key); // Recursively process imports in the included stylesheet - let processed = - process_imports_and_includes(included_decls, base_dir.clone(), visited)?; + let processed = process_imports_and_includes(included_decls, resolver, visited)?; local_declarations.extend(processed); } _ => { @@ -223,36 +239,19 @@ fn process_imports_and_includes( fn load_stylesheet( href: &str, - base_dir: Option<&std::path::PathBuf>, -) -> error::SpannedResult<(ast::Declarations, PathBuf)> { - // Resolve the file path - let path = if let Some(base_dir) = base_dir { - base_dir.join(href) - } else { - std::path::PathBuf::from(href) - }; - - let canonical = path.canonicalize().unwrap_or_else(|_| path.clone()); - - // Try to read the file - let content = std::fs::read_to_string(&path).map_err(|e| { - error::Error::Unsupported(format!( - "Failed to load stylesheet '{}': {}", - path.display(), - e - )) - })?; + resolver: &dyn StylesheetResolver, +) -> error::SpannedResult<(ast::Declarations, String)> { + let (content, key) = resolver.resolve(href)?; // Parse the stylesheet let transform = parse_transform(&content).map_err(|e| { error::Error::Unsupported(format!( "Failed to parse imported stylesheet '{}': {:?}", - path.display(), - e + href, e )) })?; - Ok((transform.declarations, canonical)) + Ok((transform.declarations, key)) } impl<'a> IrConverter<'a> { diff --git a/xee-xslt-compiler/src/lib.rs b/xee-xslt-compiler/src/lib.rs index 3451e8417..60605fc03 100644 --- a/xee-xslt-compiler/src/lib.rs +++ b/xee-xslt-compiler/src/lib.rs @@ -1,6 +1,11 @@ mod ast_ir; mod priority; +mod resolver; mod run; -pub use ast_ir::{parse, parse_with_base_dir, parse_with_base_dir_and_initial_mode}; -pub use run::evaluate; +pub use ast_ir::{ + parse, parse_with_base_dir, parse_with_base_dir_and_initial_mode, parse_with_resolver, + parse_with_resolver_and_initial_mode, +}; +pub use resolver::{FileSystemResolver, InMemoryResolver, StylesheetResolver}; +pub use run::{evaluate, evaluate_program, evaluate_with_resolver}; diff --git a/xee-xslt-compiler/src/resolver.rs b/xee-xslt-compiler/src/resolver.rs new file mode 100644 index 000000000..c6d76821d --- /dev/null +++ b/xee-xslt-compiler/src/resolver.rs @@ -0,0 +1,65 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use xee_interpreter::error; + +/// Resolves an `xsl:import` or `xsl:include` `href` to stylesheet source. +/// +/// The returned key canonically identifies the resolved stylesheet and is used +/// to detect import cycles. +pub trait StylesheetResolver { + fn resolve(&self, href: &str) -> error::SpannedResult<(String, String)>; +} + +/// Reads imported stylesheets from the filesystem, resolving each `href` against +/// an optional base directory. +pub struct FileSystemResolver { + base_dir: Option, +} + +impl FileSystemResolver { + pub fn new(base_dir: Option) -> Self { + Self { base_dir } + } +} + +impl StylesheetResolver for FileSystemResolver { + fn resolve(&self, href: &str) -> error::SpannedResult<(String, String)> { + let path = match &self.base_dir { + Some(base_dir) => base_dir.join(href), + None => PathBuf::from(href), + }; + let canonical = path.canonicalize().unwrap_or_else(|_| path.clone()); + let content = std::fs::read_to_string(&path).map_err(|e| { + error::Error::Unsupported(format!( + "Failed to load stylesheet '{}': {}", + path.display(), + e + )) + })?; + Ok((content, canonical.to_string_lossy().into_owned())) + } +} + +/// Resolves imported stylesheets from an in-memory map keyed by `href`, so +/// `xsl:import` and `xsl:include` work without a filesystem. +pub struct InMemoryResolver { + sources: HashMap, +} + +impl InMemoryResolver { + pub fn new(sources: HashMap) -> Self { + Self { sources } + } +} + +impl StylesheetResolver for InMemoryResolver { + fn resolve(&self, href: &str) -> error::SpannedResult<(String, String)> { + self.sources + .get(href) + .map(|source| (source.clone(), href.to_string())) + .ok_or_else(|| { + error::Error::Unsupported(format!("Stylesheet not found: '{}'", href)).into() + }) + } +} diff --git a/xee-xslt-compiler/src/run.rs b/xee-xslt-compiler/src/run.rs index 3361090f4..ed8911228 100644 --- a/xee-xslt-compiler/src/run.rs +++ b/xee-xslt-compiler/src/run.rs @@ -6,7 +6,8 @@ use xee_interpreter::error; use xee_interpreter::interpreter::Program; use xee_interpreter::sequence; -use crate::ast_ir::parse; +use crate::ast_ir::{parse, parse_with_resolver}; +use crate::resolver::StylesheetResolver; pub fn evaluate_program( xot: &mut Xot, @@ -35,3 +36,20 @@ pub fn evaluate(xot: &mut Xot, xml: &str, xslt: &str) -> error::SpannedResult error::SpannedResult { + let namespaces = Namespaces::new( + Namespaces::default_namespaces(), + "".to_string(), + FN_NAMESPACE.to_string(), + ); + let static_context = StaticContext::from_namespaces(namespaces); + let root = xot.parse(xml).unwrap(); + let program = parse_with_resolver(static_context, xslt, resolver)?; + evaluate_program(xot, &program, root) +} diff --git a/xee-xslt-compiler/tests/test_xslt.rs b/xee-xslt-compiler/tests/test_xslt.rs index ddf3ec531..e7d2ac964 100644 --- a/xee-xslt-compiler/tests/test_xslt.rs +++ b/xee-xslt-compiler/tests/test_xslt.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::fmt::Write; use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; @@ -9,7 +10,9 @@ use xee_interpreter::{ xml::Documents, }; use xee_name::{Namespaces, FN_NAMESPACE}; -use xee_xslt_compiler::{evaluate, parse, parse_with_base_dir}; +use xee_xslt_compiler::{ + evaluate, evaluate_with_resolver, parse, parse_with_base_dir, InMemoryResolver, +}; use xot::Xot; fn xml(xot: &Xot, sequence: Sequence) -> String { @@ -2330,3 +2333,70 @@ fn test_basic_iterate_params() { "124" ); } + +fn base_stylesheet(marker: &str) -> String { + format!( + r#" + <{marker}/> +"# + ) +} + +#[test] +fn test_import_from_memory() { + let mut xot = Xot::new(); + let mut sources = HashMap::new(); + sources.insert("base.xsl".to_string(), base_stylesheet("imported")); + let resolver = InMemoryResolver::new(sources); + let output = evaluate_with_resolver( + &mut xot, + "", + r#" + + +"#, + &resolver, + ) + .unwrap(); + assert_eq!(xml(&xot, output), ""); +} + +#[test] +fn test_include_from_memory() { + let mut xot = Xot::new(); + let mut sources = HashMap::new(); + sources.insert("base.xsl".to_string(), base_stylesheet("included")); + let resolver = InMemoryResolver::new(sources); + let output = evaluate_with_resolver( + &mut xot, + "", + r#" + + +"#, + &resolver, + ) + .unwrap(); + assert_eq!(xml(&xot, output), ""); +} + +#[test] +fn test_import_precedence_from_memory() { + // The importing stylesheet outranks the imported one. + let mut xot = Xot::new(); + let mut sources = HashMap::new(); + sources.insert("base.xsl".to_string(), base_stylesheet("imported")); + let resolver = InMemoryResolver::new(sources); + let output = evaluate_with_resolver( + &mut xot, + "", + r#" + + +
+"#, + &resolver, + ) + .unwrap(); + assert_eq!(xml(&xot, output), "
"); +}