Skip to content

Instantly share code, notes, and snippets.

@Thomascountz
Created October 18, 2025 17:15
Show Gist options
  • Save Thomascountz/12acd66ae8e5f0bd4e269032ecefa583 to your computer and use it in GitHub Desktop.
Save Thomascountz/12acd66ae8e5f0bd4e269032ecefa583 to your computer and use it in GitHub Desktop.
Regex-based Gemfile.lock parser
# frozen_string_literal: true
class LockfileParser
# Data structures
Lockfile = Data.define(:sources, :platforms, :dependencies, :ruby_version, :bundled_with)
Source = Data.define(:type, :remote, :revision, :specs, :options)
Spec = Data.define(:name, :version, :dependencies)
Dependency = Data.define(:name, :version_requirements)
Platform = Data.define(:name)
RubyVersion = Data.define(:version, :engine, :patchlevel)
# Section markers
BUNDLED_WITH = /^BUNDLED WITH$/
CHECKSUMS = /^CHECKSUMS$/
DEPENDENCIES = /^DEPENDENCIES$/
GEM = /^GEM$/
GIT = /^GIT$/
PATH = /^PATH$/
PLATFORMS = /^PLATFORMS$/
PLUGIN = /^PLUGIN SOURCE$/
RUBY = /^RUBY VERSION$/
# Entry patterns
REMOTE = /^ remote: (.+)$/
REVISION = /^ revision: (.+)$/
SPECS = /^ specs:$/
OPTION = /^ ([a-z]+): (.+)$/i
SPEC_ENTRY = /^ ([^ (]+)(?: \(([^)]+)\))?$/
DEPENDENCY_ENTRY = /^ ([^ (]+)(?: \(([^)]+)\))?$/
TOP_LEVEL_DEPENDENCY = /^ ([^ (]+)(?: \(([^)]+)\))?(!)?$/
PLATFORM_ENTRY = /^ (.+)$/
VERSION_LINE = /^ (.+)$/
BUNDLED_VERSION = /^ (.+)$/
class << self
def parse(lockfile_content)
# content = File.read(lockfile_path)
lines = lockfile_content.lines.map(&:chomp)
sources = []
platforms = []
dependencies = []
ruby_version = nil
bundled_with = nil
i = 0
while i < lines.length
line = lines[i]
case line
when GIT, GEM, PATH, PLUGIN
source, next_i = parse_source(lines, i)
sources << source
i = next_i
when PLATFORMS
platforms, i = parse_platforms(lines, i + 1)
when DEPENDENCIES
dependencies, i = parse_dependencies(lines, i + 1)
when RUBY
ruby_version, i = parse_ruby_version(lines, i + 1)
when BUNDLED_WITH
bundled_with, i = parse_bundled_with(lines, i + 1)
when CHECKSUMS
i = skip_section(lines, i + 1)
else
i += 1
end
end
Lockfile.new(
sources: sources,
platforms: platforms,
dependencies: dependencies,
ruby_version: ruby_version,
bundled_with: bundled_with
)
end
private
def parse_source(lines, start_idx)
type = case lines[start_idx]
when GIT then :git
when GEM then :gem
when PATH then :path
when PLUGIN then :plugin
end
remote = nil
revision = nil
specs = []
options = {}
i = start_idx + 1
while i < lines.length && !section_header?(lines[i])
line = lines[i]
case line
when REMOTE
remote = line.match(REMOTE)[1]
when REVISION
revision = line.match(REVISION)[1]
when SPECS
specs, i = parse_specs(lines, i + 1)
next
when OPTION
match = line.match(OPTION)
options[match[1]] = match[2]
end
i += 1
end
source = Source.new(
type: type,
remote: remote,
revision: revision,
specs: specs,
options: options
)
[source, i]
end
def parse_specs(lines, start_idx)
specs = []
i = start_idx
while i < lines.length && lines[i].match?(SPEC_ENTRY)
line = lines[i]
match = line.match(SPEC_ENTRY)
name = match[1]
version = match[2]
dependencies = []
i += 1
while i < lines.length && lines[i].match?(DEPENDENCY_ENTRY)
dep_match = lines[i].match(DEPENDENCY_ENTRY)
dependencies << Dependency.new(
name: dep_match[1],
version_requirements: dep_match[2]
)
i += 1
end
specs << Spec.new(
name: name,
version: version,
dependencies: dependencies
)
end
[specs, i]
end
def parse_platforms(lines, start_idx)
platforms = []
i = start_idx
while i < lines.length && lines[i].match?(PLATFORM_ENTRY) && !section_header?(lines[i])
match = lines[i].match(PLATFORM_ENTRY)
platforms << Platform.new(name: match[1])
i += 1
end
[platforms, i]
end
def parse_dependencies(lines, start_idx)
dependencies = []
i = start_idx
while i < lines.length && lines[i].match?(TOP_LEVEL_DEPENDENCY)
match = lines[i].match(TOP_LEVEL_DEPENDENCY)
dependencies << Dependency.new(
name: match[1],
version_requirements: match[2]
)
i += 1
end
[dependencies, i]
end
def parse_ruby_version(lines, start_idx)
return [nil, start_idx] if start_idx >= lines.length
line = lines[start_idx]
return [nil, start_idx] unless line.match?(VERSION_LINE)
version_string = line.match(VERSION_LINE)[1]
parts = version_string.split # ["ruby", "2.7.2p137", "(truffleruby 25.0.0)"]
version, patchlevel = parts[1].split("p")
engine = parts[2] if parts.length > 2
ruby_version = RubyVersion.new(
version: version,
engine: engine,
patchlevel: patchlevel
)
[ruby_version, start_idx + 1]
end
def parse_bundled_with(lines, start_idx)
return [nil, start_idx] if start_idx >= lines.length
line = lines[start_idx]
return [nil, start_idx] unless line.match?(BUNDLED_VERSION)
version = line.match(BUNDLED_VERSION)[1]
[version, start_idx + 1]
end
def skip_section(lines, start_idx)
i = start_idx
i += 1 while i < lines.length && !section_header?(lines[i])
i
end
def section_header?(line)
line.match?(GEM) || line.match?(GIT) || line.match?(PATH) ||
line.match?(PLUGIN) || line.match?(PLATFORMS) ||
line.match?(DEPENDENCIES) || line.match?(RUBY) ||
line.match?(BUNDLED_WITH) || line.match?(CHECKSUMS)
end
end
end
if ARGV.length != 1
puts "Usage: ruby lockfile_parser.rb <path_to_Gemfile.lock>"
exit 1
end
lockfile_content = File.read(ARGV[0])
result = LockfileParser.parse(lockfile_content)
puts "Sources: #{result.sources.map(&:remote).inspect}"
puts "Platforms: #{result.platforms.map(&:name).inspect}"
puts "Dependencies: #{result.dependencies.count}"
puts "Specs: #{result.sources.sum { |s| s.specs.count }}"
puts "Ruby Version: #{result.ruby_version.version}"
puts "Bundled With: #{result.bundled_with}"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment