1use std::{2 cmp::Reverse,3 fs,4 path::{Path, PathBuf},5};67use jrsonnet_pack::{decode_text, quote_jsonnet};8use jrsonnet_rowan_parser::{AstNode, AstToken, nodes::ExprImport};9use tracing::{debug, error, info, warn};1011#[derive(Debug, Clone)]12pub struct Rule {13 pub legacy: String,14 pub canonical: String,15}1617pub fn rules_from_vendor(vendor_dir: &Path) -> Vec<Rule> {18 let mut rules = Vec::new();19 let entries = match fs::read_dir(vendor_dir) {20 Ok(e) => e,21 Err(e) => {22 warn!("read_dir {}: {e}", vendor_dir.display());23 return rules;24 }25 };26 for entry in entries.flatten() {27 let path = entry.path();28 let Ok(meta) = entry.file_type() else {29 continue;30 };31 if !meta.is_symlink() {32 continue;33 }34 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {35 continue;36 };37 let target = match fs::read_link(&path) {38 Ok(t) => t,39 Err(e) => {40 warn!("readlink {}: {e}", path.display());41 continue;42 }43 };44 let target_str = target.to_string_lossy().replace('\\', "/");45 let canonical = target_str.trim_end_matches('/').to_owned();46 if canonical.is_empty() || canonical.starts_with('/') {47 continue;48 }49 rules.push(Rule {50 legacy: name.to_owned(),51 canonical,52 });53 }54 rules.sort_by_key(|b| Reverse(b.legacy.len()));55 rules56}5758pub struct Stats {59 pub files_scanned: usize,60 pub files_modified: usize,61 pub imports_rewritten: usize,62}6364fn apply_rules(name: &str, rules: &[Rule]) -> Option<String> {65 for r in rules {66 if name == r.legacy {67 return Some(r.canonical.clone());68 }69 if let Some(rest) = name.strip_prefix(&format!("{}/", r.legacy)) {70 return Some(format!("{}/{}", r.canonical, rest));71 }72 }73 None74}7576fn rewrite_source(source: &str, rules: &[Rule]) -> Option<(String, usize)> {77 let (file, errs) = jrsonnet_rowan_parser::parse(source);78 if !errs.is_empty() {79 return None;80 }81 let mut edits: Vec<(usize, usize, String)> = Vec::new();82 for node in file.syntax().descendants() {83 let Some(imp) = ExprImport::cast(node) else {84 continue;85 };86 let Some(text) = imp.text() else { continue };87 let Some(decoded) = decode_text(&text) else {88 continue;89 };90 let Some(new_value) = apply_rules(&decoded, rules) else {91 continue;92 };93 let range = text.syntax().text_range();94 let start: usize = range.start().into();95 let end: usize = range.end().into();96 edits.push((start, end, quote_jsonnet(&new_value)));97 }98 if edits.is_empty() {99 return None;100 }101 edits.sort_by_key(|(s, _, _)| *s);102 let count = edits.len();103 let mut out = String::with_capacity(source.len());104 let mut cursor = 0usize;105 for (start, end, replacement) in edits {106 out.push_str(&source[cursor..start]);107 out.push_str(&replacement);108 cursor = end;109 }110 out.push_str(&source[cursor..]);111 Some((out, count))112}113114fn is_jsonnet_file(path: &Path) -> bool {115 matches!(116 path.extension().and_then(|e| e.to_str()),117 Some("jsonnet" | "libsonnet" | "TEMPLATE")118 )119}120121fn collect_files(root: &Path, skip: &Path, out: &mut Vec<PathBuf>) {122 if root == skip {123 return;124 }125 let meta = match fs::symlink_metadata(root) {126 Ok(m) => m,127 Err(e) => {128 warn!("stat {}: {e}", root.display());129 return;130 }131 };132 let file_type = meta.file_type();133 if file_type.is_symlink() {134 return;135 }136 if file_type.is_dir() {137 let entries = match fs::read_dir(root) {138 Ok(e) => e,139 Err(e) => {140 warn!("read_dir {}: {e}", root.display());141 return;142 }143 };144 for entry in entries.flatten() {145 collect_files(&entry.path(), skip, out);146 }147 return;148 }149 if file_type.is_file() && is_jsonnet_file(root) {150 out.push(root.to_owned());151 }152}153154pub fn rewrite(paths: &[PathBuf], vendor_dir: &Path, rules: &[Rule], dry_run: bool) -> Stats {155 let skip = vendor_dir156 .canonicalize()157 .unwrap_or_else(|_| vendor_dir.to_path_buf());158 let mut files = Vec::new();159 for p in paths {160 let canon = p.canonicalize().unwrap_or_else(|_| p.clone());161 collect_files(&canon, &skip, &mut files);162 }163 files.sort();164 files.dedup();165166 let mut stats = Stats {167 files_scanned: files.len(),168 files_modified: 0,169 imports_rewritten: 0,170 };171 for file in &files {172 let source = match fs::read_to_string(file) {173 Ok(s) => s,174 Err(e) => {175 error!("read {}: {e}", file.display());176 continue;177 }178 };179 let Some((new_source, count)) = rewrite_source(&source, rules) else {180 debug!("unchanged: {}", file.display());181 continue;182 };183 stats.files_modified += 1;184 stats.imports_rewritten += count;185 if dry_run {186 info!("would rewrite {} import(s) in {}", count, file.display());187 } else {188 match fs::write(file, &new_source) {189 Ok(()) => info!("rewrote {} import(s) in {}", count, file.display()),190 Err(e) => error!("write {}: {e}", file.display()),191 }192 }193 }194 stats195}196197#[cfg(test)]198mod tests {199 use super::*;200201 fn rules() -> Vec<Rule> {202 vec![Rule {203 legacy: "kube-prometheus".into(),204 canonical: "github.com/prometheus-operator/kube-prometheus/jsonnet/kube-prometheus"205 .into(),206 }]207 }208209 #[test]210 fn rewrites_prefix() {211 let src = "import 'kube-prometheus/main.libsonnet'\n";212 let (out, n) = rewrite_source(src, &rules()).expect("rewrite");213 assert_eq!(n, 1);214 assert_eq!(215 out,216 "import 'github.com/prometheus-operator/kube-prometheus/jsonnet/kube-prometheus/main.libsonnet'\n"217 );218 }219220 #[test]221 fn rewrites_exact_match() {222 let src = "import \"kube-prometheus\"\n";223 let (out, _n) = rewrite_source(src, &rules()).expect("rewrite");224 assert!(225 out.contains(226 "'github.com/prometheus-operator/kube-prometheus/jsonnet/kube-prometheus'"227 )228 );229 }230231 #[test]232 fn leaves_unrelated_imports_alone() {233 let src = "import 'something/else.libsonnet'\n";234 assert!(rewrite_source(src, &rules()).is_none());235 }236237 #[test]238 fn does_not_match_substring() {239 let src = "import 'kube-prometheus-extra/foo.libsonnet'\n";240 assert!(rewrite_source(src, &rules()).is_none());241 }242}