use std::path::Path;
use std::sync::Arc;
use lex_extension::{
handler::{HandlerError, LexHandler},
wire::{LabelCtx, WireNode},
};
use crate::lex::includes::{
parse_no_attach, resolve_file_reference, stamp_doc, IncludeError, LoadError, LoadedFile,
Loader, ResolveConfig,
};
use crate::lex::wire::to_wire_document;
pub const CODE_MISSING_SRC: i32 = -32000;
pub const CODE_NOT_FOUND: i32 = -32001;
pub const CODE_OUTSIDE_ROOT: i32 = -32002;
pub const CODE_TOO_LARGE: i32 = -32003;
pub const CODE_ABSOLUTE_PATH: i32 = -32004;
pub const CODE_IO: i32 = -32005;
pub const CODE_PARSE_FAILED: i32 = -32006;
pub(crate) type ParseFn = fn(&str) -> Result<crate::lex::ast::Document, String>;
pub struct LexIncludeHandler {
loader: Arc<dyn Loader + Send + Sync>,
config: ResolveConfig,
parse_fn: ParseFn,
}
impl LexIncludeHandler {
pub fn new(loader: Arc<dyn Loader + Send + Sync>, config: ResolveConfig) -> Self {
Self {
loader,
config,
parse_fn: parse_no_attach,
}
}
#[cfg(test)]
pub(crate) fn with_parse_fn(
loader: Arc<dyn Loader + Send + Sync>,
config: ResolveConfig,
parse_fn: ParseFn,
) -> Self {
Self {
loader,
config,
parse_fn,
}
}
pub fn root(&self) -> &Path {
&self.config.root
}
}
impl LexHandler for LexIncludeHandler {
fn on_resolve(&self, ctx: &LabelCtx) -> Result<Option<WireNode>, HandlerError> {
let src = extract_src(ctx)?;
let host_origin = ctx.node.origin.as_deref().map(Path::new);
let target_path = resolve_file_reference(&src, host_origin, &self.config.root)
.map_err(|e| include_error_to_handler(&e))?;
let LoadedFile {
source,
canonical_path,
} = self
.loader
.load(&target_path)
.map_err(|e| load_error_to_handler(&e))?;
let mut included = (self.parse_fn)(&source).map_err(|message| HandlerError::Custom {
code: CODE_PARSE_FAILED,
message: format!("parse of `{}` failed: {message}", canonical_path.display()),
data: Some(serde_json::json!({
"path": canonical_path.display().to_string(),
"message": message,
})),
})?;
let origin = Arc::new(canonical_path);
stamp_doc(&mut included, &origin);
promote_title_and_doc_annotations(&mut included);
let wire = to_wire_document(&included);
Ok(Some(wire))
}
}
fn promote_title_and_doc_annotations(doc: &mut crate::lex::ast::Document) {
use crate::lex::ast::elements::content_item::ContentItem;
use crate::lex::ast::elements::paragraph::Paragraph;
let mut prefix: Vec<ContentItem> = Vec::new();
if let Some(title) = doc.title.take() {
let location = title.location.clone();
let para = Paragraph::from_line(title.as_str().to_string()).at(location);
prefix.push(ContentItem::Paragraph(para));
}
for ann in doc.annotations.drain(..) {
prefix.push(ContentItem::Annotation(ann));
}
if !prefix.is_empty() {
let original = std::mem::take(doc.root.children.as_mut_vec());
let mut combined = prefix;
combined.extend(original);
*doc.root.children.as_mut_vec() = combined;
}
}
fn extract_src(ctx: &LabelCtx) -> Result<String, HandlerError> {
ctx.params
.get("src")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| HandlerError::Custom {
code: CODE_MISSING_SRC,
message: format!(
"lex.include is missing required `src` parameter; got params: {}",
ctx.params
),
data: None,
})
}
fn load_error_to_handler(err: &LoadError) -> HandlerError {
match err {
LoadError::NotFound { path } => HandlerError::Custom {
code: CODE_NOT_FOUND,
message: format!("include not found: {}", path.display()),
data: Some(serde_json::json!({ "path": path.display().to_string() })),
},
LoadError::OutsideRoot { path, root } => HandlerError::Custom {
code: CODE_OUTSIDE_ROOT,
message: format!(
"include path {} resolves outside loader root {}",
path.display(),
root.display()
),
data: Some(serde_json::json!({
"path": path.display().to_string(),
"root": root.display().to_string(),
})),
},
LoadError::TooLarge { path, size, limit } => HandlerError::Custom {
code: CODE_TOO_LARGE,
message: format!(
"include file {} is {size} bytes, exceeds limit of {limit} bytes",
path.display()
),
data: Some(serde_json::json!({
"path": path.display().to_string(),
"size": size,
"limit": limit,
})),
},
LoadError::Io { path, message } => HandlerError::Custom {
code: CODE_IO,
message: format!("io error reading {}: {message}", path.display()),
data: Some(serde_json::json!({ "path": path.display().to_string() })),
},
}
}
fn include_error_to_handler(err: &IncludeError) -> HandlerError {
match err {
IncludeError::AbsolutePath { path } => HandlerError::Custom {
code: CODE_ABSOLUTE_PATH,
message: format!(
"lex.include `src` rejected: {} is a platform-absolute path",
path.display()
),
data: Some(serde_json::json!({ "path": path.display().to_string() })),
},
IncludeError::RootEscape { path, root } => HandlerError::Custom {
code: CODE_OUTSIDE_ROOT,
message: format!(
"include path {} resolves outside resolution root {}",
path.display(),
root.display()
),
data: Some(serde_json::json!({
"path": path.display().to_string(),
"root": root.display().to_string(),
})),
},
other => HandlerError::internal(format!("path resolution failed: {other}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lex::includes::{LoadError, LoadedFile, MemoryLoader};
use lex_extension::wire::{AnnotationBody, NodeRef, Position, Range};
use std::path::PathBuf;
fn make_ctx(src: &str, host_origin: Option<&str>) -> LabelCtx {
LabelCtx {
label: "lex.include".into(),
params: serde_json::json!({ "src": src }),
body: AnnotationBody::None,
node: NodeRef {
kind: "annotation".into(),
range: Range {
start: Position(0, 0),
end: Position(0, 0),
},
origin: host_origin.map(|s| s.to_string()),
},
}
}
fn handler_with_loader(loader: MemoryLoader, root: PathBuf) -> LexIncludeHandler {
LexIncludeHandler::new(Arc::new(loader), ResolveConfig::with_root(root))
}
#[test]
fn happy_path_returns_wire_document() {
let mut loader = MemoryLoader::new();
loader.insert(
PathBuf::from("/root/included.lex"),
"Hello from included.\n",
);
let handler = handler_with_loader(loader, PathBuf::from("/root"));
let ctx = make_ctx("included.lex", Some("/root/host.lex"));
let result = handler.on_resolve(&ctx).expect("on_resolve ok");
let wire = result.expect("returned Some(WireNode)");
let WireNode::Document {
children, origin, ..
} = wire
else {
panic!("expected WireNode::Document, got something else");
};
assert_eq!(origin.as_deref(), Some("/root/included.lex"));
assert!(
!children.is_empty(),
"included document children must reach the wire payload"
);
}
#[test]
fn missing_src_returns_custom_error() {
let loader = MemoryLoader::new();
let handler = handler_with_loader(loader, PathBuf::from("/root"));
let mut ctx = make_ctx("ignored", None);
ctx.params = serde_json::json!({});
let err = handler.on_resolve(&ctx).expect_err("must error");
match err {
HandlerError::Custom { code, .. } => {
assert_eq!(code, CODE_MISSING_SRC);
}
other => panic!("expected Custom code, got {other:?}"),
}
}
#[test]
fn not_found_maps_to_code_minus_32001() {
let loader = MemoryLoader::new();
let handler = handler_with_loader(loader, PathBuf::from("/root"));
let ctx = make_ctx("missing.lex", Some("/root/host.lex"));
let err = handler.on_resolve(&ctx).expect_err("must error");
match err {
HandlerError::Custom { code, .. } => assert_eq!(code, CODE_NOT_FOUND),
other => panic!("expected NotFound→Custom, got {other:?}"),
}
}
#[test]
fn outside_root_via_resolver_maps_to_code_minus_32002() {
let loader = MemoryLoader::new();
let handler = handler_with_loader(loader, PathBuf::from("/root"));
let ctx = make_ctx("../../../etc/passwd", Some("/root/host.lex"));
let err = handler.on_resolve(&ctx).expect_err("must error");
match err {
HandlerError::Custom { code, .. } => assert_eq!(code, CODE_OUTSIDE_ROOT),
other => panic!("expected RootEscape→Custom, got {other:?}"),
}
}
#[test]
fn absolute_path_maps_to_code_minus_32004() {
let loader = MemoryLoader::new();
let handler = handler_with_loader(loader, PathBuf::from("/root"));
#[cfg(windows)]
let absolute = "C:\\Windows\\System32\\drivers\\etc\\hosts";
#[cfg(not(windows))]
let absolute = "//absolute/elsewhere"; let ctx = make_ctx(absolute, Some("/root/host.lex"));
let err = handler.on_resolve(&ctx).expect_err("must error");
match err {
HandlerError::Custom { code, .. } => {
assert!(
code == CODE_ABSOLUTE_PATH || code == CODE_OUTSIDE_ROOT,
"expected -32002 or -32004, got {code}"
);
}
other => panic!("expected Custom code, got {other:?}"),
}
}
#[test]
fn loader_outside_root_maps_to_code_minus_32002() {
struct MockEscape;
impl Loader for MockEscape {
fn load(&self, path: &std::path::Path) -> Result<LoadedFile, LoadError> {
Err(LoadError::OutsideRoot {
path: path.to_path_buf(),
root: PathBuf::from("/root"),
})
}
}
let handler = LexIncludeHandler::new(
Arc::new(MockEscape),
ResolveConfig::with_root(PathBuf::from("/root")),
);
let ctx = make_ctx("inner.lex", Some("/root/host.lex"));
let err = handler.on_resolve(&ctx).expect_err("must error");
match err {
HandlerError::Custom { code, .. } => assert_eq!(code, CODE_OUTSIDE_ROOT),
other => panic!("expected OutsideRoot→Custom, got {other:?}"),
}
}
#[test]
fn too_large_maps_to_code_minus_32003() {
struct MockTooLarge;
impl Loader for MockTooLarge {
fn load(&self, path: &std::path::Path) -> Result<LoadedFile, LoadError> {
Err(LoadError::TooLarge {
path: path.to_path_buf(),
size: 1_000_000,
limit: 100,
})
}
}
let handler = LexIncludeHandler::new(
Arc::new(MockTooLarge),
ResolveConfig::with_root(PathBuf::from("/root")),
);
let ctx = make_ctx("big.lex", Some("/root/host.lex"));
let err = handler.on_resolve(&ctx).expect_err("must error");
match err {
HandlerError::Custom { code, data, .. } => {
assert_eq!(code, CODE_TOO_LARGE);
let data = data.expect("data attached");
assert_eq!(data["size"], 1_000_000);
assert_eq!(data["limit"], 100);
}
other => panic!("expected TooLarge→Custom, got {other:?}"),
}
}
#[test]
fn io_error_maps_to_code_minus_32005() {
struct MockIo;
impl Loader for MockIo {
fn load(&self, path: &std::path::Path) -> Result<LoadedFile, LoadError> {
Err(LoadError::Io {
path: path.to_path_buf(),
message: "permission denied".into(),
})
}
}
let handler = LexIncludeHandler::new(
Arc::new(MockIo),
ResolveConfig::with_root(PathBuf::from("/root")),
);
let ctx = make_ctx("locked.lex", Some("/root/host.lex"));
let err = handler.on_resolve(&ctx).expect_err("must error");
match err {
HandlerError::Custom { code, .. } => assert_eq!(code, CODE_IO),
other => panic!("expected Io→Custom, got {other:?}"),
}
}
#[test]
fn parse_failure_maps_to_custom_parse_failed() {
fn always_fails(_source: &str) -> Result<crate::lex::ast::Document, String> {
Err("synthetic parser failure".into())
}
let mut loader = MemoryLoader::new();
loader.insert(PathBuf::from("/root/broken.lex"), "anything\n");
let handler = LexIncludeHandler::with_parse_fn(
Arc::new(loader),
ResolveConfig::with_root(PathBuf::from("/root")),
always_fails,
);
let ctx = make_ctx("broken.lex", Some("/root/host.lex"));
let err = handler.on_resolve(&ctx).expect_err("must error");
match err {
HandlerError::Custom { code, data, .. } => {
assert_eq!(code, CODE_PARSE_FAILED);
let data = data.expect("parse-failure data must be attached");
assert_eq!(
data["path"].as_str().expect("path field"),
"/root/broken.lex",
"data.path must carry the canonical path"
);
assert_eq!(
data["message"].as_str().expect("message field"),
"synthetic parser failure",
"data.message must carry the underlying parser message"
);
}
other => panic!("expected Custom CODE_PARSE_FAILED, got {other:?}"),
}
}
#[test]
fn included_document_title_and_annotations_are_promoted_to_leading_children() {
use crate::lex::ast::elements::content_item::ContentItem;
use crate::lex::wire::from_wire_node;
let mut loader = MemoryLoader::new();
loader.insert(
PathBuf::from("/root/titled.lex"),
":: meta author=alice ::\n\
Document Title\n\
\n\
Body paragraph.\n",
);
let handler = handler_with_loader(loader, PathBuf::from("/root"));
let ctx = make_ctx("titled.lex", Some("/root/host.lex"));
let wire = handler
.on_resolve(&ctx)
.expect("on_resolve ok")
.expect("Some(WireNode)");
let items = from_wire_node(&wire).expect("from_wire ok");
let first_paragraph = items
.iter()
.position(|i| matches!(i, ContentItem::Paragraph(_)));
let first_annotation = items
.iter()
.position(|i| matches!(i, ContentItem::Annotation(_)));
assert!(
first_paragraph.is_some(),
"title-as-paragraph must survive into the wire payload"
);
assert!(
first_annotation.is_some(),
"document-level annotation must survive into the wire payload"
);
let title_present = items.iter().any(|i| match i {
ContentItem::Paragraph(p) => p.lines.iter().any(|li| match li {
ContentItem::TextLine(line) => line.content.as_string() == "Document Title",
_ => false,
}),
_ => false,
});
assert!(
title_present,
"Document.title must round-trip as a leading Paragraph"
);
let meta_present = items.iter().any(|i| match i {
ContentItem::Annotation(a) => a.data.label.value == "meta",
_ => false,
});
assert!(
meta_present,
"document-level :: meta :: annotation must round-trip"
);
}
#[test]
fn round_trip_via_from_wire_recovers_typed_ast() {
use crate::lex::ast::elements::content_item::ContentItem;
use crate::lex::wire::from_wire_node;
let mut loader = MemoryLoader::new();
loader.insert(PathBuf::from("/root/snippet.lex"), "First paragraph.\n");
let handler = handler_with_loader(loader, PathBuf::from("/root"));
let ctx = make_ctx("snippet.lex", Some("/root/host.lex"));
let wire = handler
.on_resolve(&ctx)
.expect("on_resolve ok")
.expect("Some(WireNode)");
let items = from_wire_node(&wire).expect("from_wire ok");
assert!(
!items.is_empty(),
"from_wire on the included document must recover at least one item"
);
let saw_paragraph = items
.iter()
.any(|item| matches!(item, ContentItem::Paragraph(_)));
assert!(saw_paragraph, "included paragraph must survive round-trip");
}
}