Skip to main content

specfold_core/import/
sources.rs

1//! Expands the map's patterns into import units: one main file with its chapters and
2//! attachments.
3
4use super::map::{ImportMap, SourceRule};
5use crate::workspace::owners::{PathMatch, matcher};
6use ignore::WalkBuilder;
7use regex::Regex;
8use std::collections::BTreeSet;
9use std::path::Path;
10
11#[derive(Debug, Clone)]
12pub struct Unit {
13    pub rule: usize,
14    /// The file or directory that matched.
15    pub source: String,
16    pub main: String,
17    pub chapters: Vec<String>,
18    pub attachments: Vec<String>,
19}
20
21const MAIN_NAMES: &[&str] = &["index.md", "README.md", "readme.md", "00-index.md", "_index.md"];
22
23fn all_files(root: &Path) -> Vec<String> {
24    WalkBuilder::new(root)
25        .hidden(false)
26        .require_git(false)
27        .filter_entry(|e| e.file_name() != ".git")
28        .build()
29        .flatten()
30        .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
31        .filter_map(|e| e.path().strip_prefix(root).ok().map(|p| p.to_string_lossy().replace('\\', "/")))
32        .collect()
33}
34
35/// The main file, chapters and attachments of a directory, leaving out files an earlier rule
36/// already took (a nested bundle belongs to the rule that matched it first).
37fn inside<'a>(files: &'a [String], dir: &str, claimed: &BTreeSet<String>) -> Vec<&'a String> {
38    let prefix = format!("{dir}/");
39    files.iter().filter(|f| f.starts_with(&prefix) && !claimed.contains(*f)).collect()
40}
41
42fn bundle(files: &[String], dir: &str, claimed: &BTreeSet<String>) -> Option<(String, Vec<String>, Vec<String>)> {
43    let inside = inside(files, dir, claimed);
44    let main = MAIN_NAMES
45        .iter()
46        .map(|n| format!("{dir}/{n}"))
47        .find(|m| inside.contains(&m))
48        .or_else(|| inside.iter().filter(|f| f.ends_with(".md")).min().map(|s| s.to_string()))?;
49    let chapters = inside.iter().filter(|f| f.ends_with(".md") && **f != &main).map(|s| s.to_string()).collect();
50    let attachments = inside.iter().filter(|f| !f.ends_with(".md")).map(|s| s.to_string()).collect();
51    Some((main, chapters, attachments))
52}
53
54pub fn expand(root: &Path, map: &ImportMap) -> Vec<Unit> {
55    let files = all_files(root);
56    let mut claimed: BTreeSet<String> = BTreeSet::new();
57    let mut out = vec![];
58    for (ri, rule) in map.sources.iter().enumerate() {
59        out.extend(expand_rule(root, &files, ri, rule, &mut claimed));
60    }
61    out
62}
63
64/// A redirect rule claims only the files whose text is a stub, so a broad glob over a directory
65/// that mixes stubs and real documents leaves the documents to the rules after it.
66fn is_stub(root: &Path, file: &str, re: Option<&Regex>) -> bool {
67    let Some(re) = re else { return true };
68    std::fs::read_to_string(root.join(file)).is_ok_and(|t| re.is_match(&t))
69}
70
71fn expand_rule(
72    root: &Path,
73    files: &[String],
74    ri: usize,
75    rule: &SourceRule,
76    claimed: &mut BTreeSet<String>,
77) -> Vec<Unit> {
78    let dir_mode = rule.glob.ends_with('/');
79    let Some(m) = matcher(rule.glob.trim_end_matches('/')) else { return vec![] };
80    let exclude: Vec<PathMatch> = rule.exclude.iter().filter_map(|e| matcher(e)).collect();
81    let skip = |p: &str, claimed: &BTreeSet<String>| exclude.iter().any(|e| e.is_match(p)) || claimed.contains(p);
82    let mut out = vec![];
83    if dir_mode {
84        let mut dirs: BTreeSet<String> = BTreeSet::new();
85        for f in files {
86            let mut parts: Vec<&str> = f.split('/').collect();
87            parts.pop();
88            for n in 1..=parts.len() {
89                let d = parts[..n].join("/");
90                if m.is_match(&d) {
91                    dirs.insert(d);
92                }
93            }
94        }
95        for d in dirs {
96            if skip(&d, claimed) {
97                continue;
98            }
99            let unit = match bundle(files, &d, claimed) {
100                Some((main, chapters, attachments)) => Some((main, chapters, attachments)),
101                None if rule.wrap || rule.drop => {
102                    let all: Vec<String> = inside(files, &d, claimed).into_iter().cloned().collect();
103                    (!all.is_empty()).then(|| (String::new(), vec![], all))
104                }
105                None => None,
106            };
107            if let Some((main, chapters, attachments)) = unit {
108                for f in std::iter::once(&main).chain(&chapters).chain(&attachments) {
109                    claimed.insert(f.clone());
110                }
111                claimed.insert(d.clone());
112                out.push(Unit { rule: ri, source: d, main, chapters, attachments });
113            }
114        }
115        return out;
116    }
117    let stub_re = rule.redirect.as_deref().and_then(|r| Regex::new(r).ok());
118    for f in files {
119        if !m.is_match(f) || skip(f, claimed) {
120            continue;
121        }
122        if rule.redirect.is_some() && !is_stub(root, f, stub_re.as_ref()) {
123            continue;
124        }
125        claimed.insert(f.clone());
126        if rule.wrap && !f.ends_with(".md") {
127            out.push(Unit {
128                rule: ri,
129                source: f.clone(),
130                main: String::new(),
131                chapters: vec![],
132                attachments: vec![f.clone()],
133            });
134            continue;
135        }
136        let (mut chapters, mut attachments) = (vec![], vec![]);
137        if rule.sibling_dir {
138            let dir = f.trim_end_matches(".md");
139            let taken: Vec<String> = inside(files, dir, claimed).into_iter().cloned().collect();
140            for g in taken {
141                if g.ends_with(".md") {
142                    chapters.push(g.clone())
143                } else {
144                    attachments.push(g.clone())
145                }
146                claimed.insert(g);
147            }
148        }
149        out.push(Unit { rule: ri, source: f.clone(), main: f.clone(), chapters, attachments });
150    }
151    out
152}