diff --git a/crates/socket-patch-core/src/formats/gem/gemspec.rs b/crates/socket-patch-core/src/formats/gem/gemspec.rs index 0197dc26a..cb4df104e 100644 --- a/crates/socket-patch-core/src/formats/gem/gemspec.rs +++ b/crates/socket-patch-core/src/formats/gem/gemspec.rs @@ -19,6 +19,7 @@ //! caller cannot know what bundler would lock. use super::gemfile::{code_of, leading_quoted, split_args}; +use super::manifest::code_lines; /// The `gemspec` files of one directory: `(file name, text)`, the text /// `None` when the file exists but could not be read. `Err` when the @@ -95,9 +96,8 @@ pub(crate) struct GemspecDirective { /// `path:` / `name:`, a `glob:`, an absolute or escaping path). pub(crate) fn gemspec_directives(gemfile: &str) -> Result, String> { let mut out = Vec::new(); - for (line, text) in gemfile.split('\n').enumerate() { - let trimmed = text.trim_start(); - let Some(rest) = trimmed.strip_prefix("gemspec") else { + for (line, text, code) in code_lines(gemfile) { + let Some(rest) = code.trim_start().strip_prefix("gemspec") else { continue; }; // `gemspec`, `gemspec path: "x"`, `gemspec(path: "x")`; never @@ -234,15 +234,11 @@ pub(crate) fn dev_dependency_requirements( gem: &str, ) -> Result>, String> { let mut found: Option> = None; - for (i, text) in gemspec.split('\n').enumerate() { - let trimmed = text.trim(); - if trimmed.starts_with('#') { - continue; - } - let Some(at) = trimmed.find("add_development_dependency") else { + for (i, text, code) in code_lines(gemspec) { + let Some(at) = code.find("add_development_dependency") else { continue; }; - let after = &trimmed[at + "add_development_dependency".len()..]; + let after = &code[at + "add_development_dependency".len()..]; // Every literal spelling [`literal`] reads: quoted, or `%q` with any // of its bracket pairs. let mentions = [ @@ -255,7 +251,7 @@ pub(crate) fn dev_dependency_requirements( ] .iter() .any(|(open, close)| after.contains(&format!("{open}{gem}{close}"))); - let unreadable = || format!("line {}: `{trimmed}`", i + 1); + let unreadable = || format!("line {}: `{}`", i + 1, text.trim()); let Some(reqs) = read_dev_call(after) else { if mentions { return Err(unreadable()); @@ -331,10 +327,8 @@ pub(crate) fn locked_exact_requirement( if !bundler_merges_dev_dependencies(lock) { return Ok(exact); } - let Some(decl) = gemfile.split('\n').position(|line| { - let trimmed = line.trim_start(); - !trimmed.starts_with('#') - && crate::vendor::gem::gem_declaration_any(trimmed).is_some_and(|d| d.name == name) + let Some((decl, _, _)) = code_lines(gemfile).find(|(_, _, code)| { + crate::vendor::gem::gem_declaration_any(code.trim_start()).is_some_and(|d| d.name == name) }) else { return Ok(exact); }; @@ -553,6 +547,31 @@ mod tests { ); } + #[test] + fn begin_blocks_heredocs_and_end_data_are_not_code() { + let gemfile = + "=begin\ngemspec path: \"old\"\n=end\nX = <<~RUBY\n gemspec path: ROOT\nRUBY\n\ + gemspec path: \"core\"\n__END__\ngemspec glob: \"*\"\n"; + assert_eq!( + gemspec_directives(gemfile), + Ok(vec![GemspecDirective { + line: 6, + dir: "core".into(), + name: None + }]) + ); + let spec = "=begin\n s.add_development_dependency \"vuln-gem\", \"~> 2\"\n=end\n\ + DOC = <<-EOS\n s.add_development_dependency \"vuln-gem\", VERSION\n EOS\n"; + assert_eq!(dev_dependency_requirements(spec, "vuln-gem"), Ok(None)); + // A `gem` line inside a `=begin` block is not the declaration the + // directive must precede. + let gemfile = + "=begin\ngem \"vuln-gem\", \"0.9\"\n=end\ngemspec\ngem \"vuln-gem\", \"1.0.0\"\n"; + let got = + locked_exact_requirement(gemfile, LOCK_27, "vuln-gem", "1.0.0", &mut one(GEMSPEC)); + assert_eq!(got.as_deref(), Ok(">= 0, = 1.0.0")); + } + #[test] fn an_unreadable_dev_dependency_or_gemspec_is_an_error() { let gemfile = "gemspec\ngem \"vuln-gem\", \"1.0.0\"\n"; diff --git a/crates/socket-patch-core/src/formats/gem/manifest.rs b/crates/socket-patch-core/src/formats/gem/manifest.rs index 2e212e979..5bc724a74 100644 --- a/crates/socket-patch-core/src/formats/gem/manifest.rs +++ b/crates/socket-patch-core/src/formats/gem/manifest.rs @@ -396,7 +396,7 @@ impl GemfileLockfile { /// ([`eval_gemfile_paths`]). pub fn gemfile_lockfile(contents: &str) -> Option { let mut found: Option = None; - for_each_code_line(contents, |line, code| { + for (_, line, code) in code_lines(contents) { let lead = code.len() - code.trim_start().len(); for at in ident_positions(code, "lockfile") { let this = if at == lead { @@ -412,7 +412,7 @@ pub fn gemfile_lockfile(contents: &str) -> Option { Some(prev) => prev.merge(this), }); } - }); + } found } @@ -421,7 +421,7 @@ pub fn gemfile_lockfile(contents: &str) -> Option { /// A computed path is left out. pub fn eval_gemfile_paths(contents: &str) -> Vec { let mut out = Vec::new(); - for_each_code_line(contents, |_, code| { + for (_, _, code) in code_lines(contents) { for at in ident_positions(code, "eval_gemfile") { let arg = code[at + "eval_gemfile".len()..].trim_start(); let arg = arg.strip_prefix('(').map(str::trim_start).unwrap_or(arg); @@ -429,7 +429,7 @@ pub fn eval_gemfile_paths(contents: &str) -> Vec { out.push(value.to_string()); } } - }); + } out } @@ -465,16 +465,19 @@ fn plain_literal(s: &str) -> Option<(&str, &str)> { (!value.contains('\\') && !interpolated).then_some((value, rest)) } -/// Run `f(line, code)` over each line of Ruby source that holds code: -/// `code` is the line before any `#` comment. Skips `=begin`/`=end` -/// blocks and heredoc bodies, and stops at `__END__`. A leading BOM is -/// dropped, as Ruby does. -pub(crate) fn for_each_code_line<'a>(contents: &'a str, mut f: impl FnMut(&'a str, &'a str)) { +/// Each line of Ruby source that holds code: `(0-based line index, line, +/// code)`, `code` being the line before any `#` comment. Skips +/// `=begin`/`=end` blocks and heredoc bodies, and stops at `__END__`. A +/// leading BOM is dropped, as Ruby does. +pub(crate) fn code_lines(contents: &str) -> impl Iterator { use crate::formats::gem::gemfile::code_of; - let contents = crate::formats::text::strip_bom(contents); + let mut lines = crate::formats::text::strip_bom(contents) + .lines() + .enumerate(); let mut in_comment_block = false; let mut heredocs: Vec = Vec::new(); - for line in contents.lines() { + std::iter::from_fn(move || loop { + let (i, line) = lines.next()?; if let Some(id) = heredocs.first() { if line.trim() == id { heredocs.remove(0); @@ -490,12 +493,13 @@ pub(crate) fn for_each_code_line<'a>(contents: &'a str, mut f: impl FnMut(&'a st continue; } if line.trim_end() == "__END__" { - break; + return None; } let code = code_of(line).unwrap_or(line); heredocs.extend(heredoc_ids(code)); - f(line, code); - } + return Some((i, line, code)); + }) + .fuse() } /// The byte offsets in `code` where `name` appears as a bare identifier diff --git a/crates/socket-patch-core/src/vex/product.rs b/crates/socket-patch-core/src/vex/product.rs index 15a7fe290..6bd095311 100644 --- a/crates/socket-patch-core/src/vex/product.rs +++ b/crates/socket-patch-core/src/vex/product.rs @@ -378,8 +378,9 @@ fn xml_element_texts(xml: &str, paths: [&[&str]; N]) -> [Option< /// heredoc bodies and anything after `__END__` are not code, and the last /// assignment wins (#1573). fn parse_gemspec(content: &str, stem: &str) -> Option { - let mut lines: Vec<&str> = Vec::new(); - crate::formats::gem::manifest::for_each_code_line(content, |_, c| lines.push(c)); + let lines: Vec<&str> = crate::formats::gem::manifest::code_lines(content) + .map(|(_, _, code)| code) + .collect(); // The `spec` of `Gem::Specification.new do |spec|`, when the file names // it, so a helper's `ext.name = …` elsewhere can't pass for the spec's. let receiver = lines