DEV Community

Mack
Mack

Posted on

Ruby's $1 and $2 Are Landmines (And How They Ate My Blog)

I lost an hour to a bug that produced zero errors, zero crashes, and zero useful feedback. Every blog post on my site rendered with a title and... nothing else. Empty content. Here's what happened.

The Setup

I built a static site generator in Ruby. ~100 lines. Markdown files with YAML frontmatter go in, HTML pages come out. Standard stuff.

The build script ran fine. 3 posts built. No errors. But every post was empty.

The Bug

def parse_frontmatter(content)
  if content =~ /\A---\n(.*?)---\n(.*)\z/m
    meta = {}
    $1.each_line do |line|
      key, val = line.split(':', 2).map(&:strip)
      val = val.gsub(/^["']|["']$/, '')  # strip quotes
      meta[key] = val
    end
    [meta, $2]
  end
end
Enter fullscreen mode Exit fullscreen mode

See it? Line 5 calls val.gsub with a regex. In Ruby, every regex operation resets the global match variables $1, $2, etc.

By the time we return [meta, $2], $2 isn't the blog post body anymore. It's whatever the last gsub captured — which is nil, because that regex has no capture groups.

The body is gone. The build succeeds. The site is empty.

The Fix

One line:

def parse_frontmatter(content)
  if content =~ /\A---\n(.*?)---\n(.*)\z/m
    front = $1  # capture immediately!
    body = $2   # before anything else touches match vars
    meta = {}
    front.each_line do |line|
      key, val = line.split(':', 2).map(&:strip)
      val = val.gsub(/^["']|["']$/, '')
      meta[key] = val
    end
    [meta, body]
  end
end
Enter fullscreen mode Exit fullscreen mode

Capture $1 and $2 into local variables immediately after the match. Before any other code runs.

Why This Is Dangerous

$1, $2, etc. are thread-local global variables that get silently overwritten by:

  • String#gsub
  • String#sub
  • String#match
  • String#=~
  • String#scan
  • Any method that internally uses regex

They survive across lines but not across regex operations. And nothing warns you when they get clobbered.

Safer Alternatives

Named captures:

if content =~ /\A---\n(?<front>.*?)---\n(?<body>.*)\z/m
  front = $~[:front]
  body = $~[:body]
end
Enter fullscreen mode Exit fullscreen mode

Regexp.last_match:

if content =~ /\A---\n(.*?)---\n(.*)\z/m
  m = Regexp.last_match
  front = m[1]
  body = m[2]
end
Enter fullscreen mode Exit fullscreen mode

String#match with block:

content.match(/\A---\n(.*?)---\n(.*)\z/m) do |m|
  front = m[1]
  body = m[2]
end
Enter fullscreen mode Exit fullscreen mode

The Lesson

The most dangerous bugs are the ones that don't crash. This one:

  • Produced valid output (HTML files with titles)
  • Reported success (3 posts built)
  • Had no error messages
  • Only manifested as missing content

If you use Ruby's $1/$2 globals, capture them on the very next line. Or better yet, use named captures or match with a block.


This was a real bug I hit while building a blog with a 100-line Ruby static site generator. Three deploys, one hour, zero error messages.

Top comments (0)