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<&[u8]>, Option<&[u8]>)) -> Option<Vec<u8>> { |
| 80 | let lang = lang(path); |
| 81 | let path = path.to_string(); |
| 82 | move |base: &[&[u8]], ours: &[u8], theirs: &[u8], (before, after): (Option<&[u8]>, Option<&[u8]>)| { |
| 83 | let o = lines(ours); |
| 84 | let t = lines(theirs); |
| 85 | if lang != Lang::Other { |
| 86 | let all: Vec<&[u8]> = base.iter().copied().chain(o.iter().copied()).chain(t.iter().copied()).collect(); |
| 87 | if all.iter().any(|l| !blank(l)) && all.iter().all(|l| blank(l) || is_import(lang, l)) { |
| 88 | // Two imports of one name (both sides changed `import { a }` from one module) don't merge. |
| 89 | let added = |side: &[&[u8]]| -> HashSet<String> { |
| 90 | let kept: HashSet<&[u8]> = base.iter().copied().collect(); |
| 91 | side.iter().filter(|l| !kept.contains(*l)).flat_map(|l| bound_names(lang, l)).collect() |
| 92 | }; |
| 93 | if !added(&o).is_disjoint(&added(&t)) { |
| 94 | return None; |
| 95 | } |
| 96 | // Kept in order where the block around them is. |
| 97 | let around = before.zip(after).is_some_and(|(b, a)| !blank(b) && !blank(a) && b.trim_ascii() <= a.trim_ascii()); |
| 98 | return Some(imports(base, &o, &t, around)); |
| 99 | } |
| 100 | } |
| 101 | if base.is_empty() && both_go_in(&path, lang, ours, theirs, &o, &t) { |
| 102 | // Both added here: both go in, main's first, or in order where |
| 103 | // they're entries of a sorted list (a Makefile's, #includes). |
| 104 | let mut lines: Vec<&[u8]> = o.iter().chain(t.iter()).copied().collect(); |
| 105 | if sorted_list(&o, &t, before, after) { |
| 106 | lines.sort_by_key(|l| l.trim_ascii()); |
| 107 | } |
| 108 | let mut out = Vec::with_capacity(ours.len() + theirs.len() + 1); |
| 109 | for l in lines { |
| 110 | out.extend_from_slice(&ended(l)); |
| 111 | } |
| 112 | return Some(out); |
| 113 | } |
| 114 | None |
| 115 | } |
| 116 | } |
| 117 |
| 118 | /// Lines each side added one per entry, in order, between neighbours in |
| 119 | /// order too: a sorted list both sides added to. |
| 120 | fn sorted_list(o: &[&[u8]], t: &[&[u8]], before: Option<&[u8]>, after: Option<&[u8]>) -> bool { |
| 121 | let key = |l: &[u8]| l.trim_ascii().to_vec(); |
| 122 | let added: Vec<&[u8]> = o.iter().chain(t.iter()).copied().collect(); |
| 123 | if added.iter().any(|l| blank(l)) || added.len() < 2 { |
| 124 | return false; |
| 125 | } |
| 126 | let ordered = |side: &[&[u8]]| side.windows(2).all(|w| key(w[0]) <= key(w[1])); |
| 127 | let (Some(b), Some(a)) = (before, after) else { return false }; |
| 128 | if blank(b) || blank(a) || key(b) > key(a) { |
| 129 | return false; |
| 130 | } |
| 131 | ordered(o) && ordered(t) && added.iter().all(|l| key(b) <= key(l) && key(l) <= key(a)) |
| 132 | } |
| 133 |
| 134 | /// Whether two blocks added at one spot can both go in: they share no lines |
| 135 | /// (closing braces aside), declare nothing in common, and in code each is |
| 136 | /// whole (brackets closed) and made of declarations or test cases, not |
| 137 | /// statements whose order matters. |
| 138 | fn both_go_in(path: &str, lang: Lang, ours: &[u8], theirs: &[u8], o: &[&[u8]], t: &[&[u8]]) -> bool { |
| 139 | let closing = |l: &[u8]| l.iter().all(|c| c.is_ascii_whitespace() || b"}])>;,".contains(c)); |
| 140 | let mine: HashSet<&[u8]> = o.iter().copied().filter(|l| !blank(l) && !closing(l)).collect(); |
| 141 | if t.iter().any(|l| !blank(l) && !closing(l) && mine.contains(l)) { |
| 142 | return false; |
| 143 | } |
| 144 | let names = |b: &[u8]| crate::decls::declared(path, b).into_iter().map(|(k, _)| k).collect::<HashSet<_>>(); |
| 145 | if !names(ours).is_disjoint(&names(theirs)) { |
| 146 | return false; |
| 147 | } |
| 148 | if lang == Lang::Other { |
| 149 | return true; |
| 150 | } |
| 151 | [ours, theirs].iter().all(|block| balanced(block) && whole_items(path, block)) |
| 152 | } |
| 153 |
| 154 | /// Brackets open and close within the block (strings and comments aside, roughly). |
| 155 | fn balanced(block: &[u8]) -> bool { |
| 156 | let mut depth = [0i64; 3]; |
| 157 | let (mut quote, mut escaped) = (None::<u8>, false); |
| 158 | for line in lines(block) { |
| 159 | let line = match line.windows(2).position(|w| w == b"//") { |
| 160 | Some(p) if quote.is_none() => &line[..p], |
| 161 | _ => line, |
| 162 | }; |
| 163 | for &c in line { |
| 164 | if let Some(q) = quote { |
| 165 | if escaped { |
| 166 | escaped = false; |
| 167 | } else if c == b'\\' { |
| 168 | escaped = true; |
| 169 | } else if c == q { |
| 170 | quote = None; |
| 171 | } |
| 172 | continue; |
| 173 | } |
| 174 | match c { |
| 175 | b'"' | b'`' => quote = Some(c), |
| 176 | b'(' => depth[0] += 1, |
| 177 | b')' => depth[0] -= 1, |
| 178 | b'[' => depth[1] += 1, |
| 179 | b']' => depth[1] -= 1, |
| 180 | b'{' => depth[2] += 1, |
| 181 | b'}' => depth[2] -= 1, |
| 182 | _ => {} |
| 183 | } |
| 184 | if depth.iter().any(|d| *d < 0) { |
| 185 | return false; |
| 186 | } |
| 187 | } |
| 188 | if quote == Some(b'"') { |
| 189 | quote = None; |
| 190 | } |
| 191 | } |
| 192 | depth == [0, 0, 0] && quote.is_none() |
| 193 | } |
| 194 |
| 195 | /// A block made of whole declarations (functions, classes, types, constants) |
| 196 | /// or test cases (`it("…", …)`, `test(…)`), with comments, decorators and |
| 197 | /// blank lines between: things whose order next to each other doesn't matter. |
| 198 | fn whole_items(path: &str, block: &[u8]) -> bool { |
| 199 | let decl_lines: HashSet<usize> = crate::decls::declared(path, block).into_iter().map(|(_, n)| n).collect(); |
| 200 | let mut seen_item = false; |
| 201 | let mut depth = 0i64; |
| 202 | for (n, line) in String::from_utf8_lossy(block).lines().enumerate() { |
| 203 | let s = line.trim(); |
| 204 | let top = depth == 0; |
| 205 | depth += s.matches(['{', '(', '[']).count() as i64 - s.matches(['}', ')', ']']).count() as i64; |
| 206 | if !top || s.is_empty() || s.starts_with("//") || s.starts_with('#') && !s.starts_with("#[") || s.starts_with("/*") || s.starts_with('*') || s.starts_with('@') || s.starts_with("#[") { |
| 207 | continue; |
| 208 | } |
| 209 | let test_case = ["it(", "test(", "describe(", "bench(", "it.each", "test.each", "#[test]"].iter().any(|p| s.starts_with(p)); |
| 210 | if decl_lines.contains(&n) || test_case { |
| 211 | seen_item = true; |
| 212 | continue; |
| 213 | } |
| 214 | // Python bodies are indented; a top-level line that isn't an item is a statement. |
| 215 | if line.starts_with(char::is_whitespace) && seen_item { |
| 216 | continue; |
| 217 | } |
| 218 | return false; |
| 219 | } |
| 220 | seen_item |
| 221 | } |
| 222 |
| 223 | /// The names an import line binds (`import { a, b as c } from "x"` → a, c). |
| 224 | fn bound_names(lang: Lang, line: &[u8]) -> Vec<String> { |
| 225 | let Ok(s) = std::str::from_utf8(line) else { return Vec::new() }; |
| 226 | let s = s.trim().trim_end_matches(';'); |
| 227 | let split = |list: &str| -> Vec<String> { |
| 228 | list.split(',') |
| 229 | .map(|p| p.trim()) |
| 230 | .filter(|p| !p.is_empty() && *p != "*" && *p != "self") |
| 231 | .map(|p| p.rsplit(" as ").next().unwrap_or(p).trim().trim_start_matches("type ").to_string()) |
| 232 | .collect() |
| 233 | }; |
| 234 | match lang { |
| 235 | Lang::Js => { |
| 236 | let Some(clause) = s.strip_prefix("import ").or_else(|| s.strip_prefix("export ")) else { return Vec::new() }; |
| 237 | let Some((clause, _)) = clause.split_once(" from ") else { return Vec::new() }; |
| 238 | let clause = clause.trim().trim_start_matches("type "); |
| 239 | let mut names = Vec::new(); |
| 240 | let (head, braces) = match clause.find('{') { |
| 241 | Some(i) => (&clause[..i], Some(clause[i + 1..].trim_end_matches('}'))), |
| 242 | None => (clause, None), |
| 243 | }; |
| 244 | for part in head.split(',').map(str::trim).filter(|p| !p.is_empty()) { |
| 245 | names.push(part.rsplit(" as ").next().unwrap_or(part).trim().to_string()); |
| 246 | } |
| 247 | if let Some(b) = braces { |
| 248 | names.extend(split(b)); |
| 249 | } |
| 250 | names |
| 251 | } |
| 252 | Lang::Py => match s.split_once(" import ") { |
| 253 | Some((_, list)) if s.starts_with("from ") => split(list.trim_matches(['(', ')'])), |
| 254 | _ => s.strip_prefix("import ").map(split).unwrap_or_default().into_iter().map(|n| n.split('.').next().unwrap_or(&n).to_string()).collect(), |
| 255 | }, |
| 256 | Lang::Rust => { |
| 257 | let path = s.trim_start_matches("pub(crate) ").trim_start_matches("pub ").trim_start_matches("use ").trim(); |
| 258 | match path.find('{') { |
| 259 | Some(i) => split(path[i + 1..].trim_end_matches('}')), |
| 260 | None => vec![path.rsplit(" as ").next().unwrap_or(path).rsplit("::").next().unwrap_or(path).to_string()], |
| 261 | } |
| 262 | } |
| 263 | Lang::Go => { |
| 264 | let parts: Vec<&str> = s.trim_start_matches("import ").split_whitespace().collect(); |
| 265 | match parts.as_slice() { |
| 266 | [alias, _] => vec![alias.to_string()], |
| 267 | [path] => vec![path.trim_matches('"').rsplit('/').next().unwrap_or("").to_string()], |
| 268 | _ => Vec::new(), |
| 269 | } |
| 270 | } |
| 271 | // Repeated includes and usings are harmless there. |
| 272 | Lang::Java | Lang::C | Lang::Swift | Lang::CSharp | Lang::Other => Vec::new(), |
| 273 | } |
| 274 | } |
| 275 |
| 276 | /// An import block: what either side added, less what either side removed. |
| 277 | /// `around`: the lines just around the spot are in order. |
| 278 | fn imports(base: &[&[u8]], o: &[&[u8]], t: &[&[u8]], around: bool) -> Vec<u8> { |
| 279 | let key = |l: &[u8]| String::from_utf8_lossy(l).trim().to_string(); |
| 280 | let in_o: HashSet<String> = o.iter().map(|l| key(l)).collect(); |
| 281 | let in_t: HashSet<String> = t.iter().map(|l| key(l)).collect(); |
| 282 | let removed: HashSet<String> = base.iter().map(|l| key(l)).filter(|k| !k.is_empty() && (!in_o.contains(k) || !in_t.contains(k))).collect(); |
| 283 | let mut seen: HashSet<String> = HashSet::new(); |
| 284 | let mut merged: Vec<Vec<u8>> = Vec::new(); |
| 285 | for l in o.iter().chain(t.iter()) { |
| 286 | let k = key(l); |
| 287 | if removed.contains(&k) || (!k.is_empty() && !seen.insert(k)) { |
| 288 | continue; |
| 289 | } |
| 290 | merged.push(ended(l)); |
| 291 | } |
| 292 | let sorted = |ls: &[&[u8]]| ls.len() > 1 && ls.windows(2).all(|w| key(w[0]) <= key(w[1])); |
| 293 | if !merged.iter().any(|l| blank(l)) && (sorted(base) || sorted(o) || (around && (sorted(t) || t.len() < 2) && (sorted(o) || o.len() < 2))) { |
| 294 | merged.sort_by_key(|l| key(l)); |
| 295 | } |
| 296 | merged.concat() |
| 297 | } |
| 298 |
| 299 | /// A TOML file as tables and keys: each key's lines (a value can run over |
| 300 | /// several), and which table each line is in. |
| 301 | struct Toml { |
| 302 | lines: Vec<String>, |
| 303 | /// (table, key) → the line range of the key and its value. |
| 304 | keys: BTreeMap<(String, String), (usize, usize)>, |
| 305 | tables: Vec<String>, |
| 306 | } |
| 307 |
| 308 | fn parse_toml(text: &str) -> Option<Toml> { |
| 309 | let lines: Vec<String> = text.split_inclusive('\n').map(String::from).collect(); |
| 310 | let mut keys = BTreeMap::new(); |
| 311 | let mut tables = vec![String::new()]; |
| 312 | let mut table = String::new(); |
| 313 | let mut i = 0; |
| 314 | while i < lines.len() { |
| 315 | let s = lines[i].trim(); |
| 316 | if s.starts_with("[[") { |
| 317 | return None; |
| 318 | } |
| 319 | if s.starts_with('[') { |
| 320 | table = s.trim_start_matches('[').split(']').next()?.trim().to_string(); |
| 321 | tables.push(table.clone()); |
| 322 | i += 1; |
| 323 | continue; |
| 324 | } |
| 325 | if s.is_empty() || s.starts_with('#') { |
| 326 | i += 1; |
| 327 | continue; |
| 328 | } |
| 329 | let (key, value) = s.split_once('=')?; |
| 330 | let key = key.trim().to_string(); |
| 331 | // A value runs on while its brackets are open or a triple-quoted string is. |
| 332 | let mut depth: i64 = 0; |
| 333 | let mut quoted = false; |
| 334 | let mut end = i; |
| 335 | let mut text = value.to_string(); |
| 336 | loop { |
| 337 | for q in ["\"\"\"", "'''"] { |
| 338 | if text.matches(q).count() % 2 == 1 { |
| 339 | quoted = !quoted; |
| 340 | } |
| 341 | } |
| 342 | if !quoted { |
| 343 | depth += text.matches(['[', '{']).count() as i64 - text.matches([']', '}']).count() as i64; |
| 344 | } |
| 345 | if (depth <= 0 && !quoted) || end + 1 >= lines.len() { |
| 346 | break; |
| 347 | } |
| 348 | end += 1; |
| 349 | text = lines[end].clone(); |
| 350 | } |
| 351 | if keys.insert((table.clone(), key), (i, end + 1)).is_some() { |
| 352 | return None; |
| 353 | } |
| 354 | i = end + 1; |
| 355 | } |
| 356 | Some(Toml { lines, keys, tables }) |
| 357 | } |
| 358 |
| 359 | /// Merges TOML files by table and key, from main's file: None when both |
| 360 | /// sides changed the same key differently (or it isn't TOML this reads). |
| 361 | pub fn toml(base: Option<&[u8]>, ours: &[u8], theirs: &[u8]) -> Option<Vec<u8>> { |
| 362 | let read = |b: &[u8]| std::str::from_utf8(b).ok().and_then(parse_toml); |
| 363 | let b = match base { |
| 364 | Some(b) => read(b)?, |
| 365 | None => parse_toml("")?, |
| 366 | }; |
| 367 | let o = read(ours)?; |
| 368 | let t = read(theirs)?; |
| 369 | let text = |doc: &Toml, k: &(String, String)| doc.keys.get(k).map(|(s, e)| doc.lines[*s..*e].concat()); |
| 370 | let all: Vec<(String, String)> = b.keys.keys().chain(o.keys.keys()).chain(t.keys.keys()).cloned().collect::<std::collections::BTreeSet<_>>().into_iter().collect(); |
| 371 | // What to do to main's file: replace a key's lines, drop them, or add a key to a table. |
| 372 | let mut replace: BTreeMap<usize, (usize, Option<String>)> = BTreeMap::new(); |
| 373 | let mut added: Vec<((String, String), String)> = Vec::new(); |
| 374 | for k in &all { |
| 375 | let (vb, vo, vt) = (text(&b, k), text(&o, k), text(&t, k)); |
| 376 | if vo == vt || vb == vt { |
| 377 | continue; |
| 378 | } |
| 379 | if vb != vo { |
| 380 | return None; |
| 381 | } |
| 382 | match (o.keys.get(k), vt) { |
| 383 | (Some((s, e)), next) => { |
| 384 | replace.insert(*s, (*e, next)); |
| 385 | } |
| 386 | (None, Some(next)) => added.push((k.clone(), next)), |
| 387 | (None, None) => {} |
| 388 | } |
| 389 | } |
| 390 | let mut out: Vec<String> = Vec::new(); |
| 391 | let mut table = String::new(); |
| 392 | // After a table's last key in main's file: the keys the change added to it. |
| 393 | let last_line: BTreeMap<String, usize> = o.keys.iter().fold(BTreeMap::new(), |mut m, ((tb, _), (_, e))| { |
| 394 | let v = m.entry(tb.clone()).or_insert(0); |
| 395 | *v = (*v).max(*e); |
| 396 | m |
| 397 | }); |
| 398 | let mut placed: HashSet<usize> = HashSet::new(); |
| 399 | let mut i = 0; |
| 400 | while i < o.lines.len() { |
| 401 | let s = o.lines[i].trim(); |
| 402 | if s.starts_with('[') { |
| 403 | table = s.trim_start_matches('[').split(']').next().unwrap_or("").trim().to_string(); |
| 404 | } |
| 405 | if let Some((end, next)) = replace.get(&i) { |
| 406 | if let Some(n) = next { |
| 407 | out.push(n.clone()); |
| 408 | } |
| 409 | i = *end; |
| 410 | } else { |
| 411 | out.push(o.lines[i].clone()); |
| 412 | i += 1; |
| 413 | } |
| 414 | let at = i; |
| 415 | if last_line.get(&table) == Some(&at) { |
| 416 | for (n, ((tb, _), v)) in added.iter().enumerate() { |
| 417 | if *tb == table && placed.insert(n) { |
| 418 | if out.last().is_some_and(|l| !l.ends_with('\n')) { |
| 419 | out.push("\n".into()); |
| 420 | } |
| 421 | out.push(v.clone()); |
| 422 | } |
| 423 | } |
| 424 | } |
| 425 | } |
| 426 | // Keys for tables main's file has no keys in (or doesn't have): at the end, under their table. |
| 427 | for (n, ((tb, _), v)) in added.iter().enumerate() { |
| 428 | if placed.contains(&n) { |
| 429 | continue; |
| 430 | } |
| 431 | if out.last().is_some_and(|l| !l.ends_with('\n')) { |
| 432 | out.push("\n".into()); |
| 433 | } |
| 434 | if !tb.is_empty() && !o.tables.contains(tb) && !out.iter().any(|l| l.trim() == format!("[{tb}]")) { |
| 435 | out.push(format!("\n[{tb}]\n")); |
| 436 | } |
| 437 | out.push(v.clone()); |
| 438 | } |
| 439 | let _ = &t.tables; |
| 440 | Some(out.concat().into_bytes()) |
| 441 | } |
| 442 |
| 443 | #[cfg(test)] |
| 444 | mod tests { |
| 445 | use super::*; |
| 446 | use crate::merge; |
| 447 |
| 448 | fn smart(path: &str, base: &str, ours: &str, theirs: &str) -> (String, usize) { |
| 449 | let m = merge::text_for(path, base.as_bytes(), ours.as_bytes(), theirs.as_bytes(), "main", "change"); |
| 450 | (String::from_utf8(m.content).unwrap(), m.conflicts) |
| 451 | } |
| 452 |
| 453 | #[test] |
| 454 | fn both_added_at_the_same_place() { |
| 455 | let base = "fn a() {}\n"; |
| 456 | let ours = "fn a() {}\nfn b() {}\n"; |
| 457 | let theirs = "fn a() {}\nfn c() {}\n"; |
| 458 | assert_eq!(smart("lib.rs", base, ours, theirs), ("fn a() {}\nfn b() {}\nfn c() {}\n".into(), 0)); |
| 459 | // Some of the same lines: still a conflict. |
| 460 | assert_eq!(smart("lib.rs", base, "fn a() {}\nfn b() {}\n}\n", "fn a() {}\nfn c() {}\n}\n").1, 1); |
| 461 | } |
| 462 |
| 463 | #[test] |
| 464 | fn import_blocks() { |
| 465 | let base = "import { a } from \"a\";\nimport { c } from \"c\";\n\nrun();\n"; |
| 466 | let ours = "import { a } from \"a\";\nimport { b } from \"b\";\nimport { c } from \"c\";\n\nrun();\n"; |
| 467 | let theirs = "import { a } from \"a\";\nimport { bb } from \"bb\";\nimport { c } from \"c\";\n\nrun();\n"; |
| 468 | let (text, conflicts) = smart("app.ts", base, ours, theirs); |
| 469 | assert_eq!(conflicts, 0); |
| 470 | assert_eq!(text, "import { a } from \"a\";\nimport { b } from \"b\";\nimport { bb } from \"bb\";\nimport { c } from \"c\";\n\nrun();\n"); |
| 471 | // One side removes an import the other kept. |
| 472 | let (text, conflicts) = smart("m.py", "import os\nimport re\n", "import os\nimport sys\n", "import json\nimport os\nimport re\n"); |
| 473 | assert_eq!((text.as_str(), conflicts), ("import json\nimport os\nimport sys\n", 0)); |
| 474 | } |
| 475 |
| 476 | #[test] |
| 477 | fn toml_by_key() { |
| 478 | let base = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n# deps\n[dependencies]\nserde = \"1\"\n"; |
| 479 | let ours = "[package]\nname = \"app\"\nversion = \"0.2.0\"\n\n# deps\n[dependencies]\nserde = \"1\"\ntokio = \"1\"\n"; |
| 480 | let theirs = "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n# deps\n[dependencies]\nserde = \"1\"\nrand = \"0.8\"\n"; |
| 481 | let merged = String::from_utf8(toml(Some(base.as_bytes()), ours.as_bytes(), theirs.as_bytes()).unwrap()).unwrap(); |
| 482 | 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"); |
| 483 | // The same key changed differently: no merge. |
| 484 | 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()); |
| 485 | } |
| 486 | } |
| 487 |