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 f7fda3257..ff0280739 100644
--- a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs
+++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs
@@ -446,6 +446,9 @@ enum Driver {
/// [`Driver::ScanVexSourceBlock`] as a `source(…) { … }` brace block
/// (#1509): bundler pins it with `!` too.
ScanVexSourceBlockBraces,
+ /// [`Driver::ScanVexSourceBlock`] whose `source … do` follows another
+ /// statement and a `;` on its line (#1531).
+ ScanVexSourceBlockAfterSemicolon,
/// A heredoc option continues beyond the declaration's physical line.
ScanVexHeredocDeclaration,
/// A double-quoted interpolation can itself contain a heredoc opener.
@@ -545,6 +548,9 @@ impl Driver {
Driver::ScanVexSourceBlockBraces => {
"scan --mode hosted (gem in a `source(…) { }` block)"
}
+ Driver::ScanVexSourceBlockAfterSemicolon => {
+ "scan --mode hosted (gem in a `x = 1; source … do` block)"
+ }
Driver::ScanVexSemicolonJoinedDeclaration => {
"scan --mode hosted (two `;`-joined gem declarations)"
}
@@ -860,6 +866,11 @@ async fn redirect_scanned_project(
gem \"{DEP}\", \"{DEP_VERSION}\"\n}}\n",
up = server.uri()
),
+ Driver::ScanVexSourceBlockAfterSemicolon => format!(
+ "source \"{up}/upstream\"\n\nzz = 1; source \"{up}/upstream\" do\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!(
@@ -1122,6 +1133,7 @@ async fn redirect_scanned_project(
| Driver::ScanVexSourceBlockAssignment
| Driver::ScanVexSourceBlockMultiLineOpener
| Driver::ScanVexSourceBlockBraces
+ | Driver::ScanVexSourceBlockAfterSemicolon
| Driver::ScanVexCustomGitSource
| Driver::ScanVexMultiLineDeclaration
| Driver::ScanVexConditionalDeclaration
@@ -1329,6 +1341,7 @@ async fn redirect_scanned_project(
| Driver::ScanVexSourceBlockAssignment
| Driver::ScanVexSourceBlockMultiLineOpener
| Driver::ScanVexSourceBlockBraces
+ | Driver::ScanVexSourceBlockAfterSemicolon
| Driver::ScanVexTrailingSemicolonDeclaration => {
assert_eq!(env["vex"]["statements"], 1, "vex block: {env}");
assert!(
@@ -2401,6 +2414,7 @@ async fn gem_hosted_group_block_pin_survives_a_refused_vendored_takeover() {
/// `w = case … end` constructs precede the gem inside the block.
/// #1509: and when the block's `source(` opener spans lines, or the block
/// is a `source(…) { … }` brace block.
+/// #1531: and when the `source … do` follows `x = 1;` on its line.
#[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"]
@@ -2414,11 +2428,14 @@ async fn gem_hosted_unwind_keeps_a_source_block_bang() {
(Driver::ScanVexSourceBlockMultiLineOpener, "remove"),
(Driver::ScanVexSourceBlockBraces, "rollback"),
(Driver::ScanVexSourceBlockBraces, "remove"),
+ (Driver::ScanVexSourceBlockAfterSemicolon, "rollback"),
+ (Driver::ScanVexSourceBlockAfterSemicolon, "remove"),
] {
let label = match driver {
Driver::ScanVexSourceBlockAssignment => "source-block-assignment",
Driver::ScanVexSourceBlockMultiLineOpener => "source-block-multi-line-opener",
Driver::ScanVexSourceBlockBraces => "source-block-braces",
+ Driver::ScanVexSourceBlockAfterSemicolon => "source-block-after-semicolon",
_ => "source-block",
};
let Some(fx) = redirect_scanned_project(
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 b5802825f..67d078cf2 100644
--- a/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs
+++ b/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs
@@ -569,7 +569,9 @@ struct Frame {
/// enclosing block. A `{` opens one that its `}` closes (a brace block or
/// a hash). A statement runs on while a `(` or `[` is open, so a `source(`
/// whose arguments span lines still owns the `do` or `{` that follows its
-/// `)` (#1509). String, `%w[]`, regexp and backtick literals, comments,
+/// `)` (#1509). A `source` call counts wherever it starts an expression,
+/// as a keyword does (`x = 1; source "…" do`, `… then source "…" do`,
+/// #1531). 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();
@@ -585,11 +587,13 @@ fn block_stack(text: &str, at: usize) -> Vec {
let mut depth = 0usize;
// Each open `{`: a hash (counted in `depth`) or a block (a frame).
let mut braces: Vec = Vec::new();
- // The statement began with `source` and its block has not opened yet.
- let mut source_pending = false;
- // The statement began with `source(`: (its argument list is open, it
- // has closed).
- let mut source_call = (false, false);
+ // A `source` call whose block has not opened yet: the number of brace
+ // blocks open around it (a `}` closing one of them ends the call, and
+ // only a `do` or `{` at that level is its block).
+ let mut source_pending: Option = None;
+ // The `source(` call's argument list: (it is open, it has closed or
+ // there is none).
+ let mut source_call = (false, true);
let mut statement_line = 0;
let mut start = 0;
for raw in text[..at].split_inclusive('\n') {
@@ -620,21 +624,25 @@ fn block_stack(text: &str, at: usize) -> Vec {
// An open bracket carries only a `source` statement on to its
// block opener: elsewhere a bracket the masker missed (one in an
// interpolation) must not swallow the rest of the file.
- let continues = literal.is_some() || !carried.is_empty() || (depth > 0 && source_pending);
+ let continues =
+ literal.is_some() || !carried.is_empty() || (depth > 0 && source_pending.is_some());
let masked = masked_ruby_code(line, &mut literal);
heredocs.extend(heredoc_openers(line, &masked));
let masked = masked.trim();
if !continues {
depth = 0;
statement_line = line_start;
- let rest = masked.strip_prefix("source").unwrap_or("-");
- source_pending = rest.starts_with([' ', '\t', '(']);
- source_call = (false, !rest.starts_with('('));
+ source_pending = None;
+ source_call = (false, true);
}
for (n, statement) in masked.split(';').enumerate() {
let lead = if n == 0 { carried.as_str() } else { "" };
- if n > 0 && depth == 0 {
- source_pending = false;
+ // A top-level `;` starts a new statement, unless it sits in a
+ // brace block of the `source` call's own arguments.
+ if n > 0 && depth == 0 && source_pending.is_none_or(|level| braces.len() <= level) {
+ statement_line = line_start;
+ source_pending = None;
+ source_call = (false, true);
}
// A `while`/`until`/`for` takes its optional `do` as part of
// the loop, not as a block of its own.
@@ -650,6 +658,15 @@ fn block_stack(text: &str, at: usize) -> Vec {
}
let expression = || starts_expression(&format!("{lead} {}", &statement[..start]));
let (opens, source) = match word {
+ // One in a brace block of an outer call's arguments
+ // does not replace that call.
+ "source" if depth == 0 && source_pending.is_none() && expression() => {
+ if let Some(parenthesized) = source_call_args(&statement[end..]) {
+ source_pending = Some(braces.len());
+ source_call = (false, !parenthesized);
+ }
+ (false, false)
+ }
"(" | "[" => {
if depth == 0 && !source_call.1 {
source_call.0 = true;
@@ -677,7 +694,8 @@ fn block_stack(text: &str, at: usize) -> Vec {
braces.push(false);
(
true,
- closed_source_call && std::mem::take(&mut source_pending),
+ closed_source_call
+ && take_source(&mut source_pending, braces.len() - 1),
)
}
"}" => {
@@ -685,6 +703,10 @@ fn block_stack(text: &str, at: usize) -> Vec {
Some(true) => depth = depth.saturating_sub(1),
Some(false) => {
open.pop();
+ // A `source` call inside the block ends with it.
+ if source_pending.is_some_and(|level| braces.len() < level) {
+ source_pending = None;
+ }
}
None => {}
}
@@ -694,7 +716,7 @@ fn block_stack(text: &str, at: usize) -> Vec {
let opens = !std::mem::take(&mut loop_do);
(
opens,
- opens && depth == 0 && std::mem::take(&mut source_pending),
+ opens && depth == 0 && take_source(&mut source_pending, braces.len()),
)
}
"if" | "unless" => (expression(), false),
@@ -743,6 +765,23 @@ fn block_stack(text: &str, at: usize) -> Vec {
open
}
+/// Whether the pending `source` call (see [`block_stack`]) sits at brace
+/// level `level`, in which case the block opening there is its block.
+fn take_source(pending: &mut Option, level: usize) -> bool {
+ pending.take_if(|l| *l == level).is_some()
+}
+
+/// Whether `rest`, what follows the word `source` that starts an
+/// expression, makes it a call with arguments, and whether those are in
+/// parentheses (`source(…)`). `source = …` assigns a local variable.
+fn source_call_args(rest: &str) -> Option {
+ if rest.starts_with('(') {
+ return Some(true);
+ }
+ let args = rest.strip_prefix([' ', '\t'])?.trim_start();
+ (!args.is_empty() && !args.starts_with('=')).then_some(false)
+}
+
/// [`ruby_words`] and the brackets (`(`, `)`, `[`, `]`, `{`, `}`) of a
/// masked code line, in order, with their byte range.
fn ruby_tokens(code: &str) -> impl Iterator- {
@@ -1890,6 +1929,12 @@ mod tests {
// comes back between the opener and the declaration.
"source \"https://rubygems.org\"\n\ngem \"puma\"\nsource(\n \"https://rubygems.org\"\n) do\n \
gem \"rails\", \"7.0.0\"\n gem \"rack\"\nend\n",
+ // #1531: an opener after a `;`, also on a statement's
+ // continuation line, whose own indent the declaration follows.
+ "source \"https://rubygems.org\"\n\ngem \"puma\"\nzz = 1; source \"https://rubygems.org\" do\n \
+ gem \"rails\", \"7.0.0\"\nend\n",
+ "source \"https://rubygems.org\"\n\ngem \"puma\"\n zz = [1,\n2]; source \"https://rubygems.org\" do\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 \
@@ -1943,6 +1988,64 @@ mod tests {
}
}
+ #[test]
+ fn source_block_opener_mid_line() {
+ // #1531: a `source` call after a `;` on its line, or anywhere else
+ // it starts an expression, opens a source block just as a
+ // line-leading one does.
+ let at = |text: &str| in_source_block(text, text.find("gem \"rails\"").unwrap());
+ for opener in [
+ "zz = 1; source \"https://x\" do",
+ "zz = 1;source \"https://x\" do",
+ "a = 1; b = 2; source \"https://x\" do",
+ "zz = 1; source(\"https://x\") do",
+ "zz = 1; source(\"https://x\") {",
+ "zz = 1; source(\n \"https://x\"\n) do",
+ "zz = 1; source \"https://x\",\n type: :rubygems do",
+ "git_source(:gh) { |r| \"https://github.com/#{r}.git\" }; source \"https://x\" do",
+ // Anywhere a `source` call starts an expression.
+ "x = source \"https://x\" do",
+ "if true then source \"https://x\" do",
+ "a = [1,\n 2]; source \"https://x\" do",
+ "source ENV.fetch(\"S\") { warn \"x\"; \"https://x\" } do",
+ // A `source` call or a `do` block inside the arguments' own
+ // brace block leaves the outer call's `do` its block.
+ "source ENV.fetch(\"S\") { source \"https://y\" } do",
+ "source ENV.fetch(\"S\") { [1].each do |v| v end; \"https://x\" } do",
+ ] {
+ let close = if opener.ends_with('{') { "}" } else { "end" };
+ let t =
+ format!("source \"https://rubygems.org\"\n\n{opener}\n gem \"rails\"\n{close}\n");
+ assert!(at(&t), "{t}");
+ let t = format!(
+ "source \"https://rubygems.org\"\n\n{opener}\n gem \"a\"\n{close}\ngem \"rails\"\n"
+ );
+ assert!(!at(&t), "{t}");
+ }
+ // Not a `source` call that starts an expression.
+ for line in [
+ "zz = 1; group :t do",
+ "zz = 1; x.source \"https://x\" do",
+ "zz = 1; sources \"https://x\" do",
+ "foo(1; source \"https://x\") do",
+ "zz = 1; source = mirrors.first do",
+ "source = mirrors.first do",
+ "source == x do",
+ // A `source` call inside a closed brace block does not own the
+ // `do` after it.
+ "urls.each { |u| log(u); source u }.then do",
+ "urls.each { |u| source u }.then do",
+ ] {
+ let t = format!("{line}\n gem \"rails\"\nend\n");
+ assert!(!at(&t), "{t}");
+ }
+ // A `;` statement that is not a source call clears a line-leading
+ // `source` without a block.
+ assert!(!at(
+ "source \"https://x\"; group :t do\n gem \"rails\"\nend\n"
+ ));
+ }
+
#[test]
fn source_block_detection() {
let at = |text: &str| in_source_block(text, text.find("gem \"rails\"").unwrap());