diff --git a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs index 93cebb4f4..1487fb9ab 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs @@ -444,6 +444,10 @@ enum Driver { /// `source "" do` block (#1056): bundler locks it with a `!` /// source pin, which the unwind must keep. ScanVexSourceBlock, + /// [`Driver::ScanVexSourceBlock`] with assignment-form keyword + /// constructs (`v = if … end`, `w = case … end`) before the gem + /// (#1476): their `end`s must not close the user's source block. + ScanVexSourceBlockAssignment, /// A heredoc option continues beyond the declaration's physical line. ScanVexHeredocDeclaration, /// A double-quoted interpolation can itself contain a heredoc opener. @@ -526,6 +530,9 @@ impl Driver { Driver::ScanVexTwin => "scan --mode hosted (Gemfile + gems.rb twin)", Driver::ScanVexGroupBlock => "scan --mode hosted (gem in a group block)", Driver::ScanVexSourceBlock => "scan --mode hosted (gem in a source block)", + Driver::ScanVexSourceBlockAssignment => { + "scan --mode hosted (gem in a source block after `v = if … end`)" + } Driver::ScanVexSemicolonJoinedDeclaration => { "scan --mode hosted (two `;`-joined gem declarations)" } @@ -823,6 +830,12 @@ async fn redirect_scanned_project( "source \"{up}/upstream\"\n\nsource \"{up}/upstream\" do\n gem \"{DEP}\", \"{DEP_VERSION}\"\nend\n", up = server.uri() ), + Driver::ScanVexSourceBlockAssignment => format!( + "source \"{up}/upstream\"\n\nsource \"{up}/upstream\" do\n v = if ENV[\"ZZ\"]\n \"a\"\n \ + else\n \"b\"\n end\n w = case ENV[\"ZZ\"]\n when \"x\" then \"a\"\n \ + else \"b\"\n end\n gem \"{DEP}\", \"{DEP_VERSION}\"\nend\n", + up = server.uri() + ), Driver::ScanVexEvalGemfile => { std::fs::write(proj.join("Gemfile.common"), format!("gem \"{DEP}\"\n")).unwrap(); format!( @@ -1071,6 +1084,7 @@ async fn redirect_scanned_project( | Driver::ScanVexEvalGemfile | Driver::ScanVexGroupBlock | Driver::ScanVexSourceBlock + | Driver::ScanVexSourceBlockAssignment | Driver::ScanVexCustomGitSource | Driver::ScanVexMultiLineDeclaration | Driver::ScanVexConditionalDeclaration @@ -1275,6 +1289,7 @@ async fn redirect_scanned_project( Driver::ScanVex | Driver::ScanVexGroupBlock | Driver::ScanVexSourceBlock + | Driver::ScanVexSourceBlockAssignment | Driver::ScanVexTrailingSemicolonDeclaration => { assert_eq!(env["vex"]["statements"], 1, "vex block: {env}"); assert!( @@ -2342,18 +2357,29 @@ async fn gem_hosted_group_block_pin_survives_a_refused_vendored_takeover() { /// pre-scan pair, `!` included, so a FROZEN install of the restored pair /// still succeeds (it exited 16 when the unwind dropped the `!`). /// Converged without CHECKSUMS so the restore needs no rubygems.org sha. +/// #1476: the same holds when assignment-form `v = if … end` / +/// `w = case … end` constructs precede the gem inside the block. #[tokio::test(flavor = "multi_thread")] #[ignore = "host capstone: shells out to a real ruby/gem/bundler (>= 2.2); \ the unpinned `test` job skips it, an e2e job with a pinned toolchain runs it via --ignored"] async fn gem_hosted_unwind_keeps_a_source_block_bang() { - for command in ["rollback", "remove"] { + for (driver, command) in [ + (Driver::ScanVexSourceBlock, "rollback"), + (Driver::ScanVexSourceBlock, "remove"), + (Driver::ScanVexSourceBlockAssignment, "rollback"), + (Driver::ScanVexSourceBlockAssignment, "remove"), + ] { + let label = match driver { + Driver::ScanVexSourceBlockAssignment => "source-block-assignment", + _ => "source-block", + }; let Some(fx) = redirect_scanned_project( - &format!("source-block {command}"), + &format!("{label} {command}"), Spelling::Gemfile, false, true, None, - Driver::ScanVexSourceBlock, + driver, ) .await else { @@ -2416,7 +2442,7 @@ async fn gem_hosted_unwind_keeps_a_source_block_bang() { fx.pristine_gemfile, "{command}: the Gemfile comes back byte for byte" ); - let fresh = stage_fresh_checkout(&fx, &format!("source-block-{command}")); + let fresh = stage_fresh_checkout(&fx, &format!("{label}-{command}")); let install = bundle_env(&fresh, &["install"], &[("BUNDLE_FROZEN", "true")]); assert!( install.status.success(), diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs b/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs index c44c6f24b..67dfe7051 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs @@ -502,10 +502,13 @@ fn provably_appended(text: &str, block: &Block) -> bool { /// The blank line + indent to put before a declaration restored in place of /// `block`. The rewriter's `^\s*gem` match swallowed whatever whitespace /// preceded the original line, which no file records, so this re-derives the -/// conventional layout from the neighbors: inside a block (`group … do`) -/// or right after another declaration or a comment, the neighbor's indent -/// and no blank line; after anything else (`source`, `ruby`, `end`, …) one -/// blank line and the indent of the declaration that follows, if any. +/// conventional layout from the neighbors: right after the line that opens +/// the enclosing block (`group … do`), that line's indent + 2; right after +/// another declaration or a comment, the neighbor's indent and no blank +/// line; before another declaration, one blank line and that declaration's +/// indent; anywhere else inside a block (after an `end` or a statement, +/// #1476), the enclosing block's indent + 2 and no blank line; anywhere +/// else at the top level (after `source`, `ruby`, `end`, …) one blank line. fn declaration_prefix(text: &str, block: &Block, eol: &str) -> String { let Some(prev) = line_before(text, block.start) else { return String::new(); @@ -514,84 +517,462 @@ fn declaration_prefix(text: &str, block: &Block, eol: &str) -> String { if trimmed.is_empty() { return String::new(); } - let code = ruby_code(trimmed).trim_end(); - if code == "do" || code.ends_with(" do") || (code.ends_with('|') && code.contains(" do |")) { + let stack = block_stack(text, block.start); + let enclosing = stack.last().map(|f| indent_of(&text[f.line..])); + if stack + .last() + .is_some_and(|f| f.line == line_before_start(text, block.start)) + { return format!("{} ", indent_of(prev)); } if trimmed.starts_with('#') || gem_declaration_any(trimmed).is_some() { return indent_of(prev).to_string(); } let next = text[block.end..].split('\n').next().unwrap_or_default(); - let indent = if gem_declaration_any(next.trim()).is_some() { - indent_of(next) - } else { - "" - }; - format!("{eol}{indent}") + if gem_declaration_any(next.trim()).is_some() { + return format!("{eol}{}", indent_of(next)); + } + match enclosing { + Some(indent) => format!("{indent} "), + None => eol.to_string(), + } } /// Whether byte `at` of the manifest sits inside a `source "" do` /// block (at any depth: a `group … do` nested in it keeps the source). /// Bundler records a dependency declared there with a `!` source pin in the -/// lock's `DEPENDENCIES`, whichever URL the block names (#1056). A line-level -/// `do` / `end` count, like [`declaration_prefix`]'s: a block opened and -/// closed on one line nets out. Keyword constructs (`if`, `case`, `def`, …) -/// that start a line are counted too, so their `end` does not close an -/// enclosing `source … do` block. +/// lock's `DEPENDENCIES`, whichever URL the block names (#1056). fn in_source_block(text: &str, at: usize) -> bool { - let mut open: Vec = Vec::new(); - for line in text[..at].lines() { - let code = ruby_code(line).trim(); - let opens = code == "do" - || code.ends_with(" do") - || code.ends_with(")do") - || (code.ends_with('|') && code.contains(" do |")); - if opens { - let source = code + block_stack(text, at).iter().any(|f| f.source) +} + +/// A Ruby construct still open at some point of the manifest. +struct Frame { + /// A `source … do` block (anything else Ruby closes with `end`: + /// `group … do`, `if`, `case`, `def`, … is not). + source: bool, + /// Byte offset of the start of the line that opened it. + line: usize, +} + +/// The Ruby constructs still open at byte `at` (a line start) of the +/// manifest, outermost first. A word-level count over each line's code: +/// a construct opened and closed on one line nets out, and a keyword opens +/// one wherever it starts an expression (`v = if …`, `require: case …`, +/// #1476), not only at the start of a line, so its `end` never closes an +/// enclosing block. String, `%w[]`, regexp and backtick literals, +/// comments, `=begin` … `=end` blocks and heredoc bodies are not code. +fn block_stack(text: &str, at: usize) -> Vec { + let mut open: Vec = Vec::new(); + let mut block_comment = false; + // Pending heredoc terminators: (identifier, may be indented). + let mut heredocs: std::collections::VecDeque<(String, bool)> = Default::default(); + // A string or `%`-literal still open at the end of the last line. + let mut literal: Option = None; + // The code of the statement the last line left unfinished (a trailing + // `,`, `\`, `=`, …), and the start of the line it began on. + let mut carried = String::new(); + let mut statement_line = 0; + let mut start = 0; + for raw in text[..at].split_inclusive('\n') { + let line_start = start; + start += raw.len(); + let line = raw.trim_end_matches(['\n', '\r']); + // `=begin` / `=end` start at column 0 and are followed by + // whitespace or the end of the line (Ruby's own rule). + let marker = |word: &str| { + line.strip_prefix(word) + .is_some_and(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace)) + }; + if block_comment { + block_comment = !marker("=end"); + continue; + } + if let Some((id, indented)) = heredocs.front() { + let body = if *indented { line.trim_start() } else { line }; + if body == id { + heredocs.pop_front(); + } + continue; + } + if literal.is_none() && marker("=begin") { + block_comment = true; + continue; + } + let continues = literal.is_some() || !carried.is_empty(); + if !continues { + statement_line = line_start; + } + let masked = masked_ruby_code(line, &mut literal); + heredocs.extend(heredoc_openers(line, &masked)); + let masked = masked.trim(); + let mut source_do = !continues + && masked .strip_prefix("source") .is_some_and(|rest| rest.starts_with([' ', '\t', '('])); - open.push(source); - } else if opens_keyword_block(code) { - open.push(false); - } else if code == "end" || code.starts_with("end ") || code.starts_with("end.") { - open.pop(); + for (n, statement) in masked.split(';').enumerate() { + let lead = if n == 0 { carried.as_str() } else { "" }; + // A `while`/`until`/`for` takes its optional `do` as part of + // the loop, not as a block of its own. + let mut loop_do = false; + // The word after `def` is the method's name (`def begin`). + let mut method_name = false; + for (word, start, end) in ruby_words(statement) { + if std::mem::take(&mut method_name) { + continue; + } + let expression = || starts_expression(&format!("{lead} {}", &statement[..start])); + let opens = match word { + "do" => !std::mem::take(&mut loop_do), + "if" | "unless" => expression(), + "while" | "until" => { + let opens = expression(); + loop_do |= opens; + opens + } + "for" => { + loop_do = true; + true + } + "def" => { + method_name = true; + !endless_def(&statement[end..]) + } + "case" | "begin" | "class" | "module" => true, + "end" => { + open.pop(); + false + } + _ => false, + }; + if opens { + open.push(Frame { + source: word == "do" && std::mem::take(&mut source_do), + line: statement_line, + }); + } + } } + let unfinished = literal.is_some() + || masked.ends_with(['\\', ',', '(', '[', '+', '-', '*', '=']) + || masked.ends_with("||") + || masked.ends_with("&&"); + carried = if unfinished { + let tail = masked.rsplit(';').next().unwrap_or_default(); + format!("{carried} {}", tail.trim_end_matches('\\')) + .trim() + .to_string() + } else { + String::new() + }; } - open.contains(&true) + open +} + +/// The keyword-like words of a masked code line, with their byte range: a +/// symbol (`:if`), method call (`.class`) or variable sigil is a name, and +/// so are a predicate or bang (`begin?`) and a hash label (`if:`). +fn ruby_words(code: &str) -> impl Iterator { + let bytes = code.as_bytes(); + let mut i = 0; + std::iter::from_fn(move || { + while i < bytes.len() { + if !is_ident_byte(bytes[i]) { + i += 1; + continue; + } + let start = i; + while i < bytes.len() && is_ident_byte(bytes[i]) { + i += 1; + } + let prev_ok = start == 0 || !matches!(bytes[start - 1], b':' | b'.' | b'@' | b'$'); + let next_ok = match bytes.get(i) { + Some(b'?' | b'!') => false, + Some(b':') => bytes.get(i + 1) == Some(&b':'), + _ => true, + }; + if prev_ok && next_ok { + return Some((&code[start..i], start, i)); + } + } + None + }) } -/// `line` without its trailing `#` comment. A `#` inside a quoted string -/// (`"https://#{host}/"` interpolation, a URL fragment) is code, not a -/// comment. -fn ruby_code(line: &str) -> &str { - let mut quote: Option = None; - let mut escaped = false; - for (i, c) in line.char_indices() { - match quote { - Some(_) if escaped => escaped = false, - Some(_) if c == '\\' => escaped = true, - Some(q) if c == q => quote = None, - Some(_) => {} - None if c == '"' || c == '\'' => quote = Some(c), - None if c == '#' => return &line[..i], - None => {} +fn is_ident_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' || !b.is_ascii() +} + +/// Whether a keyword or literal after `before` (the masked code before it +/// in its statement) starts an expression, as opposed to modifying the +/// statement before it (`gem "x" if cond`, `x.save! if y`) or dividing. +fn starts_expression(before: &str) -> bool { + let before = before.trim_end(); + let Some(last) = before.bytes().next_back() else { + return true; + }; + match last { + b'=' | b'(' | b'[' | b'{' | b',' | b';' | b'|' | b'&' | b'>' | b'<' | b'+' | b'-' + | b'*' | b'~' => true, + // A unary `!`, not a bang method (`x.save! if y`). + b'!' => before.len() == 1 || !is_ident_byte(before.as_bytes()[before.len() - 2]), + // A label (`require: if …`) or a ternary's `:`, not `Foo::`. + b':' => !before.ends_with("::"), + // A ternary's `?`, not a predicate (`x.empty? if y`). + b'?' => before[..before.len() - 1].ends_with(char::is_whitespace), + b if is_ident_byte(b) => { + let word_start = before + .bytes() + .rposition(|b| !is_ident_byte(b)) + .map_or(0, |i| i + 1); + let called = word_start > 0 + && matches!(before.as_bytes()[word_start - 1], b'.' | b':' | b'@' | b'$'); + !called + && matches!( + &before[word_start..], + "then" + | "else" + | "elsif" + | "do" + | "begin" + | "ensure" + | "and" + | "or" + | "not" + | "when" + | "in" + | "if" + | "unless" + | "while" + | "until" + | "case" + ) } + _ => false, } - line } -/// Whether the (comment-stripped, trimmed) line opens a keyword construct -/// that Ruby closes with `end`: one that starts the line, not a modifier -/// (`gem "x" if cond`), and not closed on the same line. -fn opens_keyword_block(code: &str) -> bool { - let first = code - .split(|c: char| c.is_whitespace() || c == '(' || c == ';') - .next() - .unwrap_or_default(); - matches!( - first, - "if" | "unless" | "while" | "until" | "case" | "begin" | "def" | "class" | "module" - ) && !(code.ends_with(" end") || code.ends_with(";end")) +/// Whether `rest` (what follows `def`) is an endless method (`def x = 1`, +/// `def x(a = f()) = a`, `def ==(o) = …`), which Ruby closes without an +/// `end`. A setter (`def x=(v)`) is not. +fn endless_def(rest: &str) -> bool { + let rest = rest.trim_start(); + let ident = |c: char| c.is_alphanumeric() || c == '_'; + let name_len = if rest.starts_with(ident) { + // `x`, `self.x`, `x?`, `x!`. + let len = rest + .find(|c: char| !(ident(c) || c == '.')) + .unwrap_or(rest.len()); + len + usize::from(rest[len..].starts_with(['?', '!'])) + } else { + // An operator method: `==`, `<=>`, `[]=`, `+@`, …. + rest.find(|c: char| !"[]=!<>+-*/%~^&|@".contains(c)) + .unwrap_or(rest.len()) + }; + if name_len == 0 { + return false; + } + let mut tail = &rest[name_len..]; + if tail.starts_with('(') { + let mut depth = 0usize; + let Some(close) = tail.find(|c: char| { + match c { + '(' => depth += 1, + ')' => depth -= 1, + _ => {} + } + depth == 0 + }) else { + return false; + }; + tail = &tail[close + 1..]; + } else if !tail.starts_with([' ', '\t']) { + return false; + } + tail.trim_start() + .strip_prefix('=') + .is_some_and(|t| !t.starts_with(['=', '~', '>'])) +} + +/// A string or `%`-literal left open at the end of a line. +#[derive(Clone, Copy)] +struct Literal { + open: u8, + close: u8, + /// Nesting of `open` inside a bracketed literal (`%w[a [b] c]`). + depth: usize, +} + +/// `line` up to its `#` comment, with the contents of every string, +/// backtick, `%`-literal (`%w[…]`, `%i(…)`, `%q{…}`, …) and regexp literal +/// masked as `x`, byte for byte, so a keyword or `#` inside one never counts +/// as code. The delimiters stay. A string or `%`-literal still open at the +/// end of the line is carried in `literal` to mask the next line from its +/// start. +fn masked_ruby_code(line: &str, literal: &mut Option) -> String { + let bytes = line.as_bytes(); + let mut out = bytes.to_vec(); + let mut i = 0; + if let Some(lit) = literal.take() { + match mask_literal(bytes, &mut out, 0, lit) { + Ok(end) => i = end + 1, + Err(lit) => { + *literal = Some(lit); + i = bytes.len(); + } + } + } + while i < bytes.len() { + let b = bytes[i]; + let before = std::str::from_utf8(&out[..i]).unwrap_or_default(); + let (lit, body) = match b { + b'#' => { + out.truncate(i); + break; + } + b'"' | b'\'' | b'`' => ( + Literal { + open: b, + close: b, + depth: 0, + }, + i + 1, + ), + b'/' if starts_expression(before) => ( + Literal { + open: b, + close: b, + depth: 0, + }, + i + 1, + ), + b'%' if starts_expression(before) => { + let mut j = i + 1; + if bytes.get(j).is_some_and(|c| b"wWiIqQrsx".contains(c)) { + j += 1; + } + match bytes.get(j) { + Some(&open) if open.is_ascii_punctuation() => { + let close = match open { + b'(' => b')', + b'[' => b']', + b'{' => b'}', + b'<' => b'>', + _ => open, + }; + ( + Literal { + open, + close, + depth: 0, + }, + j + 1, + ) + } + _ => { + i += 1; + continue; + } + } + } + _ => { + i += 1; + continue; + } + }; + match mask_literal(bytes, &mut out, body, lit) { + Ok(end) => i = end + 1, + // An unclosed `/` was a division after all. + Err(_) if b == b'/' => { + out[body..].copy_from_slice(&bytes[body..]); + i += 1; + } + Err(lit) => { + *literal = Some(lit); + break; + } + } + } + // The delimiters are ASCII, so a masked run never cuts a multi-byte + // character: the result is still UTF-8. + String::from_utf8(out).unwrap_or_default() +} + +/// Masks `lit`'s body in `out` from byte `from` of `bytes`: `Ok` with the +/// closing delimiter's index, or `Err` with the literal still open at the +/// end of the line. +fn mask_literal( + bytes: &[u8], + out: &mut [u8], + from: usize, + mut lit: Literal, +) -> Result { + let mut j = from; + while j < bytes.len() { + let c = bytes[j]; + if c == b'\\' { + out[j] = b'x'; + if let Some(next) = out.get_mut(j + 1) { + *next = b'x'; + } + j += 2; + continue; + } + if c == lit.close && lit.depth == 0 { + return Ok(j); + } + if lit.open != lit.close && c == lit.open { + lit.depth += 1; + } else if lit.open != lit.close && c == lit.close { + lit.depth -= 1; + } + out[j] = b'x'; + j += 1; + } + Err(lit) +} + +/// The heredocs a line opens (`<<~EOS`, `<<-"EOS"`, `x = < Vec<(String, bool)> { + let bytes = masked.as_bytes(); + let mut out = Vec::new(); + let mut i = 0; + while let Some(at) = masked[i..].find("<<").map(|p| p + i) { + i = at + 2; + if at > 0 && (is_ident_byte(bytes[at - 1]) || matches!(bytes[at - 1], b')' | b']' | b'}')) { + continue; + } + let mut j = at + 2; + let indented = matches!(bytes.get(j), Some(b'~' | b'-')); + if indented { + j += 1; + } + let id = match bytes.get(j) { + Some(&q @ (b'"' | b'\'' | b'`')) => masked[j + 1..] + .find(q as char) + .map(|end| &line[j + 1..j + 1 + end]), + Some(&b) + if (b.is_ascii_alphabetic() || b == b'_') + && (indented || starts_expression(&masked[..at])) => + { + let len = masked[j..] + .bytes() + .position(|b| !is_ident_byte(b)) + .unwrap_or(masked.len() - j); + Some(&line[j..j + len]) + } + _ => None, + }; + if let Some(id) = id.filter(|id| !id.is_empty()) { + out.push((id.to_string(), indented)); + i = j + 1; + } + } + out } /// How the gem comes back into the manifest. @@ -1108,6 +1489,25 @@ mod tests { // only the block goes. let t = format!("gem \"puma\"\n{block}\n"); assert_eq!(run(&t, &Decl::Transitive), "gem \"puma\"\n"); + // After an inner `end` inside a block (#1476): the block's indent + + // 2, a blank line only when another declaration follows. + let t = format!("group :test do\n if x\n gem \"a\"\n end\n{block}\nend\n"); + assert_eq!( + run(&t, &direct), + "group :test do\n if x\n gem \"a\"\n end\n gem \"rails\", \"7.0.0\"\nend\n" + ); + let t = + format!("group :test do\n if x\n gem \"a\"\n end\n{block}\n gem \"b\"\nend\n"); + assert_eq!( + run(&t, &direct), + "group :test do\n if x\n gem \"a\"\n end\n\n gem \"rails\", \"7.0.0\"\n gem \"b\"\nend\n" + ); + // A multi-line opener: the indent of the statement's first line. + let t = format!("group :development,\n :test do\n if x\n end\n{block}\nend\n"); + assert_eq!( + run(&t, &direct), + "group :development,\n :test do\n if x\n end\n gem \"rails\", \"7.0.0\"\nend\n" + ); let t = format!("gem \"puma\"\n{block}\n"); assert_eq!( run(&t, &Decl::Direct(constraint_args("rails (>= 6, ~> 7.0)"))), @@ -1361,6 +1761,21 @@ mod tests { // Nested in a group inside the source block, parenthesized opener. "source \"https://rubygems.org\"\n\ngem \"puma\"\nsource(\"https://rubygems.org\") do\n \ group :web do\n gem \"rails\", \"7.0.0\"\n end\nend\n", + // Assignment-form keyword constructs before the declaration + // (#1476): their `end`s close them, not the source block, and + // the declaration comes back at the block's indent. + "source \"https://rubygems.org\"\n\ngem \"puma\"\nsource \"https://rubygems.org\" do\n \ + v = if ENV[\"ZZ\"]\n \"a\"\n else\n \"b\"\n end\n gem \"rails\", \"7.0.0\"\nend\n", + "source \"https://rubygems.org\"\n\ngem \"puma\"\nsource \"https://rubygems.org\" do\n \ + v = case ENV[\"ZZ\"]\n when \"x\" then \"a\"\n else \"b\"\n end\n \ + w = begin\n 1\n rescue\n 2\n end\n gem \"rails\", \"7.0.0\"\nend\n", + "source \"https://rubygems.org\"\n\ngem \"puma\"\nsource \"https://rubygems.org\" do\n \ + x ||= unless ENV[\"ZZ\"]\n 1\n end\n y = (while false\n end)\n \ + gem \"rails\", \"7.0.0\"\nend\n", + // An `end` indented deeper than the block's body: the + // declaration takes the block's body indent, not the `end`'s. + "source \"https://rubygems.org\"\n\ngem \"puma\"\nsource \"https://rubygems.org\" do\n \ + w = case ENV[\"ZZ\"]\n when \"x\" then \"a\"\n end\n gem \"rails\", \"7.0.0\"\nend\n", ] { let lock = format!( "GEM\n remote: https://rubygems.org/\n specs:\n puma (6.0.0)\n \ @@ -1442,6 +1857,138 @@ mod tests { assert!(!at( "source \"https://x\" do\n gem \"a\" if true\nend\ngem \"rails\"\n" )); + // An assignment-form construct (#1476) opens one too, whichever + // keyword, operator or expression position introduces it. + for opener in [ + "v = if ENV[\"A\"]", + "v = unless ENV[\"A\"]", + "v = case ENV[\"A\"]\n when \"x\" then 1", + "v = begin\n 1\n rescue\n 2", + "v ||= while false", + "v += until true", + "v = (if true", + "foo(1, if true", + "x = 1; if true", + "v = [case 1\n when 1 then 2", + "gem \"foo\", require: if ENV[\"A\"]", + "gem \"foo\", :require => case ENV[\"A\"]\n when \"x\" then 1", + "v = c ? case x\n when 1 then 2", + "v = a and if b", + "v = for x in [] do", + "if a then 1 else\n 2 end; v = (if true", + ] { + let t = format!( + "source \"https://x\" do\n {opener}\n 1\n end\n gem \"rails\"\nend\n" + ); + assert!(at(&t), "{t}"); + } + // Closed on its own line, it nets out. + assert!(at( + "source \"https://x\" do\n v = if a then 1 else 2 end\n gem \"rails\"\nend\n" + )); + assert!(at( + "source \"https://x\" do\n v = (case a when 1 then 2 end).to_s\n gem \"rails\"\nend\n" + )); + assert!(!at( + "v = if a then 1 else 2 end\nsource \"https://x\" do\n gem \"a\"\nend\ngem \"rails\"\n" + )); + // Modifiers after a value, names that only look like keywords + // (`x.class`, `:if`, `if:`, `begin?`, `"if"`), and an endless + // `def` open nothing, so the source block's `end` still closes it. + for line in [ + "v = 1 if true", + "v = x unless y", + "x.save! if y", + "x.empty? while y", + "return if y", + "v = x =~ /re/ if y", + "v = x.class", + "v = range.begin", + "v = [:if, :case]", + "foo(if: 1, case: 2)", + "v = x.begin?", + "v = \"if case begin\"", + "def x = 1", + "def x(a) = a", + "v = loop { break }", + "v = compute.then if ENV[\"X\"]", + "gem \"a\", require: %w[active_support/core_ext/class]", + "gem \"a\", platforms: %i[mri case]", + "gem \"a\" if RUBY_PLATFORM =~ /linux|case/", + "v = `echo if`", + "v = a / b if c", + "v = a % b if c", + ] { + let t = format!("source \"https://x\" do\n {line}\nend\ngem \"rails\"\n"); + assert!(!at(&t), "{t}"); + } + // A `while … do` loop takes one `end`, not two. + assert!(!at( + "source \"https://x\" do\n while false do\n end\nend\ngem \"rails\"\n" + )); + // A `;` ends the loop's statement: a later `do` opens a block. + assert!(at( + "source \"https://x\" do\n while q.any?; q.each do |x|\n end\n end\n gem \"rails\"\nend\n" + )); + // A method named like a keyword is a name (`def begin`). + assert!(!at( + "source \"https://x\" do\n def begin\n 1\n end\nend\ngem \"rails\"\n" + )); + // `=begin` … `=end` comments and heredoc bodies are not code. + assert!(!at( + "source \"https://x\" do\n=begin\nGems for the CI box\n=end\nend\ngem \"rails\"\n" + )); + assert!(at( + "source \"https://x\" do\n NOTE = <<~EOS\n the end\n end\n EOS\n gem \"rails\"\nend\n" + )); + assert!(at( + "source \"https://x\" do\n NOTE = <<-'EOS' + <