Skip to content

Find & Replace — Ruby source

Find and replace text with literal or regular-expression matching, global replace, case sensitivity, whole-word, and capture-group substitution. Live match counter.

This is the Ruby implementation — the same logic the interactive tool runs, in a shareable, citable form.

# frozen_string_literal: true

# Find & replace with literal or regex matching, $-substitution
# ($1 backrefs, $&, $$), case sensitivity, whole-word, and global modes.
#
# Language: Ruby (3.2+, standard library only)
# Source:   CosmoDev polyglot showcase port of the find-replace tool,
#           ported from src/lib/findReplace.ts (the canonical TypeScript
#           implementation).
# License:  display source — part of CosmoDev's polyglot tool pages.
#
# Mirrors the live lib: a literal find string is Regexp.escape'd and matched
# verbatim; an isRegex find is compiled as-is. \b wraps the pattern when
# whole_word is set, and Regexp::IGNORECASE covers the JS i flag. The JS m
# flag makes ^ and $ anchor at line breaks — Ruby's ^ and $ ALWAYS do, so no
# flag maps it (a documented approximation: this port is line-anchored where
# the lib would restrict ^/$ to the string bounds when multiline is off).
# RegexpError is caught and returned as the error string (the lib never
# raises), and an empty find is a no-op.
#
# Replacement $-substitution is implemented in expand_match (not gsub's \1)
# so it matches JavaScript's String.replace exactly for the realistic cases:
# $$ -> $, $& -> whole match, $1..$99 -> capture group (literal "$<digits>"
# when out of range). JS's $` and $' are unsupported.

module FindReplace
  Result = Struct.new(:result, :matches, :error, keyword_init: true)

  module_function

  # Escape metacharacters for literals, wrap \b..\b for whole-word.
  def build_pattern(find, is_regex:, whole_word:)
    pattern = is_regex ? find : Regexp.escape(find)
    whole_word ? "\\b#{pattern}\\b" : pattern
  end

  # Compile the pattern with the case flag. Invalid syntax returns the
  # engine's message (the lib's error path), never a raise.
  def build_regex(find, is_regex:, case_sensitive:, whole_word:)
    flags = 0
    flags |= Regexp::IGNORECASE unless case_sensitive
    Regexp.new(build_pattern(find, is_regex: is_regex, whole_word: whole_word), flags)
  rescue RegexpError => e
    e.message
  end

  # Apply JS String.replace $-substitution for one match.
  #   "$$" -> "$";  "$&" -> whole match;  "$1".."$99" -> capture group N
  #   (literal "$<digits>" when N is out of range, matching JS).
  # groups[0] is the whole match; m[n] is nil for a group that did not
  # participate, which becomes "" as in JS.
  def expand_match(template, match)
    num_groups = match.size - 1
    groups = (0..num_groups).map { |i| match[i] || '' }
    out = +''
    i = 0
    n = template.length
    while i < n
      c = template[i]
      if c != '$'
        out << c
        i += 1
        next
      end
      nxt = i + 1 < n ? template[i + 1] : nil
      case nxt
      when '$'
        out << '$'
        i += 2
      when '&'
        out << groups[0]
        i += 2
      when '0'..'9'
        d1 = nxt.ord - 48
        # Greedily try a second digit ($nn), matching JS.
        if i + 2 < n && ('0'..'9').cover?(template[i + 2])
          d2 = d1 * 10 + (template[i + 2].ord - 48)
          if d2.between?(1, num_groups)
            out << groups[d2]
            i += 3
            next
          end
        end
        if d1.between?(1, num_groups)
          out << groups[d1]
          i += 2
        else
          out << '$' << nxt
          i += 2
        end
      else
        out << '$'
        i += 1
      end
    end
    out
  end

  # Replace occurrences of +find+ with +replacement+. Never raises.
  def find_replace(input, find, replacement, is_regex: false, case_sensitive: true,
                   whole_word: false, global: true, multiline: false)
    return Result.new(result: input, matches: 0, error: nil) if find.empty?

    built = build_regex(find, is_regex: is_regex, case_sensitive: case_sensitive,
                             whole_word: whole_word)
    return Result.new(result: input, matches: 0, error: built) if built.is_a?(String)
    pattern = built

    out = +''
    matches = 0
    num_groups = 0
    pos = 0
    while (m = pattern.match(input, pos))
      num_groups = m.size - 1
      out << input[pos...m.begin(0)]
      out << expand_match(replacement, m)
      matches += 1
      pos = m.end(0)
      break unless global
      if pos == m.begin(0) # empty match: copy one char and step, like JS
        out << input[pos] if pos < input.length
        pos += 1
      end
    end
    out << input[pos..] if pos <= input.length

    matches = 1 + num_groups if matches != 0 && !global # JS String.match quirk
    Result.new(result: out, matches: matches, error: nil)
  end
end

if $PROGRAM_NAME == __FILE__
  r = FindReplace.find_replace('Hello World world', 'world', 'Universe',
                               case_sensitive: false)
  if r.error
    puts "error: #{r.error}"
  else
    puts "#{r.result}  (#{r.matches} matches)"
  end
end

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →