Skip to content

Commit 22266dd

Browse files
committed
Add DotRubyVersionFile parser for .ruby-version files
Parses .ruby-version file contents and produces a LanguagePack::RubyVersion instance. Uses service object pattern: initialize stores contents, call returns a Result struct with ruby_version and a warnings array. Supported formats: - Plain version: 3.4.8 - Ruby prefix: ruby-3.4.8 - With @org suffix: ruby-3.4.8@company-name - Pre-release (dot or dash): 3.4.0.rc1, 3.4.0-preview2 - Comments (#) and blank lines are ignored Invalid inputs produce warnings (not errors): - JRuby prefix - Version specifiers (>=, ~>, etc.) - Multiple version lines - Unparseable content
1 parent be53007 commit 22266dd

3 files changed

Lines changed: 265 additions & 0 deletions

File tree

lib/language_pack.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ def self.detect(arch:, app_path:, cache_path:, environment_name:, gemfile_lock:,
141141
require "language_pack/helpers/default_env_vars"
142142
require "language_pack/helpers/outdated_ruby_version"
143143
require "language_pack/helpers/download_presence"
144+
require "language_pack/helpers/dot_ruby_version_file"
144145
require "language_pack/installers/heroku_ruby_installer"
145146

146147
require "language_pack/ruby"
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
require "language_pack/shell_helpers"
2+
require "language_pack/ruby_version"
3+
4+
module LanguagePack
5+
module Helpers
6+
# Parses contents of `.ruby-version` file to transform it into a RubyVersion
7+
#
8+
# Example:
9+
#
10+
# version = DotRubyVersionFile.new(
11+
# contents: File.read(".ruby-version")
12+
# ).call
13+
# assert_eq [], version.warnings
14+
# assert_eq :ruby, version.ruby_version.engine
15+
class DotRubyVersionFile
16+
VERSION_PATTERN = /\A(?<version>\d+\.\d+\.\d+)(?:[.-](?<pre>\S+))?\z/
17+
JRUBY_PATTERN = /\Ajruby-/i
18+
SPECIFIER_PATTERN = />=|<=|~>|>|</
19+
20+
Result = Data.define(:ruby_version, :warnings)
21+
22+
def initialize(contents:)
23+
@contents = contents
24+
end
25+
26+
def call
27+
warnings = []
28+
lines = meaningful_lines
29+
30+
if lines.empty?
31+
return Result.new(ruby_version: nil, warnings: warnings)
32+
end
33+
34+
line = lines.first
35+
version_string = strip_ruby_prefix_and_at_suffix(line)
36+
37+
if lines.length > 1
38+
warnings << <<~EOF
39+
The `.ruby-version` file contains multiple version lines.
40+
Only a single version is supported. Remove additional lines.
41+
42+
Contents:
43+
44+
```
45+
#{@contents}
46+
```
47+
EOF
48+
end
49+
50+
if version_string.match?(JRUBY_PATTERN)
51+
warnings << <<~EOF
52+
JRuby not supported in `.ruby-version` file.
53+
54+
The `.ruby-version` file contains a JRuby version however the
55+
JRuby engine is not supported by `.ruby-version` on Heroku at this time.
56+
57+
Contents:
58+
59+
```
60+
#{@contents}
61+
```
62+
EOF
63+
end
64+
65+
if version_string.match?(SPECIFIER_PATTERN)
66+
warnings << <<~EOF
67+
Cannot parse version specifiers in `.ruby-version` file.
68+
69+
Only exact versions are supported such as `3.4.8` or `ruby-3.4.8`.
70+
Got:
71+
72+
```
73+
#{@contents}
74+
```
75+
EOF
76+
end
77+
78+
if warnings.any?
79+
Result.new(ruby_version: nil, warnings: warnings)
80+
elsif (match = version_string.match(VERSION_PATTERN))
81+
Result.new(
82+
ruby_version: LanguagePack::RubyVersion.new(
83+
pre: match[:pre],
84+
engine: :ruby,
85+
default: false,
86+
ruby_version: match[:version],
87+
engine_version: match[:version]
88+
),
89+
warnings: warnings
90+
)
91+
else
92+
warnings << <<~EOF
93+
Cannot parse Ruby version from `.ruby-version` file.
94+
95+
Expected format: `3.4.8` or `ruby-3.4.8`. Got:
96+
97+
```
98+
#{@contents}
99+
```
100+
EOF
101+
Result.new(ruby_version: nil, warnings: warnings)
102+
end
103+
end
104+
105+
private def meaningful_lines
106+
@contents.gsub(/\s*#.*/, "").each_line.filter_map { |line|
107+
stripped = line.strip
108+
stripped unless stripped.empty?
109+
}
110+
end
111+
112+
private def strip_ruby_prefix_and_at_suffix(line)
113+
version = line.delete_prefix("ruby-")
114+
version.split("@").first
115+
end
116+
end
117+
end
118+
end
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
require "spec_helper"
2+
3+
describe "DotRubyVersionFile" do
4+
it "parses a plain version" do
5+
result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "3.4.8").call
6+
expect(result.ruby_version).to be_a(LanguagePack::RubyVersion)
7+
expect(result.ruby_version.ruby_version).to eq("3.4.8")
8+
expect(result.ruby_version.engine).to eq(:ruby)
9+
expect(result.ruby_version.engine_version).to eq("3.4.8")
10+
expect(result.ruby_version.default?).to eq(false)
11+
expect(result.warnings).to eq([])
12+
end
13+
14+
it "strips ruby- prefix" do
15+
result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "ruby-3.4.8").call
16+
expect(result.ruby_version.ruby_version).to eq("3.4.8")
17+
expect(result.ruby_version.engine).to eq(:ruby)
18+
expect(result.warnings).to eq([])
19+
end
20+
21+
it "trims trailing comment" do
22+
result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "ruby-3.4.8 # our version").call
23+
expect(result.ruby_version.ruby_version).to eq("3.4.8")
24+
expect(result.warnings).to eq([])
25+
26+
result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "ruby-3.4.8# our version").call
27+
expect(result.ruby_version.ruby_version).to eq("3.4.8")
28+
expect(result.warnings).to eq([])
29+
end
30+
31+
it "strips @company-name suffix with ruby- prefix" do
32+
result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "ruby-3.4.8@company-name").call
33+
expect(result.ruby_version.ruby_version).to eq("3.4.8")
34+
expect(result.warnings).to eq([])
35+
36+
result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "ruby-3.4.8@company=>name").call
37+
expect(result.ruby_version.ruby_version).to eq("3.4.8")
38+
expect(result.warnings).to eq([])
39+
end
40+
41+
it "strips @org suffix from plain version" do
42+
result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "3.4.8@myorg").call
43+
expect(result.ruby_version.ruby_version).to eq("3.4.8")
44+
expect(result.warnings).to eq([])
45+
end
46+
47+
it "skips comments and blank lines" do
48+
result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: <<~EOF).call
49+
# This is a comment
50+
51+
3.4.8
52+
EOF
53+
expect(result.ruby_version.ruby_version).to eq("3.4.8")
54+
expect(result.warnings).to eq([])
55+
end
56+
57+
it "strips leading and trailing whitespace from the version line" do
58+
result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: " 3.4.8 \n").call
59+
expect(result.ruby_version.ruby_version).to eq("3.4.8")
60+
expect(result.warnings).to eq([])
61+
end
62+
63+
it "parses pre-release with dot syntax" do
64+
result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "3.4.0.rc1").call
65+
expect(result.ruby_version.ruby_version).to eq("3.4.0")
66+
expect(result.ruby_version.version_for_download).to eq("ruby-3.4.0.rc1")
67+
expect(result.warnings).to eq([])
68+
end
69+
70+
it "normalizes pre-release dash syntax to dot syntax" do
71+
result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "3.4.0-preview2").call
72+
expect(result.ruby_version.ruby_version).to eq("3.4.0")
73+
expect(result.ruby_version.version_for_download).to eq("ruby-3.4.0.preview2")
74+
expect(result.warnings).to eq([])
75+
end
76+
77+
it "normalizes pre-release with ruby- prefix" do
78+
result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "ruby-3.4.0-rc1").call
79+
expect(result.ruby_version.ruby_version).to eq("3.4.0")
80+
expect(result.ruby_version.version_for_download).to eq("ruby-3.4.0.rc1")
81+
expect(result.warnings).to eq([])
82+
end
83+
84+
it "is empty for an empty string" do
85+
result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "").call
86+
expect(result.ruby_version).to be_nil
87+
expect(result.warnings).to eq([])
88+
end
89+
90+
it "is empty for only whitespace" do
91+
result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: " \n \n").call
92+
expect(result.ruby_version).to be_nil
93+
expect(result.warnings).to eq([])
94+
end
95+
96+
it "is empty for only comments" do
97+
result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "# just a comment\n# another").call
98+
expect(result.ruby_version).to be_nil
99+
expect(result.warnings).to eq([])
100+
end
101+
102+
it "warns for multiple meaningful lines" do
103+
result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: <<~EOF).call
104+
3.4.8
105+
3.5.0
106+
EOF
107+
expect(result.ruby_version).to be_nil
108+
expect(result.warnings.length).to eq(1)
109+
expect(result.warnings.first).to include("multiple version lines")
110+
end
111+
112+
it "warns for jruby" do
113+
result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "jruby-10.0.2.0").call
114+
expect(result.ruby_version).to be_nil
115+
expect(result.warnings.length).to eq(1)
116+
expect(result.warnings.first).to include("JRuby")
117+
end
118+
119+
it "warns for version specifiers with >=" do
120+
result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "ruby >= 3.1.6, < 3.3").call
121+
expect(result.ruby_version).to be_nil
122+
expect(result.warnings.length).to eq(1)
123+
expect(result.warnings.first).to include("version specifiers")
124+
end
125+
126+
it "warns for ~> specifier" do
127+
result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "~> 3.1").call
128+
expect(result.ruby_version).to be_nil
129+
expect(result.warnings.length).to eq(1)
130+
expect(result.warnings.first).to include("version specifiers")
131+
end
132+
133+
it "warns for >= specifier" do
134+
result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: ">= 3.1.6").call
135+
expect(result.ruby_version).to be_nil
136+
expect(result.warnings.length).to eq(1)
137+
expect(result.warnings.first).to include("version specifiers")
138+
end
139+
140+
it "warns for garbage input" do
141+
result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "not-a-version").call
142+
expect(result.ruby_version).to be_nil
143+
expect(result.warnings.length).to eq(1)
144+
expect(result.warnings.first).to include("Cannot parse")
145+
end
146+
end

0 commit comments

Comments
 (0)