• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

type-ruby / t-ruby / 20478486132

24 Dec 2025 04:47AM UTC coverage: 75.962% (+0.08%) from 75.882%
20478486132

Pull #15

github

web-flow
Merge 668bc3161 into a32530d84
Pull Request #15: feat: integrate type checker into compiler for return type validation

64 of 71 new or added lines in 4 files covered. (90.14%)

38 existing lines in 2 files now uncovered.

5075 of 6681 relevant lines covered (75.96%)

1166.68 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.53
/lib/t_ruby/config.rb
1
# frozen_string_literal: true
2

3
require "yaml"
1✔
4

5
module TRuby
1✔
6
  # Error raised when configuration is invalid
7
  class ConfigError < StandardError; end
1✔
8

9
  class Config
1✔
10
    # Valid strictness levels
11
    VALID_STRICTNESS = %w[strict standard permissive].freeze
1✔
12
    # New schema structure (v0.0.12+)
13
    DEFAULT_CONFIG = {
14
      "source" => {
1✔
15
        "include" => ["src"],
16
        "exclude" => [],
17
        "extensions" => [".trb"],
18
      },
19
      "output" => {
20
        "ruby_dir" => "build",
21
        "rbs_dir" => nil,
22
        "clean_before_build" => false,
23
      },
24
      "compiler" => {
25
        "strictness" => "standard",
26
        "generate_rbs" => true,
27
        "target_ruby" => "3.0",
28
        "experimental" => [],
29
        "checks" => {
30
          "no_implicit_any" => false,
31
          "no_unused_vars" => false,
32
          "strict_nil" => false,
33
        },
34
      },
35
      "watch" => {
36
        "paths" => [],
37
        "debounce" => 100,
38
        "clear_screen" => false,
39
        "on_success" => nil,
40
      },
41
    }.freeze
42

43
    # Legacy keys for migration detection
44
    LEGACY_KEYS = %w[emit paths strict include exclude].freeze
1✔
45

46
    # Always excluded (not configurable)
47
    AUTO_EXCLUDE = [".git"].freeze
1✔
48

49
    attr_reader :source, :output, :compiler, :watch, :version_requirement
1✔
50

51
    def initialize(config_path = nil)
1✔
52
      raw_config = load_raw_config(config_path)
214✔
53
      config = process_config(raw_config)
214✔
54

55
      @source = config["source"]
214✔
56
      @output = config["output"]
214✔
57
      @compiler = config["compiler"]
214✔
58
      @watch = config["watch"]
214✔
59
      @version_requirement = raw_config["version"]
214✔
60
    end
61

62
    # Get output directory for compiled Ruby files
63
    # @return [String] output directory path
64
    def ruby_dir
1✔
65
      @output["ruby_dir"] || "build"
235✔
66
    end
67

68
    # Get output directory for RBS files
69
    # @return [String] RBS output directory (defaults to ruby_dir if not specified)
70
    def rbs_dir
1✔
71
      @output["rbs_dir"] || ruby_dir
65✔
72
    end
73

74
    # Check if output directory should be cleaned before build
75
    # @return [Boolean] true if should clean before build
76
    def clean_before_build?
1✔
77
      @output["clean_before_build"] == true
3✔
78
    end
79

80
    # Get compiler strictness level
81
    # @return [String] one of: strict, standard, permissive
82
    def strictness
1✔
83
      @compiler["strictness"] || "standard"
8✔
84
    end
85

86
    # Check if RBS files should be generated
87
    # @return [Boolean] true if RBS files should be generated
88
    def generate_rbs?
1✔
89
      @compiler["generate_rbs"] != false
3✔
90
    end
91

92
    # Get target Ruby version
93
    # @return [String] target Ruby version (e.g., "3.0", "3.2")
94
    def target_ruby
1✔
95
      (@compiler["target_ruby"] || "3.0").to_s
5✔
96
    end
97

98
    # Get list of enabled experimental features
99
    # @return [Array<String>] list of experimental feature names
100
    def experimental_features
1✔
101
      @compiler["experimental"] || []
9✔
102
    end
103

104
    # Check if a specific experimental feature is enabled
105
    # @param feature [String] feature name to check
106
    # @return [Boolean] true if feature is enabled
107
    def experimental_enabled?(feature)
1✔
108
      experimental_features.include?(feature)
5✔
109
    end
110

111
    # Check if no_implicit_any check is enabled
112
    # @return [Boolean] true if check is enabled
113
    def check_no_implicit_any?
1✔
114
      @compiler.dig("checks", "no_implicit_any") == true
5✔
115
    end
116

117
    # Check if no_unused_vars check is enabled
118
    # @return [Boolean] true if check is enabled
119
    def check_no_unused_vars?
1✔
120
      @compiler.dig("checks", "no_unused_vars") == true
4✔
121
    end
122

123
    # Check if strict_nil check is enabled
124
    # @return [Boolean] true if check is enabled
125
    def check_strict_nil?
1✔
126
      @compiler.dig("checks", "strict_nil") == true
