rfmt 0.1.0 → 0.2.2

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/rfmt/cli.rb ADDED
@@ -0,0 +1,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+ require 'rfmt'
5
+ require 'rfmt/configuration'
6
+ require 'rfmt/cache'
7
+
8
+ module Rfmt
9
+ # Cache management commands
10
+ class CacheCommands < Thor
11
+ desc 'clear', 'Clear all cache data'
12
+ option :cache_dir, type: :string, desc: 'Cache directory (default: ~/.cache/rfmt)'
13
+ def clear
14
+ cache_opts = options[:cache_dir] ? { cache_dir: options[:cache_dir] } : {}
15
+ cache = Cache.new(**cache_opts)
16
+ cache.clear
17
+ say 'Cache cleared', :green
18
+ end
19
+
20
+ desc 'stats', 'Show cache statistics'
21
+ option :cache_dir, type: :string, desc: 'Cache directory (default: ~/.cache/rfmt)'
22
+ def stats
23
+ cache_opts = options[:cache_dir] ? { cache_dir: options[:cache_dir] } : {}
24
+ cache = Cache.new(**cache_opts)
25
+ stats = cache.stats
26
+ say "Cache directory: #{stats[:cache_dir]}", :blue
27
+ say "Total files in cache: #{stats[:total_files]}", :blue
28
+ say "Cache size: #{(stats[:cache_size_bytes] / 1024.0).round(2)} KB", :blue
29
+ end
30
+
31
+ desc 'prune', 'Remove cache entries for files that no longer exist'
32
+ option :cache_dir, type: :string, desc: 'Cache directory (default: ~/.cache/rfmt)'
33
+ def prune
34
+ cache_opts = options[:cache_dir] ? { cache_dir: options[:cache_dir] } : {}
35
+ cache = Cache.new(**cache_opts)
36
+ pruned = cache.prune
37
+ say "Pruned #{pruned} stale cache entries", :green
38
+ end
39
+ end
40
+
41
+ # Command Line Interface for rfmt
42
+ class CLI < Thor
43
+ class_option :config, type: :string, desc: 'Path to configuration file'
44
+ class_option :verbose, type: :boolean, aliases: '-v', desc: 'Verbose output'
45
+
46
+ desc 'format [FILES]', 'Format Ruby files'
47
+ option :write, type: :boolean, default: true, desc: 'Write formatted output'
48
+ option :check, type: :boolean, desc: "Check if files are formatted (don't write)"
49
+ option :diff, type: :boolean, desc: 'Show diff of changes'
50
+ option :diff_format, type: :string, default: 'unified', desc: 'Diff format: unified, side_by_side, or color'
51
+ option :parallel, type: :boolean, default: true, desc: 'Process files in parallel'
52
+ option :jobs, type: :numeric, desc: 'Number of parallel jobs (default: CPU count)'
53
+ option :cache, type: :boolean, default: true, desc: 'Use cache to skip unchanged files'
54
+ option :cache_dir, type: :string, desc: 'Cache directory (default: ~/.cache/rfmt)'
55
+ def format(*files)
56
+ config = load_config
57
+ files = files.empty? ? config.files_to_format : files.flatten
58
+
59
+ if files.empty?
60
+ say 'No files to format', :yellow
61
+ return
62
+ end
63
+
64
+ # Initialize cache
65
+ cache = if options[:cache]
66
+ cache_opts = options[:cache_dir] ? { cache_dir: options[:cache_dir] } : {}
67
+ Cache.new(**cache_opts)
68
+ end
69
+
70
+ # Filter files using cache
71
+ if cache
72
+ original_count = files.size
73
+ files = files.select { |file| cache.needs_formatting?(file) }
74
+ skipped = original_count - files.size
75
+ say "Skipped #{skipped} unchanged file(s) (cache hit)", :blue if skipped.positive? && options[:verbose]
76
+ end
77
+
78
+ if files.empty?
79
+ say 'All files are already formatted', :green
80
+ return
81
+ end
82
+
83
+ say "Formatting #{files.size} file(s)...", :blue if options[:verbose]
84
+
85
+ results = if options[:parallel] && files.size > 1
86
+ format_files_parallel(files)
87
+ else
88
+ format_files_sequential(files)
89
+ end
90
+ handle_results(results, cache)
91
+ end
92
+
93
+ desc 'check [FILES]', 'Check if files need formatting'
94
+ def check(*files)
95
+ invoke :format, files, check: true, write: false
96
+ end
97
+
98
+ desc 'version', 'Show version'
99
+ def version
100
+ say "rfmt #{Rfmt::VERSION}"
101
+ say "Rust extension: #{Rfmt.rust_version}"
102
+ end
103
+
104
+ desc 'config', 'Show current configuration'
105
+ def config_cmd
106
+ config = load_config
107
+ require 'json'
108
+ say JSON.pretty_generate(config.config)
109
+ end
110
+
111
+ desc 'cache SUBCOMMAND', 'Manage cache'
112
+ subcommand 'cache', CacheCommands
113
+
114
+ desc 'init', 'Initialize rfmt configuration'
115
+ option :force, type: :boolean, desc: 'Overwrite existing configuration'
116
+ option :path, type: :string, default: '.rfmt.yml', desc: 'Configuration file path'
117
+ def init
118
+ config_file = options[:path] || '.rfmt.yml'
119
+
120
+ # Use Rfmt::Config module for consistent behavior
121
+ result = Rfmt::Config.init(config_file, force: options[:force] || false)
122
+
123
+ if result
124
+ say "Created #{config_file}", :green
125
+ else
126
+ say "Configuration file already exists at #{config_file}. Use --force to overwrite.", :yellow
127
+ end
128
+ end
129
+
130
+ private
131
+
132
+ def load_config
133
+ if options[:config]
134
+ Configuration.new(file: options[:config])
135
+ else
136
+ Configuration.discover
137
+ end
138
+ end
139
+
140
+ def format_files_sequential(files)
141
+ files.map do |file|
142
+ format_single_file(file)
143
+ end
144
+ end
145
+
146
+ def format_files_parallel(files)
147
+ require 'parallel'
148
+
149
+ # Determine number of processes to use
150
+ process_count = options[:jobs] || Parallel.processor_count
151
+
152
+ say "Processing #{files.size} files with #{process_count} parallel jobs...", :blue if options[:verbose]
153
+
154
+ Parallel.map(files, in_processes: process_count) do |file|
155
+ format_single_file(file)
156
+ end
157
+ end
158
+
159
+ def format_single_file(file)
160
+ start_time = Time.now
161
+ source = File.read(file)
162
+
163
+ formatted = Rfmt.format(source)
164
+ changed = source != formatted
165
+
166
+ {
167
+ file: file,
168
+ changed: changed,
169
+ original: source,
170
+ formatted: formatted,
171
+ duration: Time.now - start_time,
172
+ error: nil
173
+ }
174
+ rescue StandardError => e
175
+ {
176
+ file: file,
177
+ error: e.message,
178
+ duration: Time.now - start_time
179
+ }
180
+ end
181
+
182
+ def handle_results(results, cache = nil)
183
+ failed_count = 0
184
+ changed_count = 0
185
+ error_count = 0
186
+
187
+ results.each do |result|
188
+ if result[:error]
189
+ say "Error in #{result[:file]}: #{result[:error]}", :red
190
+ error_count += 1
191
+ next
192
+ end
193
+
194
+ if result[:changed]
195
+ changed_count += 1
196
+
197
+ if options[:check]
198
+ say "#{result[:file]} needs formatting", :yellow
199
+ failed_count += 1
200
+ show_diff(result[:file], result[:original], result[:formatted]) if options[:diff]
201
+ elsif options[:diff]
202
+ show_diff(result[:file], result[:original], result[:formatted])
203
+ elsif options[:write]
204
+ File.write(result[:file], result[:formatted])
205
+ say "Formatted #{result[:file]}", :green if options[:verbose]
206
+
207
+ # Update cache after successful write
208
+ cache&.mark_formatted(result[:file])
209
+ else
210
+ puts result[:formatted]
211
+ end
212
+ else
213
+ say "#{result[:file]} already formatted", :blue if options[:verbose]
214
+
215
+ # Update cache even if no changes (file was checked)
216
+ cache&.mark_formatted(result[:file])
217
+ end
218
+ end
219
+
220
+ # Save cache to disk
221
+ cache&.save
222
+
223
+ # Summary
224
+ say "\n#{results.size} file(s) processed", :blue if options[:verbose]
225
+ say "#{changed_count} file(s) changed", :yellow if changed_count.positive? && options[:verbose]
226
+ say "#{error_count} error(s)", :red if error_count.positive?
227
+
228
+ exit(1) if (options[:check] && failed_count.positive?) || error_count.positive?
229
+ end
230
+
231
+ def show_diff(file, original, formatted)
232
+ require 'diffy'
233
+
234
+ say "\n#{'=' * 80}", :blue
235
+ say "Diff for #{file}:", :yellow
236
+ say '=' * 80, :blue
237
+
238
+ case options[:diff_format]
239
+ when 'unified'
240
+ diff = Diffy::Diff.new(original, formatted, context: 3)
241
+ puts diff.to_s(:color)
242
+ when 'side_by_side'
243
+ diff = Diffy::Diff.new(original, formatted, context: 3)
244
+ # Side-by-side is not well supported in terminal, use unified with more context
245
+ puts diff.to_s(:color)
246
+ when 'color'
247
+ show_colored_line_diff(original, formatted)
248
+ else
249
+ diff = Diffy::Diff.new(original, formatted, context: 3)
250
+ puts diff.to_s(:color)
251
+ end
252
+
253
+ say "#{'=' * 80}\n", :blue
254
+ end
255
+
256
+ def show_colored_line_diff(original, formatted)
257
+ require 'diff/lcs'
258
+
259
+ original_lines = original.split("\n")
260
+ formatted_lines = formatted.split("\n")
261
+
262
+ diffs = Diff::LCS.sdiff(original_lines, formatted_lines)
263
+
264
+ diffs.each_with_index do |diff, idx|
265
+ line_num = idx + 1
266
+ case diff.action
267
+ when '-'
268
+ say "#{line_num}: - #{diff.old_element}", :red
269
+ when '+'
270
+ say "#{line_num}: + #{diff.new_element}", :green
271
+ when '='
272
+ say "#{line_num}: #{diff.old_element}", :white
273
+ when '!'
274
+ say "#{line_num}: - #{diff.old_element}", :red
275
+ say "#{line_num}: + #{diff.new_element}", :green
276
+ end
277
+ end
278
+ end
279
+ end
280
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Rfmt
6
+ # Configuration management for rfmt
7
+ class Configuration
8
+ class ConfigError < StandardError; end
9
+
10
+ DEFAULT_CONFIG = {
11
+ 'version' => '1.0',
12
+ 'formatting' => {
13
+ 'line_length' => 100,
14
+ 'indent_width' => 2,
15
+ 'indent_style' => 'spaces'
16
+ },
17
+ 'include' => ['**/*.rb'],
18
+ 'exclude' => ['vendor/**/*', 'tmp/**/*', 'node_modules/**/*']
19
+ }.freeze
20
+
21
+ CONFIG_FILES = ['.rfmt.yml', '.rfmt.yaml', 'rfmt.yml', 'rfmt.yaml'].freeze
22
+
23
+ attr_reader :config
24
+
25
+ def initialize(options = {})
26
+ @config = load_configuration(options)
27
+ end
28
+
29
+ # Discover configuration file in current directory
30
+ def self.discover
31
+ config_file = CONFIG_FILES.find { |file| File.exist?(file) }
32
+ config_file ? new(file: config_file) : new
33
+ end
34
+
35
+ # Get list of files to format based on include/exclude patterns
36
+ def files_to_format(base_path: '.')
37
+ include_patterns = @config['include']
38
+ exclude_patterns = @config['exclude']
39
+
40
+ included_files = include_patterns.flat_map { |pattern| Dir.glob(File.join(base_path, pattern)) }
41
+ excluded_files = exclude_patterns.flat_map { |pattern| Dir.glob(File.join(base_path, pattern)) }
42
+
43
+ (included_files - excluded_files).select { |f| File.file?(f) }
44
+ end
45
+
46
+ # Get formatting configuration
47
+ def formatting_config
48
+ @config['formatting']
49
+ end
50
+
51
+ private
52
+
53
+ def load_configuration(options)
54
+ config = deep_dup(DEFAULT_CONFIG)
55
+
56
+ # Load from file if specified
57
+ if (file = options[:file] || options['file'])
58
+ file_config = YAML.load_file(file)
59
+ config = deep_merge(config, file_config)
60
+ end
61
+
62
+ # Override with options
63
+ options.delete(:file)
64
+ options.delete('file')
65
+ config = deep_merge(config, options) unless options.empty?
66
+
67
+ validate_config!(config)
68
+ config
69
+ end
70
+
71
+ def validate_config!(config)
72
+ line_length = config.dig('formatting', 'line_length')
73
+ raise ConfigError, 'line_length must be positive' if line_length && line_length <= 0
74
+
75
+ indent_width = config.dig('formatting', 'indent_width')
76
+ raise ConfigError, 'indent_width must be positive' if indent_width && indent_width <= 0
77
+ end
78
+
79
+ def deep_merge(hash1, hash2)
80
+ hash1.merge(hash2) do |_key, old_val, new_val|
81
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
82
+ deep_merge(old_val, new_val)
83
+ else
84
+ new_val
85
+ end
86
+ end
87
+ end
88
+
89
+ def deep_dup(hash)
90
+ hash.transform_values do |value|
91
+ value.is_a?(Hash) ? deep_dup(value) : value.dup
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+ require 'json'
5
+ require_relative 'prism_node_extractor'
6
+
7
+ module Rfmt
8
+ # PrismBridge provides the Ruby-side integration with the Prism parser
9
+ # It parses Ruby source code and converts the AST to a JSON format
10
+ # that can be consumed by the Rust formatter
11
+ class PrismBridge
12
+ extend PrismNodeExtractor
13
+
14
+ class ParseError < StandardError; end
15
+
16
+ # Parse Ruby source code and return serialized AST
17
+ # @param source [String] Ruby source code to parse
18
+ # @return [String] JSON-serialized AST with comments
19
+ # @raise [ParseError] if parsing fails
20
+ def self.parse(source)
21
+ result = Prism.parse(source)
22
+
23
+ handle_parse_errors(result) if result.failure?
24
+
25
+ serialize_ast_with_comments(result)
26
+ end
27
+
28
+ # Parse Ruby source code from a file
29
+ # @param file_path [String] Path to Ruby file
30
+ # @return [String] JSON-serialized AST
31
+ # @raise [ParseError] if parsing fails
32
+ # @raise [Errno::ENOENT] if file doesn't exist
33
+ def self.parse_file(file_path)
34
+ source = File.read(file_path)
35
+ parse(source)
36
+ rescue Errno::ENOENT
37
+ raise ParseError, "File not found: #{file_path}"
38
+ end
39
+
40
+ # Handle parsing errors from Prism
41
+ def self.handle_parse_errors(result)
42
+ errors = result.errors.map do |error|
43
+ {
44
+ line: error.location.start_line,
45
+ column: error.location.start_column,
46
+ message: error.message
47
+ }
48
+ end
49
+
50
+ error_messages = errors.map do |err|
51
+ "#{err[:line]}:#{err[:column]}: #{err[:message]}"
52
+ end.join("\n")
53
+
54
+ raise ParseError, "Parse errors:\n#{error_messages}"
55
+ end
56
+
57
+ # Serialize the Prism AST to JSON
58
+ def self.serialize_ast(node)
59
+ JSON.generate(convert_node(node))
60
+ end
61
+
62
+ # Serialize the Prism AST with comments to JSON
63
+ def self.serialize_ast_with_comments(result)
64
+ comments = result.comments.map do |comment|
65
+ {
66
+ comment_type: comment.class.name.split('::').last.downcase.gsub('comment', ''),
67
+ location: {
68
+ start_line: comment.location.start_line,
69
+ start_column: comment.location.start_column,
70
+ end_line: comment.location.end_line,
71
+ end_column: comment.location.end_column,
72
+ start_offset: comment.location.start_offset,
73
+ end_offset: comment.location.end_offset
74
+ },
75
+ text: comment.location.slice,
76
+ position: 'leading' # Default position, will be refined by Rust
77
+ }
78
+ end
79
+
80
+ JSON.generate({
81
+ ast: convert_node(result.value),
82
+ comments: comments
83
+ })
84
+ end
85
+
86
+ # Convert a Prism node to our internal representation
87
+ def self.convert_node(node)
88
+ return nil if node.nil?
89
+
90
+ {
91
+ node_type: node_type_name(node),
92
+ location: extract_location(node),
93
+ children: extract_children(node),
94
+ metadata: extract_metadata(node),
95
+ comments: extract_comments(node),
96
+ formatting: extract_formatting(node)
97
+ }
98
+ end
99
+
100
+ # Get the node type name from Prism node
101
+ def self.node_type_name(node)
102
+ # Prism node class names are like "Prism::ProgramNode"
103
+ # We want just "program_node" in snake_case
104
+ node.class.name.split('::').last.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
105
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
106
+ end
107
+
108
+ # Extract location information from node
109
+ def self.extract_location(node)
110
+ loc = node.location
111
+ {
112
+ start_line: loc.start_line,
113
+ start_column: loc.start_column,
114
+ end_line: loc.end_line,
115
+ end_column: loc.end_column,
116
+ start_offset: loc.start_offset,
117
+ end_offset: loc.end_offset
118
+ }
119
+ end
120
+
121
+ # Extract child nodes
122
+ def self.extract_children(node)
123
+ children = []
124
+
125
+ begin
126
+ # Different node types have different child accessors
127
+ children = case node
128
+ when Prism::ProgramNode
129
+ node.statements ? node.statements.body : []
130
+ when Prism::StatementsNode
131
+ node.body || []
132
+ when Prism::ClassNode
133
+ [
134
+ node.constant_path,
135
+ node.superclass,
136
+ node.body
137
+ ].compact
138
+ when Prism::ModuleNode
139
+ [
140
+ node.constant_path,
141
+ node.body
142
+ ].compact
143
+ when Prism::DefNode
144
+ params = if node.parameters
145
+ node.parameters.child_nodes.compact
146
+ else
147
+ []
148
+ end
149
+ params + [node.body].compact
150
+ when Prism::CallNode
151
+ result = []
152
+ result << node.receiver if node.receiver
153
+ result.concat(node.arguments.child_nodes.compact) if node.arguments
154
+ result << node.block if node.block
155
+ result
156
+ when Prism::IfNode, Prism::UnlessNode
157
+ [
158
+ node.predicate,
159
+ node.statements,
160
+ node.consequent
161
+ ].compact
162
+ when Prism::ArrayNode
163
+ node.elements || []
164
+ when Prism::HashNode
165
+ node.elements || []
166
+ when Prism::BlockNode
167
+ params = if node.parameters
168
+ node.parameters.child_nodes.compact
169
+ else
170
+ []
171
+ end
172
+ params + [node.body].compact
173
+ else
174
+ # For unknown types, try to get child nodes if they exist
175
+ []
176
+ end
177
+ rescue StandardError => e
178
+ # Log warning in debug mode but continue processing
179
+ warn "Warning: Failed to extract children from #{node.class}: #{e.message}" if $DEBUG
180
+ children = []
181
+ end
182
+
183
+ children.compact.map { |child| convert_node(child) }
184
+ end
185
+
186
+ # Extract metadata specific to node type
187
+ def self.extract_metadata(node)
188
+ metadata = {}
189
+
190
+ case node
191
+ when Prism::ClassNode
192
+ if (name = extract_node_name(node))
193
+ metadata['name'] = name
194
+ end
195
+ if (superclass = extract_superclass_name(node))
196
+ metadata['superclass'] = superclass
197
+ end
198
+ when Prism::ModuleNode
199
+ if (name = extract_node_name(node))
200
+ metadata['name'] = name
201
+ end
202
+ when Prism::DefNode
203
+ if (name = extract_node_name(node))
204
+ metadata['name'] = name
205
+ end
206
+ metadata['parameters_count'] = extract_parameter_count(node).to_s
207
+ when Prism::CallNode
208
+ if (name = extract_node_name(node))
209
+ metadata['name'] = name
210
+ end
211
+ if (message = extract_message_name(node))
212
+ metadata['message'] = message
213
+ end
214
+ when Prism::StringNode
215
+ if (content = extract_string_content(node))
216
+ metadata['content'] = content
217
+ end
218
+ when Prism::IntegerNode
219
+ if (value = extract_literal_value(node))
220
+ metadata['value'] = value
221
+ end
222
+ when Prism::FloatNode
223
+ if (value = extract_literal_value(node))
224
+ metadata['value'] = value
225
+ end
226
+ when Prism::SymbolNode
227
+ if (value = extract_literal_value(node))
228
+ metadata['value'] = value
229
+ end
230
+ end
231
+
232
+ metadata
233
+ end
234
+
235
+ # Extract comments associated with the node
236
+ def self.extract_comments(_node)
237
+ # Prism attaches comments to the parse result, not individual nodes
238
+ # For Phase 1, we'll return empty array and implement in Phase 2
239
+ []
240
+ end
241
+
242
+ # Extract formatting information
243
+ def self.extract_formatting(node)
244
+ loc = node.location
245
+ {
246
+ indent_level: 0, # Will be calculated during formatting
247
+ needs_blank_line_before: false,
248
+ needs_blank_line_after: false,
249
+ preserve_newlines: false,
250
+ multiline: loc.start_line != loc.end_line,
251
+ original_formatting: nil # Can store original text if needed
252
+ }
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rfmt
4
+ # PrismNodeExtractor provides safe methods to extract information from Prism nodes
5
+ # This module encapsulates the logic for accessing Prism node properties,
6
+ # making the code resilient to Prism API changes
7
+ module PrismNodeExtractor
8
+ # Extract the name from a node
9
+ # @param node [Prism::Node] The node to extract name from
10
+ # @return [String, nil] The node name or nil if not available
11
+ def extract_node_name(node)
12
+ return nil unless node.respond_to?(:name)
13
+
14
+ node.name.to_s
15
+ end
16
+
17
+ # Extract superclass name from a class node
18
+ # @param class_node [Prism::ClassNode] The class node
19
+ # @return [String, nil] The superclass name or nil if not available
20
+ def extract_superclass_name(class_node)
21
+ return nil unless class_node.respond_to?(:superclass)
22
+
23
+ sc = class_node.superclass
24
+ return nil if sc.nil?
25
+
26
+ case sc
27
+ when Prism::ConstantReadNode
28
+ sc.name.to_s
29
+ when Prism::ConstantPathNode
30
+ # Try full_name first, fall back to name
31
+ if sc.respond_to?(:full_name)
32
+ sc.full_name.to_s
33
+ elsif sc.respond_to?(:name)
34
+ sc.name.to_s
35
+ else
36
+ sc.to_s
37
+ end
38
+ else
39
+ sc.to_s
40
+ end
41
+ end
42
+
43
+ # Extract parameter count from a method definition node
44
+ # @param def_node [Prism::DefNode] The method definition node
45
+ # @return [Integer] The number of parameters (0 if none)
46
+ def extract_parameter_count(def_node)
47
+ return 0 unless def_node.respond_to?(:parameters)
48
+ return 0 if def_node.parameters.nil?
49
+ return 0 unless def_node.parameters.respond_to?(:child_nodes)
50
+
51
+ def_node.parameters.child_nodes.compact.length
52
+ end
53
+
54
+ # Extract message name from a call node
55
+ # @param call_node [Prism::CallNode] The call node
56
+ # @return [String, nil] The message name or nil if not available
57
+ def extract_message_name(call_node)
58
+ return nil unless call_node.respond_to?(:message)
59
+
60
+ call_node.message.to_s
61
+ end
62
+
63
+ # Extract content from a string node
64
+ # @param string_node [Prism::StringNode] The string node
65
+ # @return [String, nil] The string content or nil if not available
66
+ def extract_string_content(string_node)
67
+ return nil unless string_node.respond_to?(:content)
68
+
69
+ string_node.content
70
+ end
71
+
72
+ # Extract value from a literal node (Integer, Float, Symbol)
73
+ # @param node [Prism::Node] The literal node
74
+ # @return [String, nil] The value as string or nil if not available
75
+ def extract_literal_value(node)
76
+ return nil unless node.respond_to?(:value)
77
+
78
+ node.value.to_s
79
+ end
80
+ end
81
+ end