Onset: version control where nobody waits and nothing gets lost. The on command, its core and onset-server.
| 6 | //! first. |
| 7 | //! - **Import blocks** (JS/TS, Python, Rust, Go, Java/Kotlin, C/C++, Swift, |
| 8 | //! C#): each side's added imports go in and removed ones go, kept sorted |
| 9 | //! when they were. |
| 10 | //! - **TOML** merges by table and key, as JSON does, keeping the file's |
| 11 | //! layout and comments. |
| 12 |
| 13 | use std::collections::{BTreeMap, HashSet}; |
| 14 |
| 15 | use crate::diff::lines; |
| 16 |
| 17 | #[derive(Clone, Copy, PartialEq, Eq)] |
| 18 | enum Lang { |
| 19 | Js, |
| 20 | Py, |
| 21 | Rust, |
| 22 | Go, |
| 23 | Java, |
| 24 | C, |
| 25 | Swift, |
| 26 | CSharp, |
| 27 | Other, |
| 28 | } |
| 29 |
| 30 | fn lang(path: &str) -> Lang { |
| 31 | let ext = path.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default(); |
| 32 | match ext.as_str() { |
| 33 | "js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs" | "mts" | "cts" => Lang::Js, |
| 34 | "py" | "pyi" => Lang::Py, |
| 35 | "rs" => Lang::Rust, |
| 36 | "go" => Lang::Go, |
| 37 | "java" | "kt" | "kts" | "scala" => Lang::Java, |
| 38 | "c" | "h" | "cc" | "cpp" | "cxx" | "hpp" | "hh" | "m" | "mm" => Lang::C, |
| 39 | "swift" => Lang::Swift, |
| 40 | "cs" => Lang::CSharp, |
| 41 | _ => Lang::Other, |
| 42 | } |
| 43 | } |
| 44 |
| 45 | fn blank(line: &[u8]) -> bool { |
| 46 | line.iter().all(|b| b.is_ascii_whitespace()) |
| 47 | } |
| 48 |
| 49 | /// A whole import on one line, for the file's language. |
| 50 | fn is_import(lang: Lang, line: &[u8]) -> bool { |
| 51 | let Ok(s) = std::str::from_utf8(line) else { return false }; |
| 52 | let s = s.trim(); |
| 53 | match lang { |
| 54 | Lang::Js => { |
| 55 | (s.starts_with("import ") && (s.contains(" from ") || s.starts_with("import \"") || s.starts_with("import '"))) |
| 56 | || (s.starts_with("export ") && s.contains(" from ")) |
| 57 | } |
| 58 | Lang::Py => (s.starts_with("import ") || (s.starts_with("from ") && s.contains(" import "))) && !s.ends_with('('), |
| 59 | Lang::Rust => (s.starts_with("use ") || s.starts_with("pub use ") || s.starts_with("pub(crate) use ")) && s.ends_with(';'), |
| 60 | Lang::Go => (s.starts_with('"') && s.ends_with('"')) || (s.ends_with('"') && s.split_whitespace().count() == 2) || s.starts_with("import \""), |
| 61 | Lang::Java => s.starts_with("import ") && (s.ends_with(';') || !s.contains(' ') || s.split_whitespace().count() == 2), |
| 62 | Lang::C => s.starts_with("#include ") || s.starts_with("#import "), |
| 63 | Lang::Swift => s.starts_with("import "), |
| 64 | Lang::CSharp => s.starts_with("using ") && s.ends_with(';'), |
| 65 | Lang::Other => false, |
| 66 | } |
| 67 | } |
| 68 |
| 69 | /// Lines with a line break each (the file's last line may lack one). |
| 70 | fn ended(line: &[u8]) -> Vec<u8> { |
| 71 | let mut l = line.to_vec(); |
| 72 | if !l.ends_with(b"\n") { |
| 73 | l.push(b'\n'); |
| 74 | } |
| 75 | l |
| 76 | } |
| 77 |
| 78 | /// The resolver for a file at `path` (see `merge::text_with`). |
| 79 | pub fn resolver(path: &str) -> impl Fn(&[&[u8]], &[u8], &[u8]) -> Option<Vec<u8>> { |
| 80 | let lang = lang(path); |
| 81 | move |base: &[&[u8]], ours: &[u8], theirs: &[u8]| { |
| 82 | let o = lines(ours); |
| 83 | let t = lines(theirs); |
| 84 | if lang != Lang::Other { |
| 85 | let all: Vec<&[u8]> = base.iter().copied().chain(o.iter().copied()).chain(t.iter().copied()).collect(); |
| 86 | if all.iter().any(|l| !blank(l)) && all.iter().all(|l| blank(l) || is_import(lang, l)) { |
| 87 | return Some(imports(base, &o, &t)); |
| 88 | } |
| 89 | } |
| 90 | if base.is_empty() { |
| 91 | // Both added here: both go in, unless they added some of the same lines. |
| 92 | let mine: HashSet<&[u8]> = o.iter().copied().filter(|l| !blank(l)).collect(); |
| 93 | if t.iter().any(|l| !blank(l) && mine.contains(l)) { |
| 94 | return None; |
| 95 | } |
| 96 | let mut out = Vec::with_capacity(ours.len() + theirs.len() + 1); |
| 97 | for l in o.iter().chain(t.iter()) { |
| 98 | out.extend_from_slice(&ended(l)); |
| 99 | } |
| 100 | return Some(out); |
| 101 | } |
| 102 | None |
| 103 | } |
| 104 | } |
| 105 |
| 106 | /// An import block: what either side added, less what either side removed. |
| 107 | fn imports(base: &[&[u8]], o: &[&[u8]], t: &[&[u8]]) -> Vec<u8> { |
| 108 | let key = |l: &[u8]| String::from_utf8_lossy(l).trim().to_string(); |
| 109 | let in_o: HashSet<String> = o.iter().map(|l| key(l)).collect(); |
| 110 | let in_t: HashSet<String> = t.iter().map(|l| key(l)).collect(); |
| 111 | let removed: HashSet<String> = base.iter().map(|l| key(l)).filter(|k| !k.is_empty() && (!in_o.contains(k) || !in_t.contains(k))).collect(); |
| 112 | let mut seen: HashSet<String> = HashSet::new(); |
| 113 | let mut merged: Vec<Vec<u8>> = Vec::new(); |
| 114 | for l in o.iter().chain(t.iter()) { |
| 115 | let k = key(l); |
| 116 | if removed.contains(&k) || (!k.is_empty() && !seen.insert(k)) { |
| 117 | continue; |
| 118 | } |
| 119 | merged.push(ended(l)); |
| 120 | } |
| 121 | let sorted = |ls: &[&[u8]]| ls.len() > 1 && ls.windows(2).all(|w| key(w[0]) <= key(w[1])); |
| 122 | if !merged.iter().any(|l| blank(l)) && (sorted(base) || sorted(o)) { |
| 123 | merged.sort_by_key(|l| key(l)); |
| 124 | } |
| 125 | merged.concat() |
| 126 | } |
| 127 |
| 128 | /// A TOML file as tables and keys: each key's lines (a value can run over |
| 129 | /// several), and which table each line is in. |
| 130 | struct Toml { |
| 131 | lines: Vec<String>, |
| 132 | /// (table, key) → the line range of the key and its value. |
| 133 | keys: BTreeMap<(String, String), (usize, usize)>, |
| 134 | tables: Vec<String>, |
| 135 | } |
| 136 |
| 137 | fn parse_toml(text: &str) -> Option<Toml> { |
| 138 | let lines: Vec<String> = text.split_inclusive('\n').map(String::from).collect(); |
| 139 | let mut keys = BTreeMap::new(); |
| 140 | let mut tables = vec![String::new()]; |
| 141 | let mut table = String::new(); |
| 142 | let mut i = 0; |
| 143 | while i < lines.len() { |
| 144 | let s = lines[i].trim(); |
| 145 | if s.starts_with("[[") { |
| 146 | return None; |
| 147 | } |
| 148 | if s.starts_with('[') { |
| 149 | table = s.trim_start_matches('[').split(']').next()?.trim().to_string(); |
| 150 | tables.push(table.clone()); |
| 151 | i += 1; |
| 152 | continue; |
| 153 | } |
| 154 | if s.is_empty() || s.starts_with('#') { |
| 155 | i += 1; |
| 156 | continue; |
| 157 | } |
| 158 | let (key, value) = s.split_once('=')?; |
| 159 | let key = key.trim().to_string(); |
| 160 | // A value runs on while its brackets are open or a triple-quoted string is. |
| 161 | let mut depth: i64 = 0; |
| 162 | let mut quoted = false; |
| 163 | let mut end = i; |
| 164 | let mut text = value.to_string(); |
| 165 | loop { |
| 166 | for q in ["\"\"\"", "'''"] { |
| 167 | if text.matches(q).count() % 2 == 1 { |
| 168 | quoted = !quoted; |
| 169 | } |
| 170 | } |
| 171 | if !quoted { |
| 172 | depth += text.matches(['[', '{']).count() as i64 - text.matches([']', '}']).count() as i64; |
| 173 | } |
| 174 | if (depth <= 0 && !quoted) || end + 1 >= lines.len() { |
| 175 | break; |
| 176 | } |
| 177 | end += 1; |
| 178 | text = lines[end].clone(); |
| 179 | } |
| 180 | if keys.insert((table.clone(), key), (i, end + 1)).is_some() { |
| 181 | return None; |
| 182 | } |
| 183 | i = end + 1; |
| 184 | } |
| 185 | Some(Toml { lines, keys, tables }) |
| 186 | } |
| 187 |
| 188 | /// Merges TOML files by table and key, from main's file: None when both |
| 189 | /// sides changed the same key differently (or it isn't TOML this reads). |
| 190 | pub fn toml(base: Option<&[u8]>, ours: &[u8], theirs: &[u8]) -> Option<Vec<u8>> { |
| 191 | let read = |b: &[u8]| std::str::from_utf8(b).ok().and_then(parse_toml); |
| 192 | let b = match base { |
| 193 | Some(b) => read(b)?, |
| 194 | None => parse_toml("")?, |
| 195 | }; |
| 196 | let o = read(ours)?; |
| 197 | let t = read(theirs)?; |
| 198 | let text = |doc: &Toml, k: &(String, String)| doc.keys.get(k).map(|(s, e)| doc.lines[*s..*e].concat()); |
| 199 | let all: Vec<(String, String)> = b.keys.keys().chain(o.keys.keys()).chain(t.keys.keys()).cloned().collect::<std::collections::BTreeSet<_>>().into_iter().collect(); |
| 200 | // What to do to main's file: replace a key's lines, drop them, or add a key to a table. |
| 201 | let mut replace: BTreeMap<usize, (usize, Option<String>)> = BTreeMap::new(); |
| 202 | let mut added: Vec<((String, String), String)> = Vec::new(); |
| 203 | for k in &all { |
| 204 | let (vb, vo, vt) = (text(&b, k), text(&o, k), text(&t, k)); |
| 205 | if vo == vt || vb == vt { |
| 206 | continue; |
| 207 | } |
| 208 | if vb != vo { |
| 209 | return None; |
| 210 | } |
| 211 | match (o.keys.get(k), vt) { |
| 212 | (Some((s, e)), next) => { |
| 213 | replace.insert(*s, (*e, next)); |
| 214 | } |
| 215 | (None, Some(next)) => added.push((k.clone(), next)), |
| 216 | (None, None) => {} |
| 217 | } |
| 218 | } |
| 219 | let mut out: Vec<String> = Vec::new(); |
| 220 | let mut table = String::new(); |
| 221 | // After a table's last key in main's file: the keys the change added to it. |
| 222 | let last_line: BTreeMap<String, usize> = o.keys.iter().fold(BTreeMap::new(), |mut m, ((tb, _), (_, e))| { |
| 223 | let v = m.entry(tb.clone()).or_insert(0); |
| 224 | *v = (*v).max(*e); |
| 225 | m |
| 226 | }); |
| 227 | let mut placed: HashSet<usize> = HashSet::new(); |
| 228 | let mut i = 0; |
| 229 | while i < o.lines.len() { |
| 230 | let s = o.lines[i].trim(); |
| 231 | if s.starts_with('[') { |
| 232 | table = s.trim_start_matches('[').split(']').next().unwrap_or("").trim().to_string(); |
| 233 | } |
| 234 | if let Some((end, next)) = replace.get(&i) { |
| 235 | if let Some(n) = next { |
| 236 | out.push(n.clone()); |
| 237 | } |
| 238 | i = *end; |
| 239 | } else { |
| 240 | out.push(o.lines[i].clone()); |
| 241 | i += 1; |
| 242 | } |
| 243 | let at = i; |
| 244 | if last_line.get(&table) == Some(&at) { |
| 245 | for (n, ((tb, _), v)) in added.iter().enumerate() { |
| 246 | if *tb == table && placed.insert(n) { |
| 247 | if out.last().is_some_and(|l| !l.ends_with('\n')) { |
| 248 | out.push("\n".into()); |
| 249 | } |
| 250 | out.push(v.clone()); |
| 251 | } |
| 252 | } |
| 253 | } |
| 254 | } |
| 255 | // Keys for tables main's file has no keys in (or doesn't have): at the end, under their table. |
| 256 | for (n, ((tb, _), v)) in added.iter().enumerate() { |
| 257 | if placed.contains(&n) { |
| 258 | continue; |
| 259 | } |
| 260 | if out.last().is_some_and(|l| !l.ends_with('\n')) { |
| 261 | out.push("\n".into()); |
| 262 | } |
| 263 | if !tb.is_empty() && !o.tables.contains(tb) && !out.iter().any(|l| l.trim() == format!("[{tb}]")) { |
| 264 | out.push(format!("\n[{tb}]\n")); |
| 265 | } |
| 266 | out.push(v.clone()); |
| 267 | } |
| 268 | let _ = &t.tables; |
| 269 | Some(out.concat().into_bytes()) |
| 270 | } |
| 271 |
| 272 | #[cfg(test)] |
| 273 | mod tests { |
| 274 | use super::*; |
| 275 | use crate::merge; |
| 276 |
| 277 | fn smart(path: &str, base: &str, ours: &str, theirs: &str) -> (String, usize) { |
| 278 | let m = merge::text_for(path, base.as_bytes(), ours.as_bytes(), theirs.as_bytes(), "main", "change"); |
| 279 | (String::from_utf8(m.content).unwrap(), m.conflicts) |
| 280 | } |
| 281 |
| 282 | #[test] |
| 283 | fn both_added_at_the_same_place() { |
| 284 | let base = "fn a() {}\n"; |
| 285 | let ours = "fn a() {}\nfn b() {}\n"; |
| 286 | let theirs = "fn a() {}\nfn c() {}\n"; |
| 287 | assert_eq!(smart("lib.rs", base, ours, theirs), ("fn a() {}\nfn b() {}\nfn c() {}\n".into(), 0)); |
| 288 | // Some of the same lines: still a conflict. |
| 289 | assert_eq!(smart("lib.rs", base, "fn a() {}\nfn b() {}\n}\n", "fn a() {}\nfn c() {}\n}\n").1, 1); |
| 290 | } |
| 291 |
| 292 | #[test] |
| 293 | fn import_blocks() { |
| 294 | let base = "import { a } from \"a\";\nimport { c } from \"c\";\n\nrun();\n"; |
| 295 | let ours = "import { a } from \"a\";\nimport { b } from \"b\";\nimport { c } from \"c\";\n\nrun();\n"; |
| 296 | let theirs = "import { a } from \"a\";\nimport { bb } from \"bb\";\nimport { c } from \"c\";\n\nrun();\n"; |
| 297 | let (text, conflicts) = smart("app.ts", base, ours, theirs); |
| 298 | assert_eq!(conflicts, 0); |
| 299 | assert_eq!(text, "import { a } from \"a\";\nimport { b } from \"b\";\nimport { bb } from \"bb\";\nimport { c } from \"c\";\n\nrun();\n"); |
| 300 | // One side removes an import the other kept. |
| 301 | let (text, conflicts) = smart("m.py", "import os\nimport re\n", "import os\nimport sys\n", "import json\nimport os\nimport re\n"); |
| 302 | assert_eq!((text.as_str(), conflicts), ("import json\nimport os\nimport sys\n", 0)); |
| 303 | } |
| 304 |
| 305 | #[test] |
| 306 | fn toml_by_key() { |
| 307 | let base = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n# deps\n[dependencies]\nserde = \"1\"\n"; |
| 308 | let ours = "[package]\nname = \"app\"\nversion = \"0.2.0\"\n\n# deps\n[dependencies]\nserde = \"1\"\ntokio = \"1\"\n"; |
| 309 | let theirs = "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n# deps\n[dependencies]\nserde = \"1\"\nrand = \"0.8\"\n"; |
| 310 | let merged = String::from_utf8(toml(Some(base.as_bytes()), ours.as_bytes(), theirs.as_bytes()).unwrap()).unwrap(); |
| 311 | assert_eq!(merged, "[package]\nname = \"app\"\nversion = \"0.2.0\"\nedition = \"2024\"\n\n# deps\n[dependencies]\nserde = \"1\"\ntokio = \"1\"\nrand = \"0.8\"\n"); |
| 312 | // The same key changed differently: no merge. |
| 313 | assert!(toml(Some(base.as_bytes()), base.replace("0.1.0", "0.3.0").as_bytes(), base.replace("0.1.0", "0.4.0").as_bytes()).is_none()); |
| 314 | } |
| 315 | } |
| 316 |