use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use syn::{Expr, File, Item, Type};
pub struct ArrayItem {
pub name: String,
pub vis: String,
pub is_static: bool,
pub elem_type: String,
pub is_reference: bool,
pub elements: Vec<Expr>,
pub attrs: Vec<String>,
}
pub struct ArrayAnalysis {
pub splittable: Vec<ArrayItem>,
pub use_items: Vec<Item>,
pub other_items: Vec<Item>,
}
fn estimate_elements_lines(elem_type: &str, elements: &[Expr]) -> usize {
let body = render_slice_literal(elem_type, elements, true);
body.lines().count()
}
fn render_slice_literal(_elem_type: &str, elements: &[Expr], as_reference: bool) -> String {
let elems_ts = elements.iter().map(|e| quote::quote! { #e });
let inner = quote::quote! { [ #(#elems_ts),* ] };
let full = if as_reference {
quote::quote! { & #inner }
} else {
inner
};
let wrapper: syn::Stmt = syn::parse_quote! { let _x = #full; };
let file = syn::File {
shebang: None,
attrs: Vec::new(),
items: vec![syn::parse_quote! {
fn __render() { #wrapper }
}],
};
let rendered = prettyplease::unparse(&file);
rendered
.lines()
.skip_while(|l| !l.contains("let _x = "))
.collect::<Vec<_>>()
.join("\n")
.replacen(" let _x = ", "", 1)
.trim_end()
.trim_end_matches('}')
.trim_end()
.trim_end_matches(';')
.to_string()
}
fn element_type_of(ty: &Type) -> Option<(String, bool)> {
match ty {
Type::Reference(r) => {
if let Type::Slice(s) = &*r.elem {
let t = &*s.elem;
Some((quote::quote! { #t }.to_string(), true))
} else {
None
}
}
Type::Slice(s) => {
let t = &*s.elem;
Some((quote::quote! { #t }.to_string(), false))
}
Type::Array(a) => {
let t = &*a.elem;
Some((quote::quote! { #t }.to_string(), false))
}
_ => None,
}
}
fn elements_of(expr: &Expr) -> Option<(Vec<Expr>, bool)> {
match expr {
Expr::Reference(r) => {
if let Expr::Array(arr) = &*r.expr {
Some((arr.elems.iter().cloned().collect(), true))
} else {
None
}
}
Expr::Array(arr) => Some((arr.elems.iter().cloned().collect(), false)),
_ => None,
}
}
fn render_attrs(attrs: &[syn::Attribute]) -> Vec<String> {
attrs
.iter()
.map(|a| {
let ts = quote::quote! { #a };
ts.to_string()
})
.collect()
}
fn render_vis(vis: &syn::Visibility) -> String {
match vis {
syn::Visibility::Inherited => String::new(),
other => quote::quote! { #other }.to_string(),
}
}
fn tokenize_idents(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
for ch in text.chars() {
if ch.is_alphanumeric() || ch == '_' {
cur.push(ch);
} else {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
fn use_leaf_symbols(use_item: &Item) -> Vec<String> {
fn walk(tree: &syn::UseTree, out: &mut Vec<String>) {
match tree {
syn::UseTree::Path(p) => walk(&p.tree, out),
syn::UseTree::Name(n) => out.push(n.ident.to_string()),
syn::UseTree::Rename(r) => out.push(r.rename.to_string()),
syn::UseTree::Glob(_) => out.push("*".to_string()),
syn::UseTree::Group(g) => {
for t in &g.items {
walk(t, out);
}
}
}
}
let mut out = Vec::new();
if let Item::Use(u) = use_item {
walk(&u.tree, &mut out);
}
out
}
fn use_imports_any(use_item: &Item, referenced: &std::collections::HashSet<String>) -> bool {
let leaves = use_leaf_symbols(use_item);
leaves.iter().any(|s| s == "*" || referenced.contains(s))
}
pub fn analyse_arrays(file: &File, max_lines: usize) -> ArrayAnalysis {
let mut splittable = Vec::new();
let mut use_items = Vec::new();
let mut other_items = Vec::new();
for item in &file.items {
match item {
Item::Use(_) => {
use_items.push(item.clone());
}
Item::Static(s) => {
if let (Some((elem_type, _ty_ref)), Some((elements, is_reference))) =
(element_type_of(&s.ty), elements_of(&s.expr))
{
if !elements.is_empty()
&& estimate_elements_lines(&elem_type, &elements) > max_lines
{
splittable.push(ArrayItem {
name: s.ident.to_string(),
vis: render_vis(&s.vis),
is_static: true,
elem_type,
is_reference,
elements,
attrs: render_attrs(&s.attrs),
});
continue;
}
}
other_items.push(item.clone());
}
Item::Const(c) => {
if let (Some((elem_type, _ty_ref)), Some((elements, is_reference))) =
(element_type_of(&c.ty), elements_of(&c.expr))
{
if !elements.is_empty()
&& estimate_elements_lines(&elem_type, &elements) > max_lines
{
splittable.push(ArrayItem {
name: c.ident.to_string(),
vis: render_vis(&c.vis),
is_static: false,
elem_type,
is_reference,
elements,
attrs: render_attrs(&c.attrs),
});
continue;
}
}
other_items.push(item.clone());
}
_ => other_items.push(item.clone()),
}
}
ArrayAnalysis {
splittable,
use_items,
other_items,
}
}
fn chunk_elements(elements: &[Expr], n: usize) -> Vec<Vec<Expr>> {
if n <= 1 {
return vec![elements.to_vec()];
}
let total = elements.len();
let base = total / n;
let rem = total % n;
let mut chunks = Vec::with_capacity(n);
let mut start = 0;
for i in 0..n {
let take = base + usize::from(i < rem);
let end = (start + take).min(total);
chunks.push(elements[start..end].to_vec());
start = end;
if start >= total {
break;
}
}
chunks
}
fn chunk_count_for(item: &ArrayItem, max_lines: usize) -> usize {
let total_lines = estimate_elements_lines(&item.elem_type, &item.elements);
let budget = (max_lines * 7 / 10).max(1);
total_lines.div_ceil(budget).max(2)
}
fn generate_chunk_file(
item: &ArrayItem,
part_index: usize,
elements: &[Expr],
use_items: &[Item],
) -> String {
let mut content = String::new();
content.push_str("// Copyright 2026 COOLJAPAN OU (Team KitaSan)\n");
content.push_str("// SPDX-License-Identifier: Apache-2.0\n");
content.push_str("//! Auto-generated array chunk.\n");
content.push_str("//!\n");
content.push_str("//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)\n\n");
if !use_items.is_empty() {
let deepened: Vec<Item> = use_items
.iter()
.map(crate::module_generator::deepen_super_in_use)
.collect();
let formatted = prettyplease::unparse(&syn::File {
shebang: None,
attrs: Vec::new(),
items: deepened,
});
content.push_str(&formatted);
content.push('\n');
}
let part_name = format!("{}_PART_{}", item.name, part_index);
let body = render_slice_literal(&item.elem_type, elements, item.is_reference);
let ty = if item.is_reference {
format!("&[{}]", item.elem_type)
} else {
format!("[{}; {}]", item.elem_type, elements.len())
};
content.push_str(&format!(
"pub(super) static {}: {} = {};\n",
part_name, ty, body
));
content
}
fn generate_mod_rs(
analysis: &ArrayAnalysis,
chunk_plan: &[(usize, Vec<usize>)],
file_inner_docs: &[String],
) -> String {
let mut content = String::new();
content.push_str("// Copyright 2026 COOLJAPAN OU (Team KitaSan)\n");
content.push_str("// SPDX-License-Identifier: Apache-2.0\n");
for doc in file_inner_docs {
content.push_str(doc);
content.push('\n');
}
content.push('\n');
let other_rendered = if analysis.other_items.is_empty() {
String::new()
} else {
prettyplease::unparse(&syn::File {
shebang: None,
attrs: Vec::new(),
items: analysis.other_items.clone(),
})
};
let mut referenced: std::collections::HashSet<String> = std::collections::HashSet::new();
for item in &analysis.splittable {
for ident in tokenize_idents(&item.elem_type) {
referenced.insert(ident);
}
}
for ident in tokenize_idents(&other_rendered) {
referenced.insert(ident);
}
let kept_uses: Vec<Item> = analysis
.use_items
.iter()
.filter(|u| use_imports_any(u, &referenced))
.cloned()
.collect();
if !kept_uses.is_empty() {
let formatted = prettyplease::unparse(&syn::File {
shebang: None,
attrs: Vec::new(),
items: kept_uses,
});
content.push_str(&formatted);
content.push('\n');
}
if !other_rendered.is_empty() {
content.push_str(&other_rendered);
content.push('\n');
}
let mut all_chunk_ids: Vec<usize> = chunk_plan
.iter()
.flat_map(|(_, ids)| ids.iter().copied())
.collect();
all_chunk_ids.sort_unstable();
all_chunk_ids.dedup();
for id in &all_chunk_ids {
content.push_str(&format!("mod chunk_{};\n", id));
}
content.push('\n');
for (item_pos, chunk_ids) in chunk_plan {
let item = &analysis.splittable[*item_pos];
content.push_str(&reconstruct_item(item, chunk_ids));
content.push('\n');
}
content
}
fn reconstruct_item(item: &ArrayItem, chunk_ids: &[usize]) -> String {
let name = &item.name;
let t = &item.elem_type;
let mut out = String::new();
let attrs_block: String = item
.attrs
.iter()
.map(|a| format!("{}\n", a))
.collect::<String>();
let parts_entries: Vec<String> = chunk_ids
.iter()
.map(|id| format!("chunk_{id}::{name}_PART_{id}"))
.collect();
out.push_str(&format!(
"const {name}_PARTS: &[&[{t}]] = &[{}];\n",
parts_entries.join(", ")
));
out.push_str(&format!(
"const {name}_LEN: usize = {{\n \
const fn total(parts: &[&[{t}]]) -> usize {{\n \
let mut n = 0;\n let mut i = 0;\n \
while i < parts.len() {{\n n += parts[i].len();\n i += 1;\n }}\n \
n\n }}\n total({name}_PARTS)\n}};\n"
));
out.push_str(&format!(
"static {name}_STORAGE: [{t}; {name}_LEN] = {{\n \
const fn build() -> [{t}; {name}_LEN] {{\n \
let mut out = [{name}_PARTS[0][0]; {name}_LEN];\n \
let mut w = 0;\n let mut p = 0;\n \
while p < {name}_PARTS.len() {{\n \
let part = {name}_PARTS[p];\n let mut i = 0;\n \
while i < part.len() {{\n out[w] = part[i];\n w += 1;\n i += 1;\n }}\n \
p += 1;\n }}\n out\n }}\n build()\n}};\n"
));
let vis = if item.vis.is_empty() {
String::new()
} else {
format!("{} ", item.vis)
};
let kw = if item.is_static { "static" } else { "const" };
out.push_str(&attrs_block);
out.push_str(&format!("{vis}{kw} {name}: &[{t}] = &{name}_STORAGE;\n"));
out
}
fn file_inner_docs(file: &File) -> Vec<String> {
file.attrs
.iter()
.filter(|a| matches!(a.style, syn::AttrStyle::Inner(_)))
.map(|a| {
let ts = quote::quote! { #a };
let s = ts.to_string();
if let Some(doc) = parse_doc_attr(&s) {
format!("//!{}", doc)
} else {
s
}
})
.collect()
}
fn parse_doc_attr(s: &str) -> Option<String> {
let marker = "doc = \"";
let start = s.find(marker)? + marker.len();
let rest = &s[start..];
let end = rest.rfind('"')?;
Some(rest[..end].replace("\\\"", "\"").replace("\\\\", "\\"))
}
pub fn run_split_arrays(input_file: &Path, max_lines: usize, dry_run: bool) -> Result<bool> {
use std::fs;
let source = fs::read_to_string(input_file)
.with_context(|| format!("Failed to read input file: {}", input_file.display()))?;
let syntax_tree: File = syn::parse_file(&source)
.with_context(|| format!("Failed to parse Rust file: {}", input_file.display()))?;
let analysis = analyse_arrays(&syntax_tree, max_lines);
if analysis.splittable.is_empty() {
println!(
"No oversized array literals (> {} lines) found in {}",
max_lines,
input_file.display()
);
return Ok(false);
}
let mut chunk_plan: Vec<(usize, Vec<usize>)> = Vec::new();
let mut chunk_files: Vec<(usize, String)> = Vec::new(); let mut next_chunk_id = 0usize;
for (item_pos, item) in analysis.splittable.iter().enumerate() {
let n = chunk_count_for(item, max_lines);
let chunks = chunk_elements(&item.elements, n);
let mut ids = Vec::with_capacity(chunks.len());
for chunk in &chunks {
let id = next_chunk_id;
next_chunk_id += 1;
ids.push(id);
let content = generate_chunk_file(item, id, chunk, &analysis.use_items);
chunk_files.push((id, content));
}
chunk_plan.push((item_pos, ids));
}
let parent = input_file.parent().unwrap_or_else(|| Path::new("."));
let stem = input_file
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| {
anyhow::anyhow!("Cannot determine file stem for {}", input_file.display())
})?;
let output_dir: PathBuf = parent.join(stem);
let docs = file_inner_docs(&syntax_tree);
let mod_content = generate_mod_rs(&analysis, &chunk_plan, &docs);
if dry_run {
println!(
"\nDRY RUN — array split of {} ({} splittable item(s)):",
input_file.display(),
analysis.splittable.len()
);
println!(" would create directory: {}/", output_dir.display());
println!(" mod.rs ({} lines)", mod_content.lines().count());
for (id, content) in &chunk_files {
println!(" chunk_{}.rs ({} lines)", id, content.lines().count());
}
for (item_pos, ids) in &chunk_plan {
println!(
" {} -> {} chunks",
analysis.splittable[*item_pos].name,
ids.len()
);
}
return Ok(true);
}
fs::create_dir_all(&output_dir)
.with_context(|| format!("Cannot create output dir: {}", output_dir.display()))?;
for (id, content) in &chunk_files {
let path = output_dir.join(format!("chunk_{}.rs", id));
fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display()))?;
if let Err(e) = syn::parse_file(content) {
eprintln!(
"⚠️ Warning: generated {} may contain syntax errors: {}",
path.display(),
e
);
}
println!("Created: {}", path.display());
}
let mod_path = output_dir.join("mod.rs");
fs::write(&mod_path, &mod_content).with_context(|| "Failed to write mod.rs")?;
if let Err(e) = syn::parse_file(&mod_content) {
eprintln!(
"⚠️ Warning: generated {} may contain syntax errors: {}",
mod_path.display(),
e
);
}
println!("Created: {}", mod_path.display());
if input_file.exists() {
fs::remove_file(input_file)
.with_context(|| format!("Cannot remove original file: {}", input_file.display()))?;
println!("Removed: {}", input_file.display());
}
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn analyse_str(src: &str, max_lines: usize) -> ArrayAnalysis {
let file = syn::parse_file(src).expect("parse");
analyse_arrays(&file, max_lines)
}
#[test]
fn detects_oversized_reference_array() {
let mut body = String::from(
"#[derive(Clone, Copy)]\npub struct A { pub x: u32 }\n\
pub static T: &[A] = &[\n",
);
for i in 0..50 {
body.push_str(&format!(" A {{ x: {} }},\n", i));
}
body.push_str("];\n");
let a = analyse_str(&body, 10);
assert_eq!(a.splittable.len(), 1);
assert_eq!(a.splittable[0].name, "T");
assert!(a.splittable[0].is_reference);
assert_eq!(a.splittable[0].elements.len(), 50);
}
#[test]
fn ignores_small_array() {
let src = "pub static T: &[u32] = &[1, 2, 3];\n";
let a = analyse_str(src, 10);
assert!(a.splittable.is_empty());
}
#[test]
fn chunking_is_even_and_complete() {
let elems: Vec<Expr> = (0..10)
.map(|i| syn::parse_str::<Expr>(&i.to_string()).expect("expr"))
.collect();
let chunks = chunk_elements(&elems, 3);
let total: usize = chunks.iter().map(|c| c.len()).sum();
assert_eq!(total, 10);
assert_eq!(chunks.len(), 3);
assert_eq!(chunks[0].len(), 4);
assert_eq!(chunks[1].len(), 3);
assert_eq!(chunks[2].len(), 3);
}
#[test]
fn end_to_end_split_compiles_shape() {
let tmp = std::env::temp_dir().join(format!("splitrs_arr_test_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).expect("mkdir");
let input = tmp.join("data.rs");
let mut body = String::from(
"//! Data table.\n#[derive(Clone, Copy)]\npub struct A { pub x: u32, pub y: f64 }\n\
pub static TABLE: &[A] = &[\n",
);
for i in 0..60 {
body.push_str(&format!(" A {{ x: {}, y: {}.0 }},\n", i, i));
}
body.push_str("];\n");
fs::write(&input, &body).expect("write input");
let split = run_split_arrays(&input, 15, false).expect("split");
assert!(split);
assert!(!input.exists(), "original should be removed");
let mod_rs = fs::read_to_string(tmp.join("data").join("mod.rs")).expect("mod.rs");
assert!(mod_rs.contains("pub static TABLE: &[A] = &TABLE_STORAGE;"));
assert!(mod_rs.contains("const TABLE_PARTS: &[&[A]]"));
let mut chunk_count = 0;
for entry in fs::read_dir(tmp.join("data")).expect("readdir") {
let p = entry.expect("entry").path();
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.starts_with("chunk_") {
chunk_count += 1;
let c = fs::read_to_string(&p).expect("chunk");
syn::parse_file(&c).expect("chunk parses");
assert!(c.contains("pub(super) static TABLE_PART_"));
}
}
assert!(chunk_count >= 2, "expected multiple chunks");
syn::parse_file(&mod_rs).expect("mod.rs parses");
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn preserves_other_items_and_docs() {
let tmp = std::env::temp_dir().join(format!("splitrs_arr_test2_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).expect("mkdir");
let input = tmp.join("mixed.rs");
let mut body = String::from(
"//! Mixed module.\nuse std::fmt;\n\
#[derive(Clone, Copy)]\npub struct B { pub v: i32 }\n\
pub fn helper() -> i32 { 7 }\n\
pub static BIG: &[B] = &[\n",
);
for i in 0..40 {
body.push_str(&format!(" B {{ v: {} }},\n", i));
}
body.push_str("];\n");
fs::write(&input, &body).expect("write");
run_split_arrays(&input, 12, false).expect("split");
let mod_rs = fs::read_to_string(tmp.join("mixed").join("mod.rs")).expect("mod.rs");
assert!(mod_rs.contains("pub fn helper"));
assert!(mod_rs.contains("pub struct B"));
assert!(mod_rs.contains("//! Mixed module."));
syn::parse_file(&mod_rs).expect("parses");
let _ = fs::remove_dir_all(&tmp);
}
}