Skip to main content

rust_split/
lib.rs

1use std::collections::BTreeSet;
2use std::fmt;
3use std::ops::Range;
4
5use quote::ToTokens;
6use serde::{Deserialize, Serialize};
7use syn::spanned::Spanned;
8use syn::visit::{self, Visit};
9
10mod naming;
11pub mod plan;
12pub mod reassemble;
13pub use plan::{Oversized, Part, SplitPlan, plan_split};
14pub use reassemble::{OutputFile, SplitOutput, Topology, reassemble_bin, split_bin, split_mod};
15
16pub type Result<T> = std::result::Result<T, Error>;
17
18#[derive(Debug)]
19pub enum Error {
20    Parse(syn::Error),
21    NonCharBoundary { offset: usize },
22}
23
24impl fmt::Display for Error {
25    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
26        match self {
27            Self::Parse(error) => write!(formatter, "parse error: {error}"),
28            Self::NonCharBoundary { offset } => {
29                write!(formatter, "span offset {offset} is not a UTF-8 boundary")
30            }
31        }
32    }
33}
34
35impl std::error::Error for Error {}
36
37impl From<syn::Error> for Error {
38    fn from(error: syn::Error) -> Self {
39        Self::Parse(error)
40    }
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Exploded {
45    pub chunks: Vec<Chunk>,
46    pub manifest: Manifest,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct Chunk {
51    pub byte_range: ByteRange,
52    pub text: String,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56pub struct ByteRange {
57    pub start: usize,
58    pub end: usize,
59}
60
61impl From<Range<usize>> for ByteRange {
62    fn from(range: Range<usize>) -> Self {
63        Self {
64            start: range.start,
65            end: range.end,
66        }
67    }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71pub struct LineSpan {
72    pub start: usize,
73    pub end: usize,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct Manifest {
78    pub rows: Vec<ManifestRow>,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub struct ManifestRow {
83    pub idx: usize,
84    pub chunk_index: usize,
85    pub name: String,
86    pub kind: String,
87    pub byte_range: ByteRange,
88    pub span: ByteRange,
89    pub line_span: LineSpan,
90    pub loc: usize,
91    pub adjacency_hint: Vec<String>,
92}
93
94pub fn explode(src: &str) -> Result<Exploded> {
95    let file = syn::parse_file(src)?;
96    let mut items = Vec::with_capacity(file.items.len());
97    for item in &file.items {
98        let range = checked_range(src, item.span().byte_range())?;
99        items.push(ItemInfo {
100            range,
101            name: item_name(item),
102            kind: item_kind(item).to_owned(),
103            adjacency_candidates: item_adjacency_candidates(item),
104        });
105    }
106
107    if items.is_empty() {
108        if src.is_empty() {
109            return Ok(Exploded {
110                chunks: Vec::new(),
111                manifest: Manifest { rows: Vec::new() },
112            });
113        }
114        return Ok(single_preamble(src));
115    }
116
117    let sibling_names = items
118        .iter()
119        .map(|item| item.name.clone())
120        .collect::<BTreeSet<_>>();
121
122    let mut chunk_ranges = Vec::with_capacity(items.len() + 1);
123    let first_start = if has_inner_file_attr(&src[..items[0].range.start]) {
124        if items[0].range.start > 0 {
125            chunk_ranges.push(ChunkSeed {
126                kind: ChunkSeedKind::Preamble,
127                range: 0..items[0].range.start,
128            });
129        }
130        items[0].range.start
131    } else {
132        0
133    };
134
135    for index in 0..items.len() {
136        let start = if index == 0 {
137            first_start
138        } else {
139            split_gap(src, items[index - 1].range.end, items[index].range.start)
140        };
141        let end = if index + 1 == items.len() {
142            src.len()
143        } else {
144            split_gap(src, items[index].range.end, items[index + 1].range.start)
145        };
146        chunk_ranges.push(ChunkSeed {
147            kind: ChunkSeedKind::Item(index),
148            range: start..end,
149        });
150    }
151
152    let mut chunks = Vec::with_capacity(chunk_ranges.len());
153    let mut rows = Vec::with_capacity(chunk_ranges.len());
154    for seed in chunk_ranges {
155        let chunk_index = chunks.len();
156        let text = src[seed.range.clone()].to_owned();
157        chunks.push(Chunk {
158            byte_range: seed.range.clone().into(),
159            text,
160        });
161
162        match seed.kind {
163            ChunkSeedKind::Preamble => rows.push(preamble_row(src, chunk_index, seed.range)),
164            ChunkSeedKind::Item(item_index) => {
165                let item = &items[item_index];
166                let adjacency_hint = item
167                    .adjacency_candidates
168                    .iter()
169                    .filter(|name| sibling_names.contains(*name) && *name != &item.name)
170                    .cloned()
171                    .collect::<BTreeSet<_>>()
172                    .into_iter()
173                    .collect::<Vec<_>>();
174                rows.push(ManifestRow {
175                    idx: rows.len(),
176                    chunk_index,
177                    name: item.name.clone(),
178                    kind: item.kind.clone(),
179                    byte_range: seed.range.into(),
180                    span: item.range.clone().into(),
181                    line_span: line_span(src, &item.range),
182                    loc: loc(&chunks[chunk_index].text),
183                    adjacency_hint,
184                });
185            }
186        }
187    }
188
189    Ok(Exploded {
190        chunks,
191        manifest: Manifest { rows },
192    })
193}
194
195pub fn manifest_toml(manifest: &Manifest) -> Result<String> {
196    toml::to_string_pretty(manifest).map_err(|error| {
197        Error::Parse(syn::Error::new(
198            proc_macro2::Span::call_site(),
199            format!("manifest serialization failed: {error}"),
200        ))
201    })
202}
203
204#[derive(Debug)]
205struct ItemInfo {
206    range: Range<usize>,
207    name: String,
208    kind: String,
209    adjacency_candidates: BTreeSet<String>,
210}
211
212#[derive(Debug)]
213struct ChunkSeed {
214    kind: ChunkSeedKind,
215    range: Range<usize>,
216}
217
218#[derive(Debug)]
219enum ChunkSeedKind {
220    Preamble,
221    Item(usize),
222}
223
224fn single_preamble(src: &str) -> Exploded {
225    let chunk = Chunk {
226        byte_range: ByteRange {
227            start: 0,
228            end: src.len(),
229        },
230        text: src.to_owned(),
231    };
232    Exploded {
233        chunks: vec![chunk],
234        manifest: Manifest {
235            rows: vec![preamble_row(src, 0, 0..src.len())],
236        },
237    }
238}
239
240fn preamble_row(src: &str, chunk_index: usize, range: Range<usize>) -> ManifestRow {
241    ManifestRow {
242        idx: chunk_index,
243        chunk_index,
244        name: "__preamble".to_owned(),
245        kind: "preamble".to_owned(),
246        byte_range: range.clone().into(),
247        span: range.clone().into(),
248        line_span: line_span(src, &range),
249        loc: loc(&src[range]),
250        adjacency_hint: Vec::new(),
251    }
252}
253
254fn checked_range(src: &str, range: Range<usize>) -> Result<Range<usize>> {
255    if !src.is_char_boundary(range.start) {
256        return Err(Error::NonCharBoundary {
257            offset: range.start,
258        });
259    }
260    if !src.is_char_boundary(range.end) {
261        return Err(Error::NonCharBoundary { offset: range.end });
262    }
263    Ok(range)
264}
265
266fn has_inner_file_attr(gap: &str) -> bool {
267    gap.lines().any(|line| {
268        let line = line.trim_start();
269        line.starts_with("//!") || line.starts_with("#![")
270    })
271}
272
273/// Where the text between two items is divided. The previous item keeps the
274/// rest of its last line, any comment lines directly below it, and the first
275/// run of blank lines after those; everything after that run leads the next
276/// item. A section banner set off by blank lines (`// ---- Linking ----`)
277/// therefore heads the item below it. With no blank line in the gap, the
278/// comments lead the next item.
279fn split_gap(src: &str, start: usize, end: usize) -> usize {
280    let gap = &src[start..end];
281    if gap.trim().is_empty() {
282        return end;
283    }
284
285    let mut lines = gap.split_inclusive('\n');
286    // The first segment finishes the previous item's own last line.
287    let mut offset = match lines.next() {
288        Some(tail) if tail.ends_with('\n') => tail.len(),
289        _ => return start,
290    };
291    let after_tail = offset;
292    let mut cut = None;
293    for line in lines {
294        if line.trim().is_empty() {
295            offset += line.len();
296            cut = Some(offset);
297        } else if cut.is_some() {
298            break;
299        } else {
300            offset += line.len();
301        }
302    }
303    start + cut.unwrap_or(after_tail)
304}
305
306fn line_span(src: &str, range: &Range<usize>) -> LineSpan {
307    if src.is_empty() {
308        return LineSpan { start: 1, end: 1 };
309    }
310    let start = byte_to_line(src, range.start);
311    let end_offset = if range.end > range.start {
312        range.end.saturating_sub(1)
313    } else {
314        range.end
315    };
316    let end = byte_to_line(src, end_offset);
317    LineSpan { start, end }
318}
319
320fn byte_to_line(src: &str, offset: usize) -> usize {
321    src[..offset.min(src.len())]
322        .bytes()
323        .filter(|byte| *byte == b'\n')
324        .count()
325        + 1
326}
327
328fn loc(text: &str) -> usize {
329    if text.is_empty() {
330        0
331    } else {
332        text.lines().count().max(1)
333    }
334}
335
336fn item_kind(item: &syn::Item) -> &'static str {
337    match item {
338        syn::Item::Const(_) => "const",
339        syn::Item::Enum(_) => "enum",
340        syn::Item::ExternCrate(_) => "extern_crate",
341        syn::Item::Fn(_) => "fn",
342        syn::Item::ForeignMod(_) => "foreign_mod",
343        syn::Item::Impl(_) => "impl",
344        syn::Item::Macro(item) if is_macro_rules(item) => "macro_rules",
345        syn::Item::Macro(_) => "macro",
346        syn::Item::Mod(_) => "mod",
347        syn::Item::Static(_) => "static",
348        syn::Item::Struct(_) => "struct",
349        syn::Item::Trait(_) => "trait",
350        syn::Item::TraitAlias(_) => "trait_alias",
351        syn::Item::Type(_) => "type",
352        syn::Item::Union(_) => "union",
353        syn::Item::Use(_) => "use",
354        syn::Item::Verbatim(_) => "verbatim",
355        _ => "unknown",
356    }
357}
358
359fn item_name(item: &syn::Item) -> String {
360    match item {
361        syn::Item::Const(item) => item.ident.to_string(),
362        syn::Item::Enum(item) => item.ident.to_string(),
363        syn::Item::ExternCrate(item) => item.ident.to_string(),
364        syn::Item::Fn(item) => item.sig.ident.to_string(),
365        syn::Item::ForeignMod(item) => {
366            let name = item.abi.name.as_ref().map(|name| name.value());
367            match name {
368                Some(name) => format!("extern \"{name}\""),
369                None => "extern".to_owned(),
370            }
371        }
372        syn::Item::Impl(item) => impl_name(item),
373        syn::Item::Macro(item) => macro_name(item),
374        syn::Item::Mod(item) => item.ident.to_string(),
375        syn::Item::Static(item) => item.ident.to_string(),
376        syn::Item::Struct(item) => item.ident.to_string(),
377        syn::Item::Trait(item) => item.ident.to_string(),
378        syn::Item::TraitAlias(item) => item.ident.to_string(),
379        syn::Item::Type(item) => item.ident.to_string(),
380        syn::Item::Union(item) => item.ident.to_string(),
381        syn::Item::Use(item) => use_tree_name(&item.tree),
382        syn::Item::Verbatim(tokens) => tokens.to_string(),
383        _ => "unknown".to_owned(),
384    }
385}
386
387fn impl_name(item: &syn::ItemImpl) -> String {
388    let self_ty = normalize_tokens(&item.self_ty.to_token_stream().to_string());
389    if let Some((_, path, _)) = &item.trait_ {
390        let trait_name = normalize_tokens(&path.to_token_stream().to_string());
391        format!("impl {trait_name} for {self_ty}")
392    } else {
393        format!("impl {self_ty}")
394    }
395}
396
397fn macro_name(item: &syn::ItemMacro) -> String {
398    item.ident
399        .as_ref()
400        .map(ToString::to_string)
401        .unwrap_or_else(|| normalize_tokens(&item.mac.path.to_token_stream().to_string()))
402}
403
404fn is_macro_rules(item: &syn::ItemMacro) -> bool {
405    normalize_tokens(&item.mac.path.to_token_stream().to_string()) == "macro_rules"
406}
407
408fn use_tree_name(tree: &syn::UseTree) -> String {
409    match tree {
410        syn::UseTree::Path(path) => format!("{}::{}", path.ident, use_tree_name(&path.tree)),
411        syn::UseTree::Name(name) => name.ident.to_string(),
412        syn::UseTree::Rename(rename) => format!("{} as {}", rename.ident, rename.rename),
413        syn::UseTree::Glob(_) => "*".to_owned(),
414        syn::UseTree::Group(group) => {
415            let names = group
416                .items
417                .iter()
418                .map(use_tree_name)
419                .collect::<Vec<_>>()
420                .join(", ");
421            format!("{{ {names} }}")
422        }
423    }
424}
425
426fn normalize_tokens(value: &str) -> String {
427    value
428        .replace(" :: ", "::")
429        .replace(" < ", "<")
430        .replace(" >", ">")
431        .replace(" ,", ",")
432        .replace("( ", "(")
433        .replace(" )", ")")
434        .replace("[ ", "[")
435        .replace(" ]", "]")
436}
437
438fn item_adjacency_candidates(item: &syn::Item) -> BTreeSet<String> {
439    let mut visitor = AdjacencyVisitor::default();
440    visitor.visit_item(item);
441    visitor.names
442}
443
444/// Every identifier a single top-level item references (typed visitor + raw
445/// macro idents), used by the reassembler to copy only the imports a part
446/// actually names. `None` when the chunk does not parse as an item, signalling
447/// the caller to keep all imports rather than risk dropping a needed one.
448pub(crate) fn referenced_idents(chunk_text: &str) -> Option<BTreeSet<String>> {
449    let item = syn::parse_str::<syn::Item>(chunk_text).ok()?;
450    let mut visitor = DependencyVisitor::default();
451    visitor.visit_item(&item);
452    Some(visitor.names)
453}
454
455/// Every identifier a multi-item source fragment references — used for an
456/// extracted module body, whose `use super::*;` reaches the root's imports, so
457/// the root must keep the imports the body names. `None` when the fragment does
458/// not parse, signalling the caller to keep all imports.
459pub(crate) fn referenced_idents_in_source(src: &str) -> Option<BTreeSet<String>> {
460    let file = syn::parse_str::<syn::File>(src).ok()?;
461    let mut visitor = DependencyVisitor::default();
462    for item in &file.items {
463        visitor.visit_item(item);
464    }
465    Some(visitor.names)
466}
467
468/// Import dependencies include complete declarations (bounds, defaults, where
469/// clauses, attributes and signatures). Keep this separate from the reduced
470/// adjacency traversal so more complete imports do not change grouping hints.
471#[derive(Default)]
472struct DependencyVisitor {
473    names: BTreeSet<String>,
474}
475
476impl<'ast> Visit<'ast> for DependencyVisitor {
477    fn visit_ident(&mut self, ident: &'ast proc_macro2::Ident) {
478        self.names.insert(ident.to_string());
479    }
480
481    fn visit_macro(&mut self, mac: &'ast syn::Macro) {
482        collect_macro_idents(&mac.tokens, &mut self.names);
483        visit::visit_macro(self, mac);
484    }
485
486    fn visit_attribute(&mut self, attr: &'ast syn::Attribute) {
487        collect_macro_idents(&attr.meta.to_token_stream(), &mut self.names);
488        visit::visit_attribute(self, attr);
489    }
490}
491
492/// Recursively collect every identifier in a macro's token stream.
493fn collect_macro_idents(tokens: &proc_macro2::TokenStream, names: &mut BTreeSet<String>) {
494    for tree in tokens.clone() {
495        match tree {
496            proc_macro2::TokenTree::Ident(ident) => {
497                names.insert(ident.to_string());
498            }
499            proc_macro2::TokenTree::Group(group) => collect_macro_idents(&group.stream(), names),
500            _ => {}
501        }
502    }
503}
504
505#[derive(Default)]
506struct AdjacencyVisitor {
507    names: BTreeSet<String>,
508}
509
510impl<'ast> Visit<'ast> for AdjacencyVisitor {
511    fn visit_ident(&mut self, ident: &'ast proc_macro2::Ident) {
512        self.names.insert(ident.to_string());
513    }
514
515    fn visit_item_fn(&mut self, item: &'ast syn::ItemFn) {
516        for input in &item.sig.inputs {
517            self.visit_fn_arg(input);
518        }
519        self.visit_block(&item.block);
520    }
521
522    fn visit_item_const(&mut self, item: &'ast syn::ItemConst) {
523        self.visit_type(&item.ty);
524        self.visit_expr(&item.expr);
525    }
526
527    fn visit_item_static(&mut self, item: &'ast syn::ItemStatic) {
528        self.visit_type(&item.ty);
529        self.visit_expr(&item.expr);
530    }
531
532    fn visit_item_type(&mut self, item: &'ast syn::ItemType) {
533        self.visit_type(&item.ty);
534    }
535
536    fn visit_item_struct(&mut self, item: &'ast syn::ItemStruct) {
537        self.visit_fields(&item.fields);
538    }
539
540    fn visit_item_enum(&mut self, item: &'ast syn::ItemEnum) {
541        for variant in &item.variants {
542            self.visit_fields(&variant.fields);
543            if let Some((_, discriminant)) = &variant.discriminant {
544                self.visit_expr(discriminant);
545            }
546        }
547    }
548
549    fn visit_item_union(&mut self, item: &'ast syn::ItemUnion) {
550        self.visit_fields_named(&item.fields);
551    }
552
553    fn visit_item_trait(&mut self, item: &'ast syn::ItemTrait) {
554        for supertrait in &item.supertraits {
555            self.visit_type_param_bound(supertrait);
556        }
557        for trait_item in &item.items {
558            self.visit_trait_item(trait_item);
559        }
560    }
561
562    fn visit_item_trait_alias(&mut self, item: &'ast syn::ItemTraitAlias) {
563        for bound in &item.bounds {
564            self.visit_type_param_bound(bound);
565        }
566    }
567
568    fn visit_item_impl(&mut self, item: &'ast syn::ItemImpl) {
569        self.visit_type(&item.self_ty);
570        if let Some((_, path, _)) = &item.trait_ {
571            self.visit_path(path);
572        }
573        for impl_item in &item.items {
574            self.visit_impl_item(impl_item);
575        }
576    }
577
578    fn visit_item_macro(&mut self, item: &'ast syn::ItemMacro) {
579        self.visit_macro(&item.mac);
580    }
581
582    fn visit_macro(&mut self, mac: &'ast syn::Macro) {
583        // syn does not parse macro bodies, so references inside `json!{ ... }`,
584        // `vec![ foo() ]`, etc. are invisible to the typed visitor. Harvest
585        // every identifier from the raw token stream; the sibling-name filter
586        // discards the noise, keeping only real cross-references.
587        collect_macro_idents(&mac.tokens, &mut self.names);
588        visit::visit_macro(self, mac);
589    }
590
591    fn visit_item_mod(&mut self, item: &'ast syn::ItemMod) {
592        if let Some((_, items)) = &item.content {
593            for item in items {
594                self.visit_item(item);
595            }
596        }
597    }
598
599    fn visit_pat_ident(&mut self, pat: &'ast syn::PatIdent) {
600        self.names.insert(pat.ident.to_string());
601        visit::visit_pat_ident(self, pat);
602    }
603}