Skip to main content

dmc_transform/builtin/
code_import.rs

1use crate::pipeline::Transformer;
2use crate::visit::{NodeAction, Visitor, walk_root};
3use dmc_diagnostic::Code;
4use dmc_diagnostic::metadata::{Origin, SourceMeta};
5use dmc_parser::ast::*;
6use duck_diagnostic::{Diagnostic, Label};
7use std::path::PathBuf;
8use std::sync::Arc;
9
10/// Resolve `file=path[{ranges}]` directives in fenced code-block info
11/// strings, replacing the block's body with the file contents (optionally
12/// sliced by 1-based line ranges).
13///
14/// Path resolution (first hit wins):
15/// 1. explicit `base_dir` (via [`CodeImport::with_base_dir`])
16/// 2. parent dir of `meta.origin` when it's [`Origin::File`]
17/// 3. cwd, with a [`Code::BaseDirNotFound`] warning (paths must be absolute)
18pub struct CodeImport {
19  pub base_dir: Option<PathBuf>,
20}
21
22/// Parsed `file=path[{ranges}]`: the file path plus optional 1-based
23/// inclusive line ranges.
24type FileMeta = (String, Option<Vec<(usize, usize)>>);
25
26impl Default for CodeImport {
27  fn default() -> Self {
28    Self::new()
29  }
30}
31
32impl CodeImport {
33  pub fn new() -> Self {
34    Self { base_dir: None }
35  }
36
37  pub fn with_base_dir(p: impl Into<PathBuf>) -> Self {
38    Self { base_dir: Some(p.into()) }
39  }
40
41  fn parse_file_meta(meta: &str) -> Option<FileMeta> {
42    for part in meta.split_whitespace() {
43      if let Some(rest) = part.strip_prefix("file=") {
44        let raw = rest.trim_matches(|c| c == '"' || c == '\'');
45        if let Some((path, range)) = raw.split_once('{') {
46          let range = range.trim_end_matches('}');
47          return Some((path.to_string(), Some(Self::parse_ranges(range))));
48        }
49        return Some((raw.to_string(), None));
50      }
51    }
52    None
53  }
54
55  /// Parse `1,3-5,8` style ranges. Malformed tokens drop silently.
56  fn parse_ranges(spec: &str) -> Vec<(usize, usize)> {
57    let mut out = Vec::new();
58    for token in spec.split(',') {
59      let token = token.trim();
60      if let Some((a, b)) = token.split_once('-') {
61        if let (Ok(a), Ok(b)) = (a.trim().parse::<usize>(), b.trim().parse::<usize>())
62          && a >= 1
63          && b >= a
64        {
65          out.push((a, b));
66        }
67      } else if let Ok(n) = token.parse::<usize>()
68        && n >= 1
69      {
70        out.push((n, n));
71      }
72    }
73    out
74  }
75
76  /// Pick 1-based inclusive line ranges from `src`. Each line keeps a
77  /// trailing `\n`.
78  fn slice_lines(src: &str, ranges: &[(usize, usize)]) -> String {
79    let lines: Vec<&str> = src.lines().collect();
80    let mut out = String::new();
81    for (a, b) in ranges {
82      let start = a.saturating_sub(1);
83      let end = (*b).min(lines.len());
84      for l in lines.iter().take(end).skip(start) {
85        out.push_str(l);
86        out.push('\n');
87      }
88    }
89    out
90  }
91}
92
93impl Transformer for CodeImport {
94  fn name(&self) -> &str {
95    "code-import"
96  }
97
98  fn transform(
99    &self,
100    doc: &mut Document,
101    meta: &SourceMeta,
102    diag_engine: &mut duck_diagnostic::DiagnosticEngine<Code>,
103  ) {
104    let base_dir = self.base_dir.clone().or_else(|| match &meta.origin {
105      Origin::File(p) => p.parent().map(|p| p.to_path_buf()),
106      _ => None,
107    });
108
109    // Walk continues even on warning so absolute `file=` paths still resolve.
110    if base_dir.is_none() {
111      diag_engine.emit(Diagnostic::new(
112        Code::BaseDirNotFound,
113        format!(
114          "code-import: source has no on-disk parent (origin = {:?}); relative `file=` paths cannot be resolved",
115          meta.origin
116        ),
117      ));
118    }
119
120    let mut v = Apply { base_dir, meta_path: meta.path.clone(), pending: Vec::new() };
121    walk_root(&mut doc.children, &mut v);
122    for d in v.pending.drain(..) {
123      diag_engine.emit(d);
124    }
125  }
126}
127
128struct Apply {
129  base_dir: Option<PathBuf>,
130  meta_path: Arc<str>,
131  pending: Vec<Diagnostic<Code>>,
132}
133
134impl Visitor for Apply {
135  fn visit_node(&mut self, node: &mut Node) -> NodeAction {
136    if let Node::CodeBlock(cb) = node
137      && let Some(meta) = cb.meta.as_deref()
138      && let Some((file, ranges)) = CodeImport::parse_file_meta(meta)
139    {
140      if let Some(rs) = &ranges
141        && rs.is_empty()
142      {
143        self.pending.push(Diagnostic::new(
144          Code::InvalidLineRange,
145          format!("code-import: line range in `{}` is empty / malformed", meta),
146        ));
147        return NodeAction::Keep;
148      }
149
150      let path = match &self.base_dir {
151        Some(b) => b.join(&file),
152        None => PathBuf::from(&file),
153      };
154      match std::fs::read_to_string(&path) {
155        Ok(content) => {
156          cb.value = match ranges {
157            Some(rs) => CodeImport::slice_lines(&content, &rs),
158            None => content,
159          };
160        },
161        Err(e) => {
162          self.pending.push(
163            Diagnostic::new(Code::ImportFileNotFound, format!("code-import: cannot read {} ({})", path.display(), e))
164              .with_label(Label::primary(cb.span.clone(), Some(format!("imported from {}", self.meta_path)))),
165          );
166        },
167      }
168    }
169    NodeAction::Keep
170  }
171}
172
173#[cfg(test)]
174mod tests {
175  use super::*;
176
177  #[test]
178  fn parse_file_meta_no_range() {
179    assert_eq!(CodeImport::parse_file_meta("file=foo.rs"), Some(("foo.rs".into(), None)));
180    assert_eq!(CodeImport::parse_file_meta("file=\"foo.rs\""), Some(("foo.rs".into(), None)));
181  }
182
183  #[test]
184  fn parse_file_meta_with_range() {
185    let (p, r) = CodeImport::parse_file_meta("file=foo.rs{1,3-5,8}").unwrap();
186    assert_eq!(p, "foo.rs");
187    assert_eq!(r, Some(vec![(1, 1), (3, 5), (8, 8)]));
188  }
189
190  #[test]
191  fn slice_lines_picks_ranges() {
192    let src = "a\nb\nc\nd\ne\n";
193    let out = CodeImport::slice_lines(src, &[(2, 3)]);
194    assert_eq!(out, "b\nc\n");
195    let out = CodeImport::slice_lines(src, &[(1, 1), (4, 5)]);
196    assert_eq!(out, "a\nd\ne\n");
197  }
198}