1use std::path::Path;
30use std::sync::Arc;
31
32use lex_extension::{
33 handler::{HandlerError, LexHandler},
34 wire::{LabelCtx, WireNode},
35};
36
37use crate::lex::includes::{
38 parse_no_attach, resolve_file_reference, stamp_doc, IncludeError, LoadError, LoadedFile,
39 Loader, ResolveConfig,
40};
41use crate::lex::wire::to_wire_document;
42
43pub const CODE_MISSING_SRC: i32 = -32000;
46pub const CODE_NOT_FOUND: i32 = -32001;
48pub const CODE_OUTSIDE_ROOT: i32 = -32002;
51pub const CODE_TOO_LARGE: i32 = -32003;
53pub const CODE_ABSOLUTE_PATH: i32 = -32004;
56pub const CODE_IO: i32 = -32005;
58pub const CODE_PARSE_FAILED: i32 = -32006;
61
62pub(crate) type ParseFn = fn(&str) -> Result<crate::lex::ast::Document, String>;
67
68pub struct LexIncludeHandler {
70 loader: Arc<dyn Loader + Send + Sync>,
71 config: ResolveConfig,
72 parse_fn: ParseFn,
73}
74
75impl LexIncludeHandler {
76 pub fn new(loader: Arc<dyn Loader + Send + Sync>, config: ResolveConfig) -> Self {
87 Self {
88 loader,
89 config,
90 parse_fn: parse_no_attach,
91 }
92 }
93
94 #[cfg(test)]
99 pub(crate) fn with_parse_fn(
100 loader: Arc<dyn Loader + Send + Sync>,
101 config: ResolveConfig,
102 parse_fn: ParseFn,
103 ) -> Self {
104 Self {
105 loader,
106 config,
107 parse_fn,
108 }
109 }
110
111 pub fn root(&self) -> &Path {
115 &self.config.root
116 }
117}
118
119impl LexHandler for LexIncludeHandler {
120 fn on_resolve(&self, ctx: &LabelCtx) -> Result<Option<WireNode>, HandlerError> {
121 let src = extract_src(ctx)?;
122
123 let host_origin = ctx.node.origin.as_deref().map(Path::new);
127 let target_path = resolve_file_reference(&src, host_origin, &self.config.root)
128 .map_err(|e| include_error_to_handler(&e))?;
129
130 let LoadedFile {
134 source,
135 canonical_path,
136 } = self
137 .loader
138 .load(&target_path)
139 .map_err(|e| load_error_to_handler(&e))?;
140
141 let mut included = (self.parse_fn)(&source).map_err(|message| HandlerError::Custom {
148 code: CODE_PARSE_FAILED,
149 message: format!("parse of `{}` failed: {message}", canonical_path.display()),
150 data: Some(serde_json::json!({
151 "path": canonical_path.display().to_string(),
152 "message": message,
153 })),
154 })?;
155
156 let origin = Arc::new(canonical_path);
160 stamp_doc(&mut included, &origin);
161
162 promote_title_and_doc_annotations(&mut included);
168
169 let wire = to_wire_document(&included);
170 Ok(Some(wire))
171 }
172}
173
174fn promote_title_and_doc_annotations(doc: &mut crate::lex::ast::Document) {
183 use crate::lex::ast::elements::content_item::ContentItem;
184 use crate::lex::ast::elements::paragraph::Paragraph;
185
186 let mut prefix: Vec<ContentItem> = Vec::new();
187 if let Some(title) = doc.title.take() {
188 let location = title.location.clone();
189 let para = Paragraph::from_line(title.as_str().to_string()).at(location);
190 prefix.push(ContentItem::Paragraph(para));
191 }
192 for ann in doc.annotations.drain(..) {
193 prefix.push(ContentItem::Annotation(ann));
194 }
195 if !prefix.is_empty() {
196 let original = std::mem::take(doc.root.children.as_mut_vec());
197 let mut combined = prefix;
198 combined.extend(original);
199 *doc.root.children.as_mut_vec() = combined;
200 }
201}
202
203fn extract_src(ctx: &LabelCtx) -> Result<String, HandlerError> {
204 ctx.params
205 .get("src")
206 .and_then(|v| v.as_str())
207 .map(|s| s.to_string())
208 .ok_or_else(|| HandlerError::Custom {
209 code: CODE_MISSING_SRC,
210 message: format!(
211 "lex.include is missing required `src` parameter; got params: {}",
212 ctx.params
213 ),
214 data: None,
215 })
216}
217
218fn load_error_to_handler(err: &LoadError) -> HandlerError {
219 match err {
220 LoadError::NotFound { path } => HandlerError::Custom {
221 code: CODE_NOT_FOUND,
222 message: format!("include not found: {}", path.display()),
223 data: Some(serde_json::json!({ "path": path.display().to_string() })),
224 },
225 LoadError::OutsideRoot { path, root } => HandlerError::Custom {
226 code: CODE_OUTSIDE_ROOT,
227 message: format!(
228 "include path {} resolves outside loader root {}",
229 path.display(),
230 root.display()
231 ),
232 data: Some(serde_json::json!({
233 "path": path.display().to_string(),
234 "root": root.display().to_string(),
235 })),
236 },
237 LoadError::TooLarge { path, size, limit } => HandlerError::Custom {
238 code: CODE_TOO_LARGE,
239 message: format!(
240 "include file {} is {size} bytes, exceeds limit of {limit} bytes",
241 path.display()
242 ),
243 data: Some(serde_json::json!({
244 "path": path.display().to_string(),
245 "size": size,
246 "limit": limit,
247 })),
248 },
249 LoadError::Io { path, message } => HandlerError::Custom {
250 code: CODE_IO,
251 message: format!("io error reading {}: {message}", path.display()),
252 data: Some(serde_json::json!({ "path": path.display().to_string() })),
253 },
254 }
255}
256
257fn include_error_to_handler(err: &IncludeError) -> HandlerError {
258 match err {
259 IncludeError::AbsolutePath { path } => HandlerError::Custom {
260 code: CODE_ABSOLUTE_PATH,
261 message: format!(
262 "lex.include `src` rejected: {} is a platform-absolute path",
263 path.display()
264 ),
265 data: Some(serde_json::json!({ "path": path.display().to_string() })),
266 },
267 IncludeError::RootEscape { path, root } => HandlerError::Custom {
268 code: CODE_OUTSIDE_ROOT,
269 message: format!(
270 "include path {} resolves outside resolution root {}",
271 path.display(),
272 root.display()
273 ),
274 data: Some(serde_json::json!({
275 "path": path.display().to_string(),
276 "root": root.display().to_string(),
277 })),
278 },
279 other => HandlerError::internal(format!("path resolution failed: {other}")),
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use crate::lex::includes::{LoadError, LoadedFile, MemoryLoader};
292 use lex_extension::wire::{AnnotationBody, NodeRef, Position, Range};
293 use std::path::PathBuf;
294
295 fn make_ctx(src: &str, host_origin: Option<&str>) -> LabelCtx {
296 LabelCtx {
297 label: "lex.include".into(),
298 params: serde_json::json!({ "src": src }),
299 body: AnnotationBody::None,
300 node: NodeRef {
301 kind: "annotation".into(),
302 range: Range {
303 start: Position(0, 0),
304 end: Position(0, 0),
305 },
306 origin: host_origin.map(|s| s.to_string()),
307 },
308 }
309 }
310
311 fn handler_with_loader(loader: MemoryLoader, root: PathBuf) -> LexIncludeHandler {
312 LexIncludeHandler::new(Arc::new(loader), ResolveConfig::with_root(root))
313 }
314
315 #[test]
316 fn happy_path_returns_wire_document() {
317 let mut loader = MemoryLoader::new();
318 loader.insert(
319 PathBuf::from("/root/included.lex"),
320 "Hello from included.\n",
321 );
322 let handler = handler_with_loader(loader, PathBuf::from("/root"));
323
324 let ctx = make_ctx("included.lex", Some("/root/host.lex"));
325 let result = handler.on_resolve(&ctx).expect("on_resolve ok");
326 let wire = result.expect("returned Some(WireNode)");
327
328 let WireNode::Document {
330 children, origin, ..
331 } = wire
332 else {
333 panic!("expected WireNode::Document, got something else");
334 };
335 assert_eq!(origin.as_deref(), Some("/root/included.lex"));
339 assert!(
342 !children.is_empty(),
343 "included document children must reach the wire payload"
344 );
345 }
346
347 #[test]
348 fn missing_src_returns_custom_error() {
349 let loader = MemoryLoader::new();
350 let handler = handler_with_loader(loader, PathBuf::from("/root"));
351 let mut ctx = make_ctx("ignored", None);
352 ctx.params = serde_json::json!({});
353 let err = handler.on_resolve(&ctx).expect_err("must error");
354 match err {
355 HandlerError::Custom { code, .. } => {
356 assert_eq!(code, CODE_MISSING_SRC);
357 }
358 other => panic!("expected Custom code, got {other:?}"),
359 }
360 }
361
362 #[test]
363 fn not_found_maps_to_code_minus_32001() {
364 let loader = MemoryLoader::new();
365 let handler = handler_with_loader(loader, PathBuf::from("/root"));
366 let ctx = make_ctx("missing.lex", Some("/root/host.lex"));
367 let err = handler.on_resolve(&ctx).expect_err("must error");
368 match err {
369 HandlerError::Custom { code, .. } => assert_eq!(code, CODE_NOT_FOUND),
370 other => panic!("expected NotFound→Custom, got {other:?}"),
371 }
372 }
373
374 #[test]
375 fn outside_root_via_resolver_maps_to_code_minus_32002() {
376 let loader = MemoryLoader::new();
377 let handler = handler_with_loader(loader, PathBuf::from("/root"));
378 let ctx = make_ctx("../../../etc/passwd", Some("/root/host.lex"));
381 let err = handler.on_resolve(&ctx).expect_err("must error");
382 match err {
383 HandlerError::Custom { code, .. } => assert_eq!(code, CODE_OUTSIDE_ROOT),
384 other => panic!("expected RootEscape→Custom, got {other:?}"),
385 }
386 }
387
388 #[test]
389 fn absolute_path_maps_to_code_minus_32004() {
390 let loader = MemoryLoader::new();
391 let handler = handler_with_loader(loader, PathBuf::from("/root"));
392 #[cfg(windows)]
397 let absolute = "C:\\Windows\\System32\\drivers\\etc\\hosts";
398 #[cfg(not(windows))]
399 let absolute = "//absolute/elsewhere"; let ctx = make_ctx(absolute, Some("/root/host.lex"));
401 let err = handler.on_resolve(&ctx).expect_err("must error");
402 match err {
406 HandlerError::Custom { code, .. } => {
407 assert!(
408 code == CODE_ABSOLUTE_PATH || code == CODE_OUTSIDE_ROOT,
409 "expected -32002 or -32004, got {code}"
410 );
411 }
412 other => panic!("expected Custom code, got {other:?}"),
413 }
414 }
415
416 #[test]
417 fn loader_outside_root_maps_to_code_minus_32002() {
418 struct MockEscape;
422 impl Loader for MockEscape {
423 fn load(&self, path: &std::path::Path) -> Result<LoadedFile, LoadError> {
424 Err(LoadError::OutsideRoot {
425 path: path.to_path_buf(),
426 root: PathBuf::from("/root"),
427 })
428 }
429 }
430 let handler = LexIncludeHandler::new(
431 Arc::new(MockEscape),
432 ResolveConfig::with_root(PathBuf::from("/root")),
433 );
434 let ctx = make_ctx("inner.lex", Some("/root/host.lex"));
435 let err = handler.on_resolve(&ctx).expect_err("must error");
436 match err {
437 HandlerError::Custom { code, .. } => assert_eq!(code, CODE_OUTSIDE_ROOT),
438 other => panic!("expected OutsideRoot→Custom, got {other:?}"),
439 }
440 }
441
442 #[test]
443 fn too_large_maps_to_code_minus_32003() {
444 struct MockTooLarge;
445 impl Loader for MockTooLarge {
446 fn load(&self, path: &std::path::Path) -> Result<LoadedFile, LoadError> {
447 Err(LoadError::TooLarge {
448 path: path.to_path_buf(),
449 size: 1_000_000,
450 limit: 100,
451 })
452 }
453 }
454 let handler = LexIncludeHandler::new(
455 Arc::new(MockTooLarge),
456 ResolveConfig::with_root(PathBuf::from("/root")),
457 );
458 let ctx = make_ctx("big.lex", Some("/root/host.lex"));
459 let err = handler.on_resolve(&ctx).expect_err("must error");
460 match err {
461 HandlerError::Custom { code, data, .. } => {
462 assert_eq!(code, CODE_TOO_LARGE);
463 let data = data.expect("data attached");
464 assert_eq!(data["size"], 1_000_000);
465 assert_eq!(data["limit"], 100);
466 }
467 other => panic!("expected TooLarge→Custom, got {other:?}"),
468 }
469 }
470
471 #[test]
472 fn io_error_maps_to_code_minus_32005() {
473 struct MockIo;
474 impl Loader for MockIo {
475 fn load(&self, path: &std::path::Path) -> Result<LoadedFile, LoadError> {
476 Err(LoadError::Io {
477 path: path.to_path_buf(),
478 message: "permission denied".into(),
479 })
480 }
481 }
482 let handler = LexIncludeHandler::new(
483 Arc::new(MockIo),
484 ResolveConfig::with_root(PathBuf::from("/root")),
485 );
486 let ctx = make_ctx("locked.lex", Some("/root/host.lex"));
487 let err = handler.on_resolve(&ctx).expect_err("must error");
488 match err {
489 HandlerError::Custom { code, .. } => assert_eq!(code, CODE_IO),
490 other => panic!("expected Io→Custom, got {other:?}"),
491 }
492 }
493
494 #[test]
495 fn parse_failure_maps_to_custom_parse_failed() {
496 fn always_fails(_source: &str) -> Result<crate::lex::ast::Document, String> {
508 Err("synthetic parser failure".into())
509 }
510
511 let mut loader = MemoryLoader::new();
512 loader.insert(PathBuf::from("/root/broken.lex"), "anything\n");
513 let handler = LexIncludeHandler::with_parse_fn(
514 Arc::new(loader),
515 ResolveConfig::with_root(PathBuf::from("/root")),
516 always_fails,
517 );
518 let ctx = make_ctx("broken.lex", Some("/root/host.lex"));
519 let err = handler.on_resolve(&ctx).expect_err("must error");
520 match err {
521 HandlerError::Custom { code, data, .. } => {
522 assert_eq!(code, CODE_PARSE_FAILED);
523 let data = data.expect("parse-failure data must be attached");
524 assert_eq!(
525 data["path"].as_str().expect("path field"),
526 "/root/broken.lex",
527 "data.path must carry the canonical path"
528 );
529 assert_eq!(
530 data["message"].as_str().expect("message field"),
531 "synthetic parser failure",
532 "data.message must carry the underlying parser message"
533 );
534 }
535 other => panic!("expected Custom CODE_PARSE_FAILED, got {other:?}"),
536 }
537 }
538
539 #[test]
540 fn included_document_title_and_annotations_are_promoted_to_leading_children() {
541 use crate::lex::ast::elements::content_item::ContentItem;
548 use crate::lex::wire::from_wire_node;
549
550 let mut loader = MemoryLoader::new();
551 loader.insert(
554 PathBuf::from("/root/titled.lex"),
555 ":: meta author=alice ::\n\
556 Document Title\n\
557 \n\
558 Body paragraph.\n",
559 );
560 let handler = handler_with_loader(loader, PathBuf::from("/root"));
561 let ctx = make_ctx("titled.lex", Some("/root/host.lex"));
562 let wire = handler
563 .on_resolve(&ctx)
564 .expect("on_resolve ok")
565 .expect("Some(WireNode)");
566
567 let items = from_wire_node(&wire).expect("from_wire ok");
568 let first_paragraph = items
571 .iter()
572 .position(|i| matches!(i, ContentItem::Paragraph(_)));
573 let first_annotation = items
574 .iter()
575 .position(|i| matches!(i, ContentItem::Annotation(_)));
576 assert!(
577 first_paragraph.is_some(),
578 "title-as-paragraph must survive into the wire payload"
579 );
580 assert!(
581 first_annotation.is_some(),
582 "document-level annotation must survive into the wire payload"
583 );
584 let title_present = items.iter().any(|i| match i {
589 ContentItem::Paragraph(p) => p.lines.iter().any(|li| match li {
590 ContentItem::TextLine(line) => line.content.as_string() == "Document Title",
591 _ => false,
592 }),
593 _ => false,
594 });
595 assert!(
596 title_present,
597 "Document.title must round-trip as a leading Paragraph"
598 );
599 let meta_present = items.iter().any(|i| match i {
601 ContentItem::Annotation(a) => a.data.label.value == "meta",
602 _ => false,
603 });
604 assert!(
605 meta_present,
606 "document-level :: meta :: annotation must round-trip"
607 );
608 }
609
610 #[test]
611 fn round_trip_via_from_wire_recovers_typed_ast() {
612 use crate::lex::ast::elements::content_item::ContentItem;
613 use crate::lex::wire::from_wire_node;
614
615 let mut loader = MemoryLoader::new();
616 loader.insert(PathBuf::from("/root/snippet.lex"), "First paragraph.\n");
617 let handler = handler_with_loader(loader, PathBuf::from("/root"));
618 let ctx = make_ctx("snippet.lex", Some("/root/host.lex"));
619 let wire = handler
620 .on_resolve(&ctx)
621 .expect("on_resolve ok")
622 .expect("Some(WireNode)");
623
624 let items = from_wire_node(&wire).expect("from_wire ok");
628 assert!(
629 !items.is_empty(),
630 "from_wire on the included document must recover at least one item"
631 );
632 let saw_paragraph = items
634 .iter()
635 .any(|item| matches!(item, ContentItem::Paragraph(_)));
636 assert!(saw_paragraph, "included paragraph must survive round-trip");
637 }
638}