dmc_transform/builtin/
code_import.rs1use 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
12pub struct CodeImport {
21 pub base_dir: Option<PathBuf>,
22}
23
24type 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 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 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 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}