Onset: version control where nobody waits and nothing gets lost. The on command, its core and onset-server.
| 6 | //! merge into a conflict: a merged file that declares something more times |
| 7 | //! than either side did (two `def helper` in one module, `timeout:` twice in |
| 8 | //! one YAML map, two `"test"` scripts in one JSON object) isn't a clean merge, |
| 9 | //! even where git would call it one. |
| 10 |
| 11 | use std::collections::HashMap; |
| 12 |
| 13 | #[derive(Clone, Copy, PartialEq, Eq, Debug)] |
| 14 | enum Family { |
| 15 | Python, |
| 16 | /// JavaScript and TypeScript. |
| 17 | Script, |
| 18 | Rust, |
| 19 | Go, |
| 20 | /// Java, Kotlin, Scala, C#, Swift, C and C++: members named by signature (overloads differ). |
| 21 | Signature, |
| 22 | Yaml, |
| 23 | /// .env and .properties: `KEY=value`. |
| 24 | Env, |
| 25 | /// .ini, .cfg, .conf: keys in [sections]. |
| 26 | Ini, |
| 27 | Json, |
| 28 | Toml, |
| 29 | Other, |
| 30 | } |
| 31 |
| 32 | fn family(path: &str) -> Family { |
| 33 | let name = path.rsplit('/').next().unwrap_or(path).to_ascii_lowercase(); |
| 34 | if name == ".env" || name.starts_with(".env.") || name.ends_with(".env") { |
| 35 | return Family::Env; |
| 36 | } |
| 37 | let ext = name.rsplit_once('.').map(|(_, e)| e).unwrap_or(""); |
| 38 | match ext { |
| 39 | "py" | "pyi" => Family::Python, |
| 40 | "js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs" | "mts" | "cts" => Family::Script, |
| 41 | "rs" => Family::Rust, |
| 42 | "go" => Family::Go, |
| 43 | "java" | "kt" | "kts" | "scala" | "cs" | "swift" | "c" | "h" | "cc" | "cpp" | "cxx" | "hpp" | "hh" | "m" | "mm" => Family::Signature, |
| 44 | "yml" | "yaml" => Family::Yaml, |
| 45 | "properties" => Family::Env, |
| 46 | "ini" | "cfg" | "conf" => Family::Ini, |
| 47 | "json" => Family::Json, |
| 48 | "toml" => Family::Toml, |
| 49 | _ => Family::Other, |
| 50 | } |
| 51 | } |
| 52 |
| 53 | /// Every declaration or key in `text`, as (where it sits, line number). |
| 54 | pub fn declared(path: &str, text: &[u8]) -> Vec<(String, usize)> { |
| 55 | let Ok(text) = std::str::from_utf8(text) else { return Vec::new() }; |
| 56 | match family(path) { |
| 57 | Family::Python => python(text), |
| 58 | Family::Script | Family::Rust | Family::Go | Family::Signature => braces(family(path), text), |
| 59 | Family::Yaml => yaml(text), |
| 60 | Family::Env => env(text), |
| 61 | Family::Ini => ini(text), |
| 62 | Family::Json => json(text), |
| 63 | Family::Toml => toml(text), |
| 64 | Family::Other => Vec::new(), |
| 65 | } |
| 66 | } |
| 67 |
| 68 | /// What `merged` declares more times than `ours` or `theirs` did alone. |
| 69 | pub fn new_duplicates(path: &str, ours: &[u8], theirs: &[u8], merged: &[u8]) -> Vec<String> { |
| 70 | if family(path) == Family::Other { |
| 71 | return Vec::new(); |
| 72 | } |
| 73 | let count = |text: &[u8]| { |
| 74 | let mut m: HashMap<String, usize> = HashMap::new(); |
| 75 | for (k, _) in declared(path, text) { |
| 76 | *m.entry(k).or_default() += 1; |
| 77 | } |
| 78 | m |
| 79 | }; |
| 80 | let (o, t, m) = (count(ours), count(theirs), count(merged)); |
| 81 | let mut out: Vec<String> = m |
| 82 | .into_iter() |
| 83 | .filter(|(k, n)| *n > 1 && *n > o.get(k).copied().unwrap_or(0).max(t.get(k).copied().unwrap_or(0))) |
| 84 | .map(|(k, _)| k) |
| 85 | .collect(); |
| 86 | out.sort(); |
| 87 | out |
| 88 | } |
| 89 |
| 90 | /// The lines of `text` that declare `key`. |
| 91 | pub fn lines_of(path: &str, text: &[u8], key: &str) -> Vec<usize> { |
| 92 | declared(path, text).into_iter().filter(|(k, _)| k == key).map(|(_, l)| l).collect() |
| 93 | } |
| 94 |
| 95 | /// The last part of a key, for messages (`Shop.total` → `total`). |
| 96 | pub fn short(key: &str) -> &str { |
| 97 | key.rsplit_once('.').map(|(_, n)| n).unwrap_or(key) |
| 98 | } |
| 99 |
| 100 | fn ident(s: &str) -> Option<&str> { |
| 101 | let end = s.find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$')).unwrap_or(s.len()); |
| 102 | (end > 0 && !s.as_bytes()[0].is_ascii_digit()).then(|| &s[..end]) |
| 103 | } |
| 104 |
| 105 | /// `= {` or `: {` (an object literal or a typed one), not `= () => {`. |
| 106 | fn opens_object(tail: &str) -> bool { |
| 107 | let rest = tail.trim_start_matches(['=', ':']).trim(); |
| 108 | rest == "{" || (tail.starts_with(':') && rest.ends_with("= {")) |
| 109 | } |
| 110 |
| 111 | fn after<'a>(s: &'a str, word: &str) -> Option<&'a str> { |
| 112 | let rest = s.strip_prefix(word)?; |
| 113 | (rest.starts_with(char::is_whitespace)).then(|| rest.trim_start()) |
| 114 | } |
| 115 |
| 116 | // Python: indentation says where a def or class sits. |
| 117 |
| 118 | fn python(text: &str) -> Vec<(String, usize)> { |
| 119 | let mut out = Vec::new(); |
| 120 | let mut stack: Vec<(usize, String)> = Vec::new(); |
| 121 | let mut in_string: Option<&str> = None; |
| 122 | for (n, line) in text.lines().enumerate() { |
| 123 | let trimmed = line.trim_start(); |
| 124 | // Skip docstrings and other triple-quoted text. |
| 125 | if let Some(q) = in_string { |
| 126 | if trimmed.contains(q) { |
| 127 | in_string = None; |
| 128 | } |
| 129 | continue; |
| 130 | } |
| 131 | for q in ["\"\"\"", "'''"] { |
| 132 | if trimmed.matches(q).count() % 2 == 1 { |
| 133 | in_string = Some(q); |
| 134 | } |
| 135 | } |
| 136 | if trimmed.is_empty() || trimmed.starts_with('#') { |
| 137 | continue; |
| 138 | } |
| 139 | let indent = line.len() - trimmed.len(); |
| 140 | while stack.last().is_some_and(|(i, _)| *i >= indent) { |
| 141 | stack.pop(); |
| 142 | } |
| 143 | let s = trimmed.strip_prefix("async ").map(str::trim_start).unwrap_or(trimmed); |
| 144 | let name = after(s, "def").or_else(|| after(s, "class")).and_then(ident); |
| 145 | let assigned = (indent == 0 || stack.last().is_some_and(|(_, k)| k.starts_with("class "))).then(|| { |
| 146 | // A module or class attribute: NAME = ... or NAME: type = ... |
| 147 | let id = ident(s)?; |
| 148 | let rest = s[id.len()..].trim_start(); |
| 149 | ((rest.starts_with('=') && !rest.starts_with("==")) || rest.starts_with(':')).then_some(id) |
| 150 | }); |
| 151 | let parent = stack.iter().map(|(_, k)| k.split_once(' ').map(|(_, n)| n).unwrap_or(k)).collect::<Vec<_>>().join("."); |
| 152 | let qualified = |name: &str| if parent.is_empty() { name.to_string() } else { format!("{parent}.{name}") }; |
| 153 | if let Some(name) = name { |
| 154 | out.push((qualified(name), n)); |
| 155 | let kind = if s.starts_with("class") { "class" } else { "def" }; |
| 156 | stack.push((indent, format!("{kind} {name}"))); |
| 157 | } else if let Some(Some(id)) = assigned |
| 158 | && id != "_" |
| 159 | { |
| 160 | out.push((qualified(id), n)); |
| 161 | } |
| 162 | } |
| 163 | out |
| 164 | } |
| 165 |
| 166 | // Brace languages: depth says where a declaration sits; strings and comments are skipped. |
| 167 |
| 168 | /// A line with strings' contents and comments taken out, so braces and words |
| 169 | /// in them don't count. `block` carries a /* comment */ across lines. |
| 170 | fn code_of(line: &str, block: &mut bool, raw_string: &mut bool) -> String { |
| 171 | let mut out = String::with_capacity(line.len()); |
| 172 | let b = line.as_bytes(); |
| 173 | let mut i = 0; |
| 174 | while i < b.len() { |
| 175 | if *block { |
| 176 | if b[i..].starts_with(b"*/") { |
| 177 | *block = false; |
| 178 | i += 2; |
| 179 | } else { |
| 180 | i += 1; |
| 181 | } |
| 182 | continue; |
| 183 | } |
| 184 | if *raw_string { |
| 185 | if b[i] == b'`' { |
| 186 | *raw_string = false; |
| 187 | out.push('`'); |
| 188 | } |
| 189 | i += 1; |
| 190 | continue; |
| 191 | } |
| 192 | match b[i] { |
| 193 | b'/' if b.get(i + 1) == Some(&b'/') => break, |
| 194 | b'/' if b.get(i + 1) == Some(&b'*') => { |
| 195 | *block = true; |
| 196 | i += 2; |
| 197 | } |
| 198 | b'`' => { |
| 199 | *raw_string = true; |
| 200 | out.push('`'); |
| 201 | i += 1; |
| 202 | } |
| 203 | q @ (b'"' | b'\'') => { |
| 204 | // A lifetime or char-like single quote with no closing quote nearby is kept. |
| 205 | let close = b[i + 1..].iter().enumerate().scan(false, |esc, (k, &c)| { |
| 206 | let hit = !*esc && c == q; |
| 207 | *esc = !*esc && c == b'\\'; |
| 208 | Some((k, hit)) |
| 209 | }); |
| 210 | match close.filter(|(_, hit)| *hit).map(|(k, _)| k).next() { |
| 211 | Some(k) => { |
| 212 | out.push(q as char); |
| 213 | out.push(q as char); |
| 214 | i += k + 2; |
| 215 | } |
| 216 | None => { |
| 217 | out.push(q as char); |
| 218 | i += 1; |
| 219 | } |
| 220 | } |
| 221 | } |
| 222 | c => { |
| 223 | out.push(c as char); |
| 224 | i += 1; |
| 225 | } |
| 226 | } |
| 227 | } |
| 228 | out |
| 229 | } |
| 230 |
| 231 | const MODIFIERS: &[&str] = &[ |
| 232 | "export", "default", "pub", "pub(crate)", "pub(super)", "pub(self)", "async", "unsafe", "static", "public", "private", "protected", "internal", "final", "abstract", "override", |
| 233 | "open", "extern", "inline", "virtual", "sealed", "readonly", "declare", "partial", "fileprivate", "mutating", "lateinit", "suspend", "operator", "infix", "data", "inner", |
| 234 | "companion", "synchronized", "native", "transient", "volatile", "strictfp", "constexpr", "explicit", "friend", "new", |
| 235 | ]; |
| 236 |
| 237 | fn strip_modifiers(mut s: &str) -> &str { |
| 238 | loop { |
| 239 | let before = s; |
| 240 | while s.starts_with('@') || s.starts_with('#') { |
| 241 | // Annotations and attributes on the same line (@Override, #[inline]). |
| 242 | let end = s.find(char::is_whitespace).unwrap_or(s.len()); |
| 243 | if s.starts_with("#[") { |
| 244 | match s.find(']') { |
| 245 | Some(e) => s = s[e + 1..].trim_start(), |
| 246 | None => return "", |
| 247 | } |
| 248 | } else { |
| 249 | s = s[end..].trim_start(); |
| 250 | } |
| 251 | } |
| 252 | for m in MODIFIERS { |
| 253 | if let Some(rest) = after(s, m) { |
| 254 | s = rest; |
| 255 | } |
| 256 | } |
| 257 | if s == before { |
| 258 | return s; |
| 259 | } |
| 260 | } |
| 261 | } |
| 262 |
| 263 | const CONTROL: &[&str] = &[ |
| 264 | "if", "else", "for", "while", "switch", "match", "return", "catch", "do", "try", "throw", "case", "when", "loop", "using", "lock", "yield", "await", "guard", "defer", "go", "select", |
| 265 | "sizeof", "typeof", "delete", "assert", "print", "println", |
| 266 | ]; |
| 267 |
| 268 | /// What a line declares, in a brace language: (name, whether it holds members). |
| 269 | fn brace_decl(fam: Family, s: &str, parent_holds_members: bool) -> Option<(String, bool)> { |
| 270 | let s = strip_modifiers(s.trim()); |
| 271 | let word = |w: &str| after(s, w).and_then(ident).map(String::from); |
| 272 | let first = ident(s).unwrap_or(""); |
| 273 | if CONTROL.contains(&first) { |
| 274 | return None; |
| 275 | } |
| 276 | match fam { |
| 277 | Family::Script => { |
| 278 | if let Some(rest) = after(s, "function").or_else(|| s.strip_prefix("function*").map(str::trim_start)) { |
| 279 | return ident(rest.trim_start_matches('*').trim_start()).map(|n| (n.to_string(), false)); |
| 280 | } |
| 281 | for (w, holds) in [("class", true), ("interface", true), ("enum", true), ("namespace", true), ("module", true), ("type", false)] { |
| 282 | if let Some(n) = word(w) { |
| 283 | return Some((n, holds)); |
| 284 | } |
| 285 | } |
| 286 | for w in ["const", "let", "var"] { |
| 287 | if let Some(rest) = after(s, w) |
| 288 | && let Some(n) = ident(rest) |
| 289 | { |
| 290 | let tail = rest[n.len()..].trim_start(); |
| 291 | if tail.starts_with('=') || tail.starts_with(':') || tail.starts_with(';') || tail.is_empty() { |
| 292 | // An object assigned here holds members (a function assigned here has a body). |
| 293 | return Some((n.to_string(), opens_object(tail))); |
| 294 | } |
| 295 | } |
| 296 | } |
| 297 | if parent_holds_members { |
| 298 | // Members: name(, get name(, name: ..., name = ..., #name, "quoted": ... |
| 299 | let s2 = s.strip_prefix("get ").or_else(|| s.strip_prefix("set ")).map(|r| r.trim_start()).unwrap_or(s); |
| 300 | let prefix = if s2.len() != s.len() { &s[..3] } else { "" }; |
| 301 | let s2 = s2.trim_start_matches('#').trim_start_matches('*'); |
| 302 | let (name, tail) = if let Some(q) = s2.strip_prefix('"').or_else(|| s2.strip_prefix('\'')) { |
| 303 | let end = q.find(['"', '\''])?; |
| 304 | (q[..end].to_string(), q[end + 1..].trim_start()) |
| 305 | } else { |
| 306 | let n = ident(s2)?; |
| 307 | (n.to_string(), s2[n.len()..].trim_start()) |
| 308 | }; |
| 309 | let tail = tail.trim_start_matches('?').trim_start_matches('!'); |
| 310 | if tail.starts_with('(') || tail.starts_with('<') || tail.starts_with(':') || (tail.starts_with('=') && !tail.starts_with("==")) { |
| 311 | return Some((format!("{prefix}{name}"), opens_object(tail))); |
| 312 | } |
| 313 | } |
| 314 | None |
| 315 | } |
| 316 | Family::Rust => { |
| 317 | let s = s.strip_prefix("const ").filter(|r| r.trim_start().starts_with("fn ")).unwrap_or(s); |
| 318 | if let Some(n) = word("fn") { |
| 319 | return Some((n, false)); |
| 320 | } |
| 321 | if let Some(rest) = s.strip_prefix("macro_rules!") { |
| 322 | return ident(rest.trim_start()).map(|n| (format!("{n}!"), false)); |
| 323 | } |
| 324 | for (w, holds) in [("struct", true), ("enum", true), ("trait", true), ("union", true), ("mod", true), ("type", false), ("static", false)] { |
| 325 | if let Some(n) = word(w) { |
| 326 | return Some((n, holds)); |
| 327 | } |
| 328 | } |
| 329 | if let Some(n) = word("const").filter(|n| n != "fn") { |
| 330 | return Some((n, false)); |
| 331 | } |
| 332 | if first == "impl" { |
| 333 | let header = s.split('{').next().unwrap_or(s).split_whitespace().collect::<Vec<_>>().join(" "); |
| 334 | return Some((header, true)); |
| 335 | } |
| 336 | if parent_holds_members { |
| 337 | // Fields (name: Type) and variants (Name, Name(..), Name { .. }). |
| 338 | let n = ident(s)?; |
| 339 | let tail = s[n.len()..].trim_start(); |
| 340 | if (tail.starts_with(':') && !tail.starts_with("::")) || tail.starts_with(',') || tail.starts_with('(') || tail.starts_with('{') || tail.is_empty() || tail.starts_with('=') { |
| 341 | return Some((n.to_string(), false)); |
| 342 | } |
| 343 | } |
| 344 | None |
| 345 | } |
| 346 | Family::Go => { |
| 347 | if let Some(rest) = after(s, "func") { |
| 348 | if let Some(recv) = rest.strip_prefix('(') { |
| 349 | let close = recv.find(')')?; |
| 350 | let ty = recv[..close].split_whitespace().last()?.trim_start_matches('*'); |
| 351 | let ty = ty.split('[').next().unwrap_or(ty); |
| 352 | let n = ident(recv[close + 1..].trim_start())?; |
| 353 | return Some((format!("{ty}.{n}"), false)); |
| 354 | } |
| 355 | return ident(rest).map(|n| (n.to_string(), false)); |
| 356 | } |
| 357 | for w in ["type", "var", "const"] { |
| 358 | if let Some(n) = word(w) { |
| 359 | let holds = w == "type" && (s.contains(" struct") || s.contains(" interface")); |
| 360 | return Some((n, holds)); |
| 361 | } |
| 362 | } |
| 363 | if parent_holds_members { |
| 364 | let n = ident(s)?; |
| 365 | let tail = &s[n.len()..]; |
| 366 | if tail.starts_with(char::is_whitespace) && !tail.trim().is_empty() { |
| 367 | return Some((n.to_string(), false)); |
| 368 | } |
| 369 | } |
| 370 | None |
| 371 | } |
| 372 | Family::Signature => { |
| 373 | for w in ["class", "interface", "enum", "struct", "record", "object", "protocol", "extension", "namespace", "trait", "union", "actor"] { |
| 374 | // A type only where its body follows (`struct strbuf *sb` names one, it doesn't declare it). |
| 375 | if let Some(rest) = after(s, w) |
| 376 | && let Some(n) = ident(rest) |
| 377 | { |
| 378 | let tail = rest[n.len()..].trim_start(); |
| 379 | let opens = tail.is_empty() |
| 380 | || tail.starts_with('{') |
| 381 | || (tail.starts_with(':') && !tail.starts_with("::")) |
| 382 | || tail.starts_with('<') |
| 383 | || tail.starts_with('(') |
| 384 | || ["extends", "implements", "where", "final", "permits"].iter().any(|k| tail.starts_with(k)); |
| 385 | if opens { |
| 386 | return Some((n.to_string(), true)); |
| 387 | } |
| 388 | } |
| 389 | } |
| 390 | for w in ["val", "var", "let", "func", "fun", "def", "init", "deinit"] { |
| 391 | if let Some(rest) = after(s, w).or_else(|| (s == w || s.starts_with(&format!("{w}("))).then(|| &s[w.len()..])) { |
| 392 | if w == "func" || w == "fun" || w == "def" || w == "init" { |
| 393 | // Overloads differ by parameters: the whole signature names it. |
| 394 | let sig = rest.split('{').next().unwrap_or(rest).split_whitespace().collect::<Vec<_>>().join(" "); |
| 395 | return Some((format!("{w} {sig}"), false)); |
| 396 | } |
| 397 | return ident(rest).map(|n| (n.to_string(), false)); |
| 398 | } |
| 399 | } |
| 400 | if !parent_holds_members { |
| 401 | return None; |
| 402 | } |
| 403 | // A method or function: `<type> name(params)` with a body (or ending a prototype in a class). |
| 404 | if let Some(open) = s.find('(') { |
| 405 | let head = s[..open].trim_end(); |
| 406 | let name = head.rsplit(|c: char| !(c.is_alphanumeric() || c == '_' || c == '~' || c == ':')).next()?; |
| 407 | if name.is_empty() || CONTROL.contains(&name) { |
| 408 | return None; |
| 409 | } |
| 410 | let close = s.rfind(')')?; |
| 411 | let tail = s[close + 1..].trim(); |
| 412 | let body = tail.ends_with('{') || tail.is_empty() || tail.starts_with("const") || tail.starts_with("throws") || tail.starts_with("->") || tail.starts_with("where") || tail.starts_with(':'); |
| 413 | if !body && !(parent_holds_members && tail.ends_with(';')) { |
| 414 | return None; |
| 415 | } |
| 416 | let sig = s[..=close].split_whitespace().collect::<Vec<_>>().join(" "); |
| 417 | return Some((sig, false)); |
| 418 | } |
| 419 | if parent_holds_members && s.ends_with(';') { |
| 420 | // A field: the name before = or ;. |
| 421 | let decl = s.split('=').next().unwrap_or(s).trim_end_matches(';').trim(); |
| 422 | let n = decl.rsplit(|c: char| !(c.is_alphanumeric() || c == '_')).next()?; |
| 423 | if !n.is_empty() && decl.contains(char::is_whitespace) { |
| 424 | return Some((n.to_string(), false)); |
| 425 | } |
| 426 | } |
| 427 | None |
| 428 | } |
| 429 | _ => None, |
| 430 | } |
| 431 | } |
| 432 |
| 433 | fn braces(fam: Family, text: &str) -> Vec<(String, usize)> { |
| 434 | let mut out = Vec::new(); |
| 435 | // Open braces: the name of what each one opened, and whether it holds members. |
| 436 | let mut opened: Vec<Option<(String, bool)>> = Vec::new(); |
| 437 | let mut pending: Option<(String, bool)> = None; |
| 438 | let (mut block, mut raw) = (false, false); |
| 439 | for (n, line) in text.lines().enumerate() { |
| 440 | let code = code_of(line, &mut block, &mut raw); |
| 441 | let trimmed = code.trim(); |
| 442 | if trimmed.is_empty() { |
| 443 | continue; |
| 444 | } |
| 445 | let inside = opened.last().map(|o| o.as_ref().is_some_and(|(_, holds)| *holds)).unwrap_or(fam == Family::Signature && opened.is_empty()); |
| 446 | let in_body = opened.iter().any(|o| o.as_ref().is_some_and(|(_, holds)| !*holds)); |
| 447 | let starts_with_close = trimmed.starts_with('}'); |
| 448 | // A prototype at the top of a C file (`int f(void);`) may be repeated; only definitions count there. |
| 449 | let prototype = fam == Family::Signature && opened.is_empty() && trimmed.ends_with(';'); |
| 450 | let decl = if starts_with_close || prototype || (in_body && fam != Family::Script && fam != Family::Rust) { |
| 451 | None |
| 452 | } else { |
| 453 | brace_decl(fam, trimmed, inside) |
| 454 | }; |
| 455 | let qualified = decl.as_ref().map(|(name, holds)| { |
| 456 | let path = opened.iter().filter_map(|o| o.as_ref().map(|(k, _)| k.as_str())).collect::<Vec<_>>().join("."); |
| 457 | let key = if path.is_empty() { name.clone() } else { format!("{path}.{name}") }; |
| 458 | (key, *holds) |
| 459 | }); |
| 460 | if let Some((key, _)) = &qualified { |
| 461 | out.push((key.clone(), n)); |
| 462 | } |
| 463 | if qualified.is_some() { |
| 464 | pending = qualified.clone(); |
| 465 | } |
| 466 | // A brace closed on its own line (`= {}`, `{ a, b }`) isn't the one that |
| 467 | // opens a declaration's body: that's the first one still open at the |
| 468 | // end of the line. Unnamed blocks (callbacks, ifs) are scopes of their own. |
| 469 | let chars: Vec<char> = trimmed.chars().collect(); |
| 470 | let mut closes_here = vec![false; chars.len()]; |
| 471 | let mut open_at: Vec<usize> = Vec::new(); |
| 472 | for (i, c) in chars.iter().enumerate() { |
| 473 | match c { |
| 474 | '{' => open_at.push(i), |
| 475 | '}' => { |
| 476 | if let Some(o) = open_at.pop() { |
| 477 | closes_here[o] = true; |
| 478 | } |
| 479 | } |
| 480 | _ => {} |
| 481 | } |
| 482 | } |
| 483 | for (i, c) in chars.iter().enumerate() { |
| 484 | match c { |
| 485 | '{' if closes_here[i] => opened.push(None), |
| 486 | '{' => opened.push(Some(pending.take().unwrap_or_else(|| (format!("@{n}"), false)))), |
| 487 | '}' => { |
| 488 | opened.pop(); |
| 489 | } |
| 490 | ';' => pending = None, |
| 491 | _ => {} |
| 492 | } |
| 493 | } |
| 494 | // A declaration whole on one line (`function a() {}`) names no later block. |
| 495 | if trimmed.ends_with('}') { |
| 496 | pending = None; |
| 497 | } |
| 498 | } |
| 499 | out |
| 500 | } |
| 501 |
| 502 | // Data files. |
| 503 |
| 504 | /// YAML mapping keys, by where they sit (indent and the keys above them). |
| 505 | fn yaml(text: &str) -> Vec<(String, usize)> { |
| 506 | let mut out = Vec::new(); |
| 507 | // (indent, path to here, list item number when inside one) |
| 508 | let mut stack: Vec<(usize, String)> = Vec::new(); |
| 509 | let mut block_scalar: Option<usize> = None; |
| 510 | let mut item = 0usize; |
| 511 | for (n, line) in text.lines().enumerate() { |
| 512 | let trimmed = line.trim_start(); |
| 513 | let indent = line.len() - trimmed.len(); |
| 514 | if let Some(i) = block_scalar { |
| 515 | if trimmed.is_empty() || indent > i { |
| 516 | continue; |
| 517 | } |
| 518 | block_scalar = None; |
| 519 | } |
| 520 | if trimmed.is_empty() || trimmed.starts_with('#') { |
| 521 | continue; |
| 522 | } |
| 523 | if trimmed.starts_with("---") || trimmed.starts_with("...") { |
| 524 | stack.clear(); |
| 525 | item += 1; |
| 526 | continue; |
| 527 | } |
| 528 | let (indent, body) = if let Some(rest) = trimmed.strip_prefix("- ") { |
| 529 | // A list item starts a mapping of its own. |
| 530 | item += 1; |
| 531 | while stack.last().is_some_and(|(i, _)| *i >= indent) { |
| 532 | stack.pop(); |
| 533 | } |
| 534 | let parent = stack.last().map(|(_, p)| p.clone()).unwrap_or_default(); |
| 535 | stack.push((indent, format!("{parent}[{item}]"))); |
| 536 | (indent + 2 + (rest.len() - rest.trim_start().len()), rest.trim_start()) |
| 537 | } else { |
| 538 | (indent, trimmed) |
| 539 | }; |
| 540 | let key = if let Some(q) = body.strip_prefix('"').or_else(|| body.strip_prefix('\'')) { |
| 541 | q.find(['"', '\'']).map(|e| (&q[..e], q[e + 1..].trim_start())) |
| 542 | } else { |
| 543 | // The colon that ends a key has a space or the line's end after it (`test:linux:` is one key). |
| 544 | let b = body.as_bytes(); |
| 545 | (0..b.len()).find(|&i| b[i] == b':' && (i + 1 == b.len() || b[i + 1].is_ascii_whitespace())).map(|e| (body[..e].trim_end(), &body[e..])) |
| 546 | }; |
| 547 | let Some((key, tail)) = key else { continue }; |
| 548 | if !tail.starts_with(':') || !(tail.len() == 1 || tail[1..].starts_with(char::is_whitespace)) || key.is_empty() || key.contains(' ') && !body.starts_with(['"', '\'']) { |
| 549 | continue; |
| 550 | } |
| 551 | while stack.last().is_some_and(|(i, _)| *i >= indent) { |
| 552 | stack.pop(); |
| 553 | } |
| 554 | let parent = stack.last().map(|(_, p)| p.clone()).unwrap_or_default(); |
| 555 | let path = if parent.is_empty() { key.to_string() } else { format!("{parent}.{key}") }; |
| 556 | out.push((path.clone(), n)); |
| 557 | stack.push((indent, path)); |
| 558 | let value = tail[1..].trim(); |
| 559 | if value.starts_with('|') || value.starts_with('>') { |
| 560 | block_scalar = Some(indent); |
| 561 | } |
| 562 | } |
| 563 | out |
| 564 | } |
| 565 |
| 566 | fn env(text: &str) -> Vec<(String, usize)> { |
| 567 | text.lines() |
| 568 | .enumerate() |
| 569 | .filter_map(|(n, line)| { |
| 570 | let s = line.trim(); |
| 571 | if s.is_empty() || s.starts_with('#') || s.starts_with('!') { |
| 572 | return None; |
| 573 | } |
| 574 | let s = s.strip_prefix("export ").map(str::trim_start).unwrap_or(s); |
| 575 | let end = s.find(['=', ':'])?; |
| 576 | let key = s[..end].trim(); |
| 577 | (!key.is_empty() && !key.contains(char::is_whitespace)).then(|| (key.to_string(), n)) |
| 578 | }) |
| 579 | .collect() |
| 580 | } |
| 581 |
| 582 | fn ini(text: &str) -> Vec<(String, usize)> { |
| 583 | let mut out = Vec::new(); |
| 584 | let mut section = String::new(); |
| 585 | for (n, line) in text.lines().enumerate() { |
| 586 | let s = line.trim(); |
| 587 | if s.is_empty() || s.starts_with('#') || s.starts_with(';') { |
| 588 | continue; |
| 589 | } |
| 590 | if s.starts_with('[') && s.ends_with(']') { |
| 591 | section = s[1..s.len() - 1].trim().to_string(); |
| 592 | continue; |
| 593 | } |
| 594 | if let Some(end) = s.find(['=', ':']) { |
| 595 | let key = s[..end].trim(); |
| 596 | if !key.is_empty() { |
| 597 | out.push((format!("{section}.{key}"), n)); |
| 598 | } |
| 599 | } |
| 600 | } |
| 601 | out |
| 602 | } |
| 603 |
| 604 | /// JSON object keys, by the object they're in. |
| 605 | fn json(text: &str) -> Vec<(String, usize)> { |
| 606 | let mut out = Vec::new(); |
| 607 | // Each open object or array: its path, and how many items an array has had. |
| 608 | let mut stack: Vec<(String, Option<usize>)> = Vec::new(); |
| 609 | let mut last_key: Option<String> = None; |
| 610 | let b = text.as_bytes(); |
| 611 | let (mut i, mut line) = (0, 0); |
| 612 | while i < b.len() { |
| 613 | match b[i] { |
| 614 | b'\n' => line += 1, |
| 615 | b'"' => { |
| 616 | let start = i + 1; |
| 617 | let mut j = start; |
| 618 | while j < b.len() && b[j] != b'"' { |
| 619 | if b[j] == b'\\' { |
| 620 | j += 1; |
| 621 | } |
| 622 | if b.get(j) == Some(&b'\n') { |
| 623 | line += 1; |
| 624 | } |
| 625 | j += 1; |
| 626 | } |
| 627 | let s = String::from_utf8_lossy(&b[start..j.min(b.len())]).into_owned(); |
| 628 | i = j; |
| 629 | // A key: a string followed by a colon, inside an object. |
| 630 | let mut k = i + 1; |
| 631 | while k < b.len() && b[k].is_ascii_whitespace() { |
| 632 | k += 1; |
| 633 | } |
| 634 | if b.get(k) == Some(&b':') && stack.last().is_some_and(|(_, arr)| arr.is_none()) { |
| 635 | let parent = &stack.last().unwrap().0; |
| 636 | out.push((format!("{parent}/{s}"), line)); |
| 637 | last_key = Some(s); |
| 638 | } |
| 639 | } |
| 640 | b'{' | b'[' => { |
| 641 | let parent = stack.last().map(|(p, arr)| match arr { |
| 642 | Some(n) => format!("{p}/{n}"), |
| 643 | None => format!("{p}/{}", last_key.clone().unwrap_or_default()), |
| 644 | }); |
| 645 | stack.push((parent.unwrap_or_default(), (b[i] == b'[').then_some(0))); |
| 646 | last_key = None; |
| 647 | } |
| 648 | b'}' | b']' => { |
| 649 | stack.pop(); |
| 650 | } |
| 651 | b',' => { |
| 652 | if let Some((_, Some(n))) = stack.last_mut() { |
| 653 | *n += 1; |
| 654 | } |
| 655 | } |
| 656 | _ => {} |
| 657 | } |
| 658 | i += 1; |
| 659 | } |
| 660 | out |
| 661 | } |
| 662 |
| 663 | /// TOML keys, by table (an array of tables, [[x]], starts a new one each time). |
| 664 | fn toml(text: &str) -> Vec<(String, usize)> { |
| 665 | let mut out = Vec::new(); |
| 666 | let mut table = String::new(); |
| 667 | let mut tables = 0; |
| 668 | let mut in_string = false; |
| 669 | for (n, line) in text.lines().enumerate() { |
| 670 | let s = line.trim(); |
| 671 | let was_string = in_string; |
| 672 | if ["\"\"\"", "'''"].iter().any(|q| s.matches(q).count() % 2 == 1) { |
| 673 | in_string = !in_string; |
| 674 | } |
| 675 | if was_string || s.is_empty() || s.starts_with('#') { |
| 676 | continue; |
| 677 | } |
| 678 | if let Some(t) = s.strip_prefix("[[") { |
| 679 | tables += 1; |
| 680 | table = format!("{}#{tables}", t.trim_end_matches("]]").trim()); |
| 681 | continue; |
| 682 | } |
| 683 | if let Some(t) = s.strip_prefix('[') { |
| 684 | table = t.split(']').next().unwrap_or("").trim().to_string(); |
| 685 | out.push((format!("[{table}]"), n)); |
| 686 | continue; |
| 687 | } |
| 688 | if let Some(end) = s.find('=') { |
| 689 | let key = s[..end].trim(); |
| 690 | if !key.is_empty() && !key.starts_with(['{', '[', ',']) { |
| 691 | out.push((format!("{table}.{key}"), n)); |
| 692 | } |
| 693 | } |
| 694 | } |
| 695 | out |
| 696 | } |
| 697 |
| 698 | #[cfg(test)] |
| 699 | mod tests { |
| 700 | use super::*; |
| 701 |
| 702 | fn dups(path: &str, ours: &str, theirs: &str, merged: &str) -> Vec<String> { |
| 703 | new_duplicates(path, ours.as_bytes(), theirs.as_bytes(), merged.as_bytes()) |
| 704 | } |
| 705 |
| 706 | #[test] |
| 707 | fn python_defs_by_place() { |
| 708 | let ours = "def base():\n return 0\n\ndef helper(x):\n return x + 1\n"; |
| 709 | let theirs = "def base():\n return 0\n\ndef helper(items):\n return len(items)\n"; |
| 710 | let merged = "def base():\n return 0\n\ndef helper(x):\n return x + 1\n\ndef helper(items):\n return len(items)\n"; |
| 711 | assert_eq!(dups("u.py", ours, theirs, merged), vec!["helper"]); |
| 712 | // Two classes, each with its own __init__: not a duplicate. |
| 713 | let a = "class A:\n def __init__(self):\n pass\n"; |
| 714 | let ab = "class A:\n def __init__(self):\n pass\n\nclass B:\n def __init__(self):\n pass\n"; |
| 715 | assert!(dups("u.py", a, a, ab).is_empty()); |
| 716 | // A property and its setter on one side is what that side had. |
| 717 | let p = "class A:\n @property\n def x(self):\n return 1\n\n @x.setter\n def x(self, v):\n pass\n"; |
| 718 | assert!(dups("u.py", p, "class A:\n pass\n", p).is_empty()); |
| 719 | // Module constants set twice. |
| 720 | assert_eq!(dups("c.py", "A = 1\nT = 30\n", "A = 1\nT = 5\n", "A = 1\nT = 30\nT = 5\n"), vec!["T"]); |
| 721 | } |
| 722 |
| 723 | #[test] |
| 724 | fn brace_languages() { |
| 725 | let js = "export function two() {\n return 2;\n}\n"; |
| 726 | assert_eq!(dups("a.js", js, js, &format!("{js}\n{js}")), vec!["two"]); |
| 727 | let two = "function one() {\n const x = 1;\n return x;\n}\nfunction two() {\n const x = 2;\n return x;\n}\n"; |
| 728 | assert!(dups("a.ts", two, two, two).is_empty()); |
| 729 | assert!(declared("a.ts", two.as_bytes()).iter().any(|(k, _)| k == "one.x")); |
| 730 | let class = "class Shop {\n total() {\n return 1;\n }\n}\n"; |
| 731 | let class2 = "class Shop {\n total() {\n return 1;\n }\n total() {\n return 2;\n }\n}\n"; |
| 732 | assert_eq!(dups("s.ts", class, class, class2), vec!["Shop.total"]); |
| 733 | // Calls in a body aren't declarations. |
| 734 | let calls = "function run() {\n log(1);\n log(2);\n}\n"; |
| 735 | assert!(declared("r.js", calls.as_bytes()).iter().all(|(k, _)| k == "run")); |
| 736 | let rs = "impl Shop {\n fn total(&self) -> u32 {\n 1\n }\n}\n"; |
| 737 | let rs2 = "impl Shop {\n fn total(&self) -> u32 {\n 1\n }\n fn total(&self) -> u32 {\n 2\n }\n}\n"; |
| 738 | assert_eq!(dups("s.rs", rs, rs, rs2), vec!["impl Shop.total"]); |
| 739 | let go = "func (s *Shop) Total() int {\n\treturn 1\n}\n"; |
| 740 | assert_eq!(dups("s.go", go, go, &format!("{go}{go}")), vec!["Shop.Total"]); |
| 741 | // Java overloads differ by parameters. |
| 742 | let java = "class A {\n void f(int x) {\n }\n void f(String s) {\n }\n}\n"; |
| 743 | assert!(dups("A.java", java, java, java).is_empty()); |
| 744 | let java2 = "class A {\n void f(int x) {\n }\n void f(int x) {\n }\n}\n"; |
| 745 | assert_eq!(dups("A.java", "class A {\n void f(int x) {\n }\n}\n", "class A {\n void f(int x) {\n }\n}\n", java2), vec!["A.void f(int x)"]); |
| 746 | // Strings and comments don't open blocks. |
| 747 | let tricky = "const a = \"{\"; // {\nfunction b() {\n}\n"; |
| 748 | assert_eq!(declared("t.js", tricky.as_bytes()).iter().map(|(k, _)| k.as_str()).collect::<Vec<_>>(), vec!["a", "b"]); |
| 749 | } |
| 750 |
| 751 | #[test] |
| 752 | fn data_keys() { |
| 753 | let y = "server:\n port: 80\n timeout: 30\n"; |
| 754 | let y2 = "server:\n port: 80\n timeout: 30\n timeout: 5\n"; |
| 755 | assert_eq!(dups("c.yaml", y, y, y2), vec!["server.timeout"]); |
| 756 | // The same key in two list items is fine. |
| 757 | let list = "jobs:\n - name: a\n run: x\n - name: b\n run: y\n"; |
| 758 | assert!(dups("ci.yml", list, list, list).is_empty()); |
| 759 | // Block scalars are text. |
| 760 | let block = "a: |\n b: 1\n b: 2\nc: 1\n"; |
| 761 | assert!(dups("x.yml", block, block, block).is_empty()); |
| 762 | assert_eq!(dups(".env", "A=1\n", "A=1\n", "A=1\nA=2\n"), vec!["A"]); |
| 763 | let j = "{\n \"scripts\": {\n \"test\": \"jest\",\n \"test\": \"vitest\"\n }\n}\n"; |
| 764 | assert_eq!(dups("package.json", "{}", "{}", j), vec!["/scripts/test"]); |
| 765 | let arr = "[{\"a\": 1}, {\"a\": 2}]"; |
| 766 | assert!(dups("x.json", arr, arr, arr).is_empty()); |
| 767 | } |
| 768 | } |
| 769 |