Skip to main content

dmc_transform/builtin/
code_import.rs

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