t-ruby 0.0.7 → 0.0.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/t_ruby/config.rb CHANGED
@@ -3,51 +3,393 @@
3
3
  require "yaml"
4
4
 
5
5
  module TRuby
6
+ # Error raised when configuration is invalid
7
+ class ConfigError < StandardError; end
8
+
6
9
  class Config
10
+ # Valid strictness levels
11
+ VALID_STRICTNESS = %w[strict standard permissive].freeze
12
+ # New schema structure (v0.0.12+)
7
13
  DEFAULT_CONFIG = {
8
- "emit" => {
9
- "rb" => true,
10
- "rbs" => false,
11
- "dtrb" => false
14
+ "source" => {
15
+ "include" => ["src"],
16
+ "exclude" => [],
17
+ "extensions" => [".trb"]
18
+ },
19
+ "output" => {
20
+ "ruby_dir" => "build",
21
+ "rbs_dir" => nil,
22
+ "preserve_structure" => true,
23
+ "clean_before_build" => false
12
24
  },
13
- "paths" => {
14
- "src" => "./src",
15
- "out" => "./build"
25
+ "compiler" => {
26
+ "strictness" => "standard",
27
+ "generate_rbs" => true,
28
+ "target_ruby" => "3.0",
29
+ "experimental" => [],
30
+ "checks" => {
31
+ "no_implicit_any" => false,
32
+ "no_unused_vars" => false,
33
+ "strict_nil" => false
34
+ }
16
35
  },
17
- "strict" => {
18
- "rbs_compat" => true,
19
- "null_safety" => false,
20
- "inference" => "basic"
36
+ "watch" => {
37
+ "paths" => [],
38
+ "debounce" => 100,
39
+ "clear_screen" => false,
40
+ "on_success" => nil
21
41
  }
22
42
  }.freeze
23
43
 
24
- attr_reader :emit, :paths, :strict
44
+ # Legacy keys for migration detection
45
+ LEGACY_KEYS = %w[emit paths strict include exclude].freeze
46
+
47
+ # Always excluded (not configurable)
48
+ AUTO_EXCLUDE = [".git"].freeze
49
+
50
+ attr_reader :source, :output, :compiler, :watch, :version_requirement
25
51
 
26
52
  def initialize(config_path = nil)
27
- config = load_config(config_path)
28
- @emit = config["emit"]
29
- @paths = config["paths"]
30
- @strict = config["strict"]
53
+ raw_config = load_raw_config(config_path)
54
+ config = process_config(raw_config)
55
+
56
+ @source = config["source"]
57
+ @output = config["output"]
58
+ @compiler = config["compiler"]
59
+ @watch = config["watch"]
60
+ @version_requirement = raw_config["version"]
61
+ end
62
+
63
+ # Get output directory for compiled Ruby files
64
+ # @return [String] output directory path
65
+ def ruby_dir
66
+ @output["ruby_dir"] || "build"
67
+ end
68
+
69
+ # Get output directory for RBS files
70
+ # @return [String] RBS output directory (defaults to ruby_dir if not specified)
71
+ def rbs_dir
72
+ @output["rbs_dir"] || ruby_dir
73
+ end
74
+
75
+ # Check if source directory structure should be preserved in output
76
+ # @return [Boolean] true if structure should be preserved
77
+ def preserve_structure?
78
+ @output["preserve_structure"] != false
79
+ end
80
+
81
+ # Check if output directory should be cleaned before build
82
+ # @return [Boolean] true if should clean before build
83
+ def clean_before_build?
84
+ @output["clean_before_build"] == true
85
+ end
86
+
87
+ # Get compiler strictness level
88
+ # @return [String] one of: strict, standard, permissive
89
+ def strictness
90
+ @compiler["strictness"] || "standard"
91
+ end
92
+
93
+ # Check if RBS files should be generated
94
+ # @return [Boolean] true if RBS files should be generated
95
+ def generate_rbs?
96
+ @compiler["generate_rbs"] != false
97
+ end
98
+
99
+ # Get target Ruby version
100
+ # @return [String] target Ruby version (e.g., "3.0", "3.2")
101
+ def target_ruby
102
+ (@compiler["target_ruby"] || "3.0").to_s
103
+ end
104
+
105
+ # Get list of enabled experimental features
106
+ # @return [Array<String>] list of experimental feature names
107
+ def experimental_features
108
+ @compiler["experimental"] || []
109
+ end
110
+
111
+ # Check if a specific experimental feature is enabled
112
+ # @param feature [String] feature name to check
113
+ # @return [Boolean] true if feature is enabled
114
+ def experimental_enabled?(feature)
115
+ experimental_features.include?(feature)
116
+ end
117
+
118
+ # Check if no_implicit_any check is enabled
119
+ # @return [Boolean] true if check is enabled
120
+ def check_no_implicit_any?
121
+ @compiler.dig("checks", "no_implicit_any") == true
122
+ end
123
+
124
+ # Check if no_unused_vars check is enabled
125
+ # @return [Boolean] true if check is enabled
126
+ def check_no_unused_vars?
127
+ @compiler.dig("checks", "no_unused_vars") == true
128
+ end
129
+
130
+ # Check if strict_nil check is enabled
131
+ # @return [Boolean] true if check is enabled
132
+ def check_strict_nil?
133
+ @compiler.dig("checks", "strict_nil") == true
134
+ end
135
+
136
+ # Get additional watch paths
137
+ # @return [Array<String>] list of additional paths to watch
138
+ def watch_paths
139
+ @watch["paths"] || []
140
+ end
141
+
142
+ # Get watch debounce delay in milliseconds
143
+ # @return [Integer] debounce delay in milliseconds
144
+ def watch_debounce
145
+ @watch["debounce"] || 100
146
+ end
147
+
148
+ # Check if terminal should be cleared before rebuild
149
+ # @return [Boolean] true if terminal should be cleared
150
+ def watch_clear_screen?
151
+ @watch["clear_screen"] == true
152
+ end
153
+
154
+ # Get command to run after successful compilation
155
+ # @return [String, nil] command to run on success
156
+ def watch_on_success
157
+ @watch["on_success"]
158
+ end
159
+
160
+ # Check if current T-Ruby version satisfies the version requirement
161
+ # @return [Boolean] true if version is satisfied or no requirement specified
162
+ def version_satisfied?
163
+ return true if @version_requirement.nil?
164
+
165
+ requirement = Gem::Requirement.new(@version_requirement)
166
+ current = Gem::Version.new(TRuby::VERSION)
167
+ requirement.satisfied_by?(current)
168
+ rescue ArgumentError
169
+ false
170
+ end
171
+
172
+ # Validate the configuration
173
+ # @raise [ConfigError] if configuration is invalid
174
+ def validate!
175
+ validate_strictness!
176
+ true
31
177
  end
32
178
 
179
+ # Backwards compatible: alias for ruby_dir
33
180
  def out_dir
34
- @paths["out"]
181
+ ruby_dir
35
182
  end
36
183
 
184
+ # Backwards compatible: first source.include directory
37
185
  def src_dir
38
- @paths["src"]
186
+ @source["include"].first || "src"
187
+ end
188
+
189
+ # Get source include directories
190
+ # @return [Array<String>] list of include directories
191
+ def source_include
192
+ @source["include"] || ["src"]
193
+ end
194
+
195
+ # Get source exclude patterns
196
+ # @return [Array<String>] list of exclude patterns
197
+ def source_exclude
198
+ @source["exclude"] || []
199
+ end
200
+
201
+ # Get source file extensions
202
+ # @return [Array<String>] list of file extensions (e.g., [".trb", ".truby"])
203
+ def source_extensions
204
+ @source["extensions"] || [".trb"]
205
+ end
206
+
207
+ # Get include patterns for file discovery
208
+ def include_patterns
209
+ extensions = @source["extensions"] || [".trb"]
210
+ extensions.map { |ext| "**/*#{ext}" }
211
+ end
212
+
213
+ # Get exclude patterns
214
+ def exclude_patterns
215
+ @source["exclude"] || []
216
+ end
217
+
218
+ # Find all source files matching include patterns, excluding exclude patterns
219
+ # @return [Array<String>] list of matching file paths
220
+ def find_source_files
221
+ files = []
222
+
223
+ @source["include"].each do |include_dir|
224
+ base_dir = File.expand_path(include_dir)
225
+ next unless Dir.exist?(base_dir)
226
+
227
+ include_patterns.each do |pattern|
228
+ full_pattern = File.join(base_dir, pattern)
229
+ files.concat(Dir.glob(full_pattern))
230
+ end
231
+ end
232
+
233
+ # Filter out excluded files
234
+ files.reject { |f| excluded?(f) }.uniq.sort
235
+ end
236
+
237
+ # Check if a file path should be excluded
238
+ # @param file_path [String] absolute or relative file path
239
+ # @return [Boolean] true if file should be excluded
240
+ def excluded?(file_path)
241
+ relative_path = relative_to_src(file_path)
242
+ all_exclude_patterns.any? { |pattern| matches_pattern?(relative_path, pattern) }
39
243
  end
40
244
 
41
245
  private
42
246
 
43
- def load_config(config_path)
44
- if config_path && File.exist?(config_path)
45
- YAML.safe_load_file(config_path, permitted_classes: [Symbol])
46
- elsif File.exist?(".trb.yml")
47
- YAML.safe_load_file(".trb.yml", permitted_classes: [Symbol])
247
+ # Validate strictness value
248
+ def validate_strictness!
249
+ value = strictness
250
+ return if VALID_STRICTNESS.include?(value)
251
+
252
+ raise ConfigError, "Invalid compiler.strictness: '#{value}'. Must be one of: #{VALID_STRICTNESS.join(', ')}"
253
+ end
254
+
255
+ def load_raw_config(config_path)
256
+ raw = if config_path && File.exist?(config_path)
257
+ YAML.safe_load_file(config_path, permitted_classes: [Symbol]) || {}
258
+ elsif File.exist?("trbconfig.yml")
259
+ YAML.safe_load_file("trbconfig.yml", permitted_classes: [Symbol]) || {}
260
+ else
261
+ {}
262
+ end
263
+ expand_env_vars(raw)
264
+ end
265
+
266
+ # Expand environment variables in config values
267
+ # Supports ${VAR} and ${VAR:-default} syntax
268
+ def expand_env_vars(obj)
269
+ case obj
270
+ when Hash
271
+ obj.transform_values { |v| expand_env_vars(v) }
272
+ when Array
273
+ obj.map { |v| expand_env_vars(v) }
274
+ when String
275
+ expand_env_string(obj)
276
+ else
277
+ obj
278
+ end
279
+ end
280
+
281
+ # Expand environment variables in a single string
282
+ def expand_env_string(str)
283
+ str.gsub(/\$\{([^}]+)\}/) do |_match|
284
+ var_expr = ::Regexp.last_match(1)
285
+ if var_expr.include?(":-")
286
+ var_name, default_value = var_expr.split(":-", 2)
287
+ ENV.fetch(var_name, default_value)
288
+ else
289
+ ENV.fetch(var_expr, "")
290
+ end
291
+ end
292
+ end
293
+
294
+ def process_config(raw_config)
295
+ if legacy_config?(raw_config)
296
+ warn "DEPRECATED: trbconfig.yml uses legacy format. Please migrate to new schema (source/output/compiler/watch)."
297
+ migrate_legacy_config(raw_config)
298
+ else
299
+ merge_with_defaults(raw_config)
300
+ end
301
+ end
302
+
303
+ def legacy_config?(raw_config)
304
+ LEGACY_KEYS.any? { |key| raw_config.key?(key) }
305
+ end
306
+
307
+ def migrate_legacy_config(raw_config)
308
+ result = deep_dup(DEFAULT_CONFIG)
309
+
310
+ # Migrate emit -> compiler.generate_rbs
311
+ if raw_config["emit"]
312
+ result["compiler"]["generate_rbs"] = raw_config["emit"]["rbs"] if raw_config["emit"].key?("rbs")
313
+ end
314
+
315
+ # Migrate paths -> source.include and output.ruby_dir
316
+ if raw_config["paths"]
317
+ if raw_config["paths"]["src"]
318
+ src_path = raw_config["paths"]["src"].sub(%r{^\./}, "")
319
+ result["source"]["include"] = [src_path]
320
+ end
321
+ if raw_config["paths"]["out"]
322
+ out_path = raw_config["paths"]["out"].sub(%r{^\./}, "")
323
+ result["output"]["ruby_dir"] = out_path
324
+ end
325
+ end
326
+
327
+ # Migrate include/exclude patterns
328
+ if raw_config["include"]
329
+ # Keep legacy include patterns as-is for now
330
+ result["source"]["include"] = [result["source"]["include"].first || "src"]
331
+ end
332
+
333
+ if raw_config["exclude"]
334
+ result["source"]["exclude"] = raw_config["exclude"]
335
+ end
336
+
337
+ result
338
+ end
339
+
340
+ def merge_with_defaults(user_config)
341
+ result = deep_dup(DEFAULT_CONFIG)
342
+ deep_merge(result, user_config)
343
+ result
344
+ end
345
+
346
+ def deep_dup(hash)
347
+ hash.each_with_object({}) do |(key, value), result|
348
+ result[key] = value.is_a?(Hash) ? deep_dup(value) : (value.is_a?(Array) ? value.dup : value)
349
+ end
350
+ end
351
+
352
+ def deep_merge(target, source)
353
+ source.each do |key, value|
354
+ if value.is_a?(Hash) && target[key].is_a?(Hash)
355
+ deep_merge(target[key], value)
356
+ elsif !value.nil?
357
+ target[key] = value
358
+ end
359
+ end
360
+ end
361
+
362
+ # Combine auto-excluded patterns with user-configured patterns
363
+ def all_exclude_patterns
364
+ patterns = AUTO_EXCLUDE.dup
365
+ patterns << out_dir.sub(%r{^\./}, "") # Add output directory
366
+ patterns.concat(exclude_patterns)
367
+ patterns.uniq
368
+ end
369
+
370
+ # Convert absolute path to relative path from first src_dir
371
+ def relative_to_src(file_path)
372
+ base_dir = File.expand_path(src_dir)
373
+ full_path = File.expand_path(file_path)
374
+
375
+ if full_path.start_with?(base_dir)
376
+ full_path.sub("#{base_dir}/", "")
48
377
  else
49
- DEFAULT_CONFIG.dup
378
+ file_path
50
379
  end
51
380
  end
381
+
382
+ # Check if path matches a glob/directory pattern
383
+ def matches_pattern?(path, pattern)
384
+ # Direct directory match (e.g., "node_modules" matches "node_modules/foo.rb")
385
+ return true if path.start_with?("#{pattern}/") || path == pattern
386
+
387
+ # Check if any path component matches
388
+ path_parts = path.split("/")
389
+ return true if path_parts.include?(pattern)
390
+
391
+ # Glob pattern match
392
+ File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_DOTMATCH)
393
+ end
52
394
  end
53
395
  end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "docs_example_verifier"
4
+
5
+ module TRuby
6
+ # Generates badges and reports for documentation verification results.
7
+ #
8
+ # Supports:
9
+ # - Shields.io compatible JSON badges
10
+ # - SVG badge generation
11
+ # - Markdown report generation
12
+ # - JSON report generation
13
+ #
14
+ # @example
15
+ # generator = DocsBadgeGenerator.new
16
+ # verifier = DocsExampleVerifier.new
17
+ # results = verifier.verify_glob("docs/**/*.md")
18
+ # generator.generate_badge(results, "coverage/docs_badge.json")
19
+ #
20
+ class DocsBadgeGenerator
21
+ # Badge colors based on pass rate
22
+ COLORS = {
23
+ excellent: "brightgreen", # 95-100%
24
+ good: "green", # 80-94%
25
+ fair: "yellow", # 60-79%
26
+ poor: "orange", # 40-59%
27
+ critical: "red", # 0-39%
28
+ }.freeze
29
+
30
+ def initialize
31
+ @verifier = DocsExampleVerifier.new
32
+ end
33
+
34
+ # Generate all outputs
35
+ #
36
+ # @param results [Array<DocsExampleVerifier::VerificationResult>] Results
37
+ # @param output_dir [String] Output directory
38
+ def generate_all(results, output_dir)
39
+ FileUtils.mkdir_p(output_dir)
40
+
41
+ generate_badge_json(results, File.join(output_dir, "docs_badge.json"))
42
+ generate_badge_svg(results, File.join(output_dir, "docs_badge.svg"))
43
+ generate_report_json(results, File.join(output_dir, "docs_report.json"))
44
+ generate_report_markdown(results, File.join(output_dir, "docs_report.md"))
45
+ end
46
+
47
+ # Generate Shields.io compatible JSON badge
48
+ #
49
+ # @param results [Array<DocsExampleVerifier::VerificationResult>] Results
50
+ # @param output_path [String] Output file path
51
+ def generate_badge_json(results, output_path)
52
+ summary = @verifier.summary(results)
53
+ pass_rate = summary[:pass_rate]
54
+
55
+ badge = {
56
+ schemaVersion: 1,
57
+ label: "docs examples",
58
+ message: "#{pass_rate}%",
59
+ color: color_for_rate(pass_rate),
60
+ }
61
+
62
+ File.write(output_path, JSON.pretty_generate(badge))
63
+ end
64
+
65
+ # Generate SVG badge
66
+ #
67
+ # @param results [Array<DocsExampleVerifier::VerificationResult>] Results
68
+ # @param output_path [String] Output file path
69
+ def generate_badge_svg(results, output_path)
70
+ summary = @verifier.summary(results)
71
+ pass_rate = summary[:pass_rate]
72
+ color = svg_color_for_rate(pass_rate)
73
+
74
+ svg = <<~SVG
75
+ <svg xmlns="http://www.w3.org/2000/svg" width="140" height="20">
76
+ <linearGradient id="b" x2="0" y2="100%">
77
+ <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
78
+ <stop offset="1" stop-opacity=".1"/>
79
+ </linearGradient>
80
+ <mask id="a">
81
+ <rect width="140" height="20" rx="3" fill="#fff"/>
82
+ </mask>
83
+ <g mask="url(#a)">
84
+ <path fill="#555" d="M0 0h85v20H0z"/>
85
+ <path fill="#{color}" d="M85 0h55v20H85z"/>
86
+ <path fill="url(#b)" d="M0 0h140v20H0z"/>
87
+ </g>
88
+ <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
89
+ <text x="42.5" y="15" fill="#010101" fill-opacity=".3">docs examples</text>
90
+ <text x="42.5" y="14">docs examples</text>
91
+ <text x="112" y="15" fill="#010101" fill-opacity=".3">#{pass_rate}%</text>
92
+ <text x="112" y="14">#{pass_rate}%</text>
93
+ </g>
94
+ </svg>
95
+ SVG
96
+
97
+ File.write(output_path, svg)
98
+ end
99
+
100
+ # Generate JSON report
101
+ #
102
+ # @param results [Array<DocsExampleVerifier::VerificationResult>] Results
103
+ # @param output_path [String] Output file path
104
+ def generate_report_json(results, output_path)
105
+ summary = @verifier.summary(results)
106
+
107
+ report = {
108
+ generated_at: Time.now.iso8601,
109
+ summary: summary,
110
+ files: group_results_by_file(results),
111
+ }
112
+
113
+ File.write(output_path, JSON.pretty_generate(report))
114
+ end
115
+
116
+ # Generate Markdown report
117
+ #
118
+ # @param results [Array<DocsExampleVerifier::VerificationResult>] Results
119
+ # @param output_path [String] Output file path
120
+ def generate_report_markdown(results, output_path)
121
+ summary = @verifier.summary(results)
122
+ grouped = group_results_by_file(results)
123
+
124
+ markdown = <<~MD
125
+ # Documentation Examples Verification Report
126
+
127
+ Generated: #{Time.now.strftime("%Y-%m-%d %H:%M:%S")}
128
+
129
+ ## Summary
130
+
131
+ | Metric | Value |
132
+ |--------|-------|
133
+ | Total Examples | #{summary[:total]} |
134
+ | Passed | #{summary[:passed]} |
135
+ | Failed | #{summary[:failed]} |
136
+ | Skipped | #{summary[:skipped]} |
137
+ | **Pass Rate** | **#{summary[:pass_rate]}%** |
138
+
139
+ ## Results by File
140
+
141
+ MD
142
+
143
+ grouped.each do |file_path, file_results|
144
+ file_summary = @verifier.summary(file_results)
145
+ status_emoji = file_summary[:failed].zero? ? "✅" : "❌"
146
+
147
+ markdown += "### #{status_emoji} #{file_path}\n\n"
148
+ markdown += "Pass rate: #{file_summary[:pass_rate]}% (#{file_summary[:passed]}/#{file_summary[:total]})\n\n"
149
+
150
+ failed_results = file_results.select(&:fail?)
151
+ if failed_results.any?
152
+ markdown += "**Failed examples:**\n\n"
153
+ failed_results.each do |result|
154
+ markdown += "- Line #{result.line_number}:\n"
155
+ result.errors.each do |error|
156
+ markdown += " - #{error}\n"
157
+ end
158
+ end
159
+ markdown += "\n"
160
+ end
161
+ end
162
+
163
+ File.write(output_path, markdown)
164
+ end
165
+
166
+ private
167
+
168
+ def color_for_rate(rate)
169
+ case rate
170
+ when 95..100 then COLORS[:excellent]
171
+ when 80...95 then COLORS[:good]
172
+ when 60...80 then COLORS[:fair]
173
+ when 40...60 then COLORS[:poor]
174
+ else COLORS[:critical]
175
+ end
176
+ end
177
+
178
+ def svg_color_for_rate(rate)
179
+ case rate
180
+ when 95..100 then "#4c1" # bright green
181
+ when 80...95 then "#97ca00" # green
182
+ when 60...80 then "#dfb317" # yellow
183
+ when 40...60 then "#fe7d37" # orange
184
+ else "#e05d44" # red
185
+ end
186
+ end
187
+
188
+ def group_results_by_file(results)
189
+ results.group_by(&:file_path)
190
+ end
191
+ end
192
+ end