4✔
127
    end
128

129
    # Get additional watch paths
130
    # @return [Array<String>] list of additional paths to watch
131
    def watch_paths
1✔
132
      @watch["paths"] || []
3✔
133
    end
134

135
    # Get watch debounce delay in milliseconds
136
    # @return [Integer] debounce delay in milliseconds
137
    def watch_debounce
1✔
138
      @watch["debounce"] || 100
5✔
139
    end
140

141
    # Check if terminal should be cleared before rebuild
142
    # @return [Boolean] true if terminal should be cleared
143
    def watch_clear_screen?
1✔
144
      @watch["clear_screen"] == true
6✔
145
    end
146

147
    # Get command to run after successful compilation
148
    # @return [String, nil] command to run on success
149
    def watch_on_success
1✔
150
      @watch["on_success"]
4✔
151
    end
152

153
    # Check if current T-Ruby version satisfies the version requirement
154
    # @return [Boolean] true if version is satisfied or no requirement specified
155
    def version_satisfied?
1✔
156
      return true if @version_requirement.nil?
5✔
157

158
      requirement = Gem::Requirement.new(@version_requirement)
4✔
159
      current = Gem::Version.new(TRuby::VERSION)
4✔
160
      requirement.satisfied_by?(current)
4✔
161
    rescue ArgumentError
UNCOV
162
      false
×
163
    end
164

165
    # Validate the configuration
166
    # @raise [ConfigError] if configuration is invalid
167
    def validate!
1✔
168
      validate_strictness!
2✔
169
      true
×
170
    end
171

172
    # Backwards compatible: alias for ruby_dir
173
    def out_dir
1✔
174
      ruby_dir
118✔
175
    end
176

177
    # Backwards compatible: first source.include directory
178
    def src_dir
1✔
179
      @source["include"].first || "src"
140✔
180
    end
181

182
    # Get source include directories
183
    # @return [Array<String>] list of include directories
184
    def source_include
1✔
185
      @source["include"] || ["src"]
129✔
186
    end
187

188
    # Get source exclude patterns
189
    # @return [Array<String>] list of exclude patterns
190
    def source_exclude
1✔
191
      @source["exclude"] || []
2✔
192
    end
193

194
    # Get source file extensions
195
    # @return [Array<String>] list of file extensions (e.g., [".trb", ".truby"])
196
    def source_extensions
1✔
197
      @source["extensions"] || [".trb"]
2✔
198
    end
199

200
    # Get include patterns for file discovery
201
    def include_patterns
1✔
202
      extensions = @source["extensions"] || [".trb"]
12✔
203
      extensions.map { |ext| "**/*#{ext}" }
26✔
204
    end
205

206
    # Get exclude patterns
207
    def exclude_patterns
1✔
208
      @source["exclude"] || []
41✔
209
    end
210

211
    # Find all source files matching include patterns, excluding exclude patterns
212
    # @return [Array<String>] list of matching file paths
213
    def find_source_files
1✔
214
      files = []
10✔
215

216
      @source["include"].each do |include_dir|
10✔
217
        base_dir = File.expand_path(include_dir)
13✔
218
        next unless Dir.exist?(base_dir)
13✔
219

220
        include_patterns.each do |pattern|
12✔
221
          full_pattern = File.join(base_dir, pattern)
14✔
222
          files.concat(Dir.glob(full_pattern))
14✔
223
        end
224
      end
225

226
      # Filter out excluded files
227
      files.reject { |f| excluded?(f) }.uniq.sort
33✔
228
    end
229

230
    # Check if a file path should be excluded
231
    # @param file_path [String] absolute or relative file path
232
    # @return [Boolean] true if file should be excluded
233
    def excluded?(file_path)
1✔
234
      relative_path = relative_to_src(file_path)
41✔
235
      all_exclude_patterns.any? { |pattern| matches_pattern?(relative_path, pattern) }
145✔
236
    end
237

238
    private
1✔
239

240
    # Validate strictness value
241
    def validate_strictness!
1✔
242
      value = strictness
2✔
243
      return if VALID_STRICTNESS.include?(value)
2✔
244

245
      raise ConfigError, "Invalid compiler.strictness: '#{value}'. Must be one of: #{VALID_STRICTNESS.join(", ")}"
2✔
246
    end
247

248
    def load_raw_config(config_path)
1✔
249
      raw = if config_path && File.exist?(config_path)
214✔
250
              YAML.safe_load_file(config_path, permitted_classes: [Symbol]) || {}
80✔
251
            elsif File.exist?("trbconfig.yml")
134✔
252
              YAML.safe_load_file("trbconfig.yml", permitted_classes: [Symbol]) || {}
39✔
253
            else
254
              {}
95✔
255
            end
256
      expand_env_vars(raw)
214✔
257
    end
258

259
    # Expand environment variables in config values
260
    # Supports ${VAR} and ${VAR:-default} syntax
261
    def expand_env_vars(obj)
1✔
262
      case obj
746✔
263
      when Hash
264
        obj.transform_values { |v| expand_env_vars(v) }
843✔
265
      when Array
266
        obj.map { |v| expand_env_vars(v) }
175✔
267
      when String
268
        expand_env_string(obj)
200✔
269
      else
270
        obj
60✔
271
      end
272
    end
273

274
    # Expand environment variables in a single string
275
    def expand_env_string(str)
1✔
276
      str.gsub(/\$\{([^}]+)\}/) do |_match|
200✔
277
        var_expr = ::Regexp.last_match(1)
8✔
278
        if var_expr.include?(":-")
8✔
279
          var_name, default_value = var_expr.split(":-", 2)
5✔
280
          ENV.fetch(var_name, default_value)
5✔
281
        else
282
          ENV.fetch(var_expr, "")
3✔
283
        end
284
      end
285
    end
286

287
    def process_config(raw_config)
1✔
288
      if legacy_config?(raw_config)
214✔
289
        warn "DEPRECATED: trbconfig.yml uses legacy format. Please migrate to new schema (source/output/compiler/watch)."
5✔
290
        migrate_legacy_config(raw_config)
5✔
291
      else
292
        merge_with_defaults(raw_config)
209✔
293
      end
294
    end
295

296
    def legacy_config?(raw_config)
1✔
297
      LEGACY_KEYS.any? { |key| raw_config.key?(key) }
1,265✔
298
    end
299

300
    def migrate_legacy_config(raw_config)
1✔
301
      result = deep_dup(DEFAULT_CONFIG)
5✔
302

303
      # Migrate emit -> compiler.generate_rbs
304
      if raw_config["emit"]&.key?("rbs")
5✔
305
        result["compiler"]["generate_rbs"] = raw_config["emit"]["rbs"]
4✔
306
      end
307

308
      # Migrate paths -> source.include and output.ruby_dir
309
      if raw_config["paths"]
5✔
310
        if raw_config["paths"]["src"]
3✔
311
          src_path = raw_config["paths"]["src"].sub(%r{^\./}, "")
3✔
312
          result["source"]["include"] = [src_path]
3✔
313
        end
314
        if raw_config["paths"]["out"]
3✔
315
          out_path = raw_config["paths"]["out"].sub(%r{^\./}, "")
3✔
316
          result["output"]["ruby_dir"] = out_path
3✔
317
        end
318
      end
319

320
      # Migrate include/exclude patterns
321
      if raw_config["include"]
5✔
322
        # Keep legacy include patterns as-is for now
UNCOV
323
        result["source"]["include"] = [result["source"]["include"].first || "src"]
×
324
      end
325

326
      if raw_config["exclude"]
5✔
UNCOV
327
        result["source"]["exclude"] = raw_config["exclude"]
×
328
      end
329

330
      result
5✔
331
    end
332

333
    def merge_with_defaults(user_config)
1✔
334
      result = deep_dup(DEFAULT_CONFIG)
209✔
335
      deep_merge(result, user_config)
209✔
336
      result
209✔
337
    end
338

339
    def deep_dup(hash)
1✔
340
      hash.transform_values do |value|
1,284✔
341
        if value.is_a?(Hash)
4,708✔
342
          deep_dup(value)
1,070✔
343
        else
344
          (value.is_a?(Array) ? value.dup : value)
3,638✔
345
        end
346
      end
347
    end
348

349
    def deep_merge(target, source)
1✔
350
      source.each do |key, value|
399✔
351
        if value.is_a?(Hash) && target[key].is_a?(Hash)
409✔
352
          deep_merge(target[key], value)
190✔
353
        elsif !value.nil?
219✔
354
          target[key] = value
219✔
355
        end
356
      end
357
    end
358

359
    # Combine auto-excluded patterns with user-configured patterns
360
    def all_exclude_patterns
1✔
361
      patterns = AUTO_EXCLUDE.dup
41✔
362
      patterns << out_dir.sub(%r{^\./}, "") # Add output directory
41✔
363
      patterns.concat(exclude_patterns)
41✔
364
      patterns.uniq
41✔
365
    end
366

367
    # Convert absolute path to relative path from first src_dir
368
    def relative_to_src(file_path)
1✔
369
      base_dir = File.expand_path(src_dir)
41✔
370
      full_path = File.expand_path(file_path)
41✔
371

372
      if full_path.start_with?(base_dir)
41✔
373
        full_path.sub("#{base_dir}/", "")
21✔
374
      else
375
        file_path
20✔
376
      end
377
    end
378

379
    # Check if path matches a glob/directory pattern
380
    def matches_pattern?(path, pattern)
1✔
381
      # Direct directory match (e.g., "node_modules" matches "node_modules/foo.rb")
382
      return true if path.start_with?("#{pattern}/") || path == pattern
104✔
383

384
      # Check if any path component matches
385
      path_parts = path.split("/")
96✔
386
      return true if path_parts.include?(pattern)
96✔
387

388
      # Glob pattern match
389
      File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_DOTMATCH)
95✔
390
    end
391
  end
392
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc