semdiff 0.1.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7bb06af96fb6fc68ab1d7af274dee105769b871eed5fcae927a5525a22ec5007
4
+ data.tar.gz: c32ed6ac3e2ceb10d2be082cec86d0313cc3cf4d0ed11214174f92e855227d0f
5
+ SHA512:
6
+ metadata.gz: c26a50d430f514e80682b9b8b848010f56775d19cf81f47a0c661d344ec670b59781a092244683f8b9e0ba0d779d0869c4705d977108a452a504a91c188d5f47
7
+ data.tar.gz: a7ac9c34728f1805a2b5284ca20def2f85b8a531386ca96c766bc4d5483a6ec077250c8310a074bd2b6cce342eb04fc5625a5a813cb7cec73fcab74ec8425959
data/.editorconfig ADDED
@@ -0,0 +1,8 @@
1
+ root = true
2
+
3
+ [*]
4
+ end_of_line = lf
5
+ insert_final_newline = true
6
+ charset = utf-8
7
+ indent_style = space
8
+ indent_size = 2
data/.rubocop.yml ADDED
@@ -0,0 +1,28 @@
1
+ inherit_from: .rubocop_todo.yml
2
+
3
+ Metrics/AbcSize:
4
+ Enabled: false
5
+
6
+ Metrics/CyclomaticComplexity:
7
+ Enabled: false
8
+
9
+ Metrics/PerceivedComplexity:
10
+ Enabled: false
11
+
12
+ Metrics/BlockLength:
13
+ Enabled: false
14
+
15
+ Metrics/ClassLength:
16
+ Enabled: false
17
+
18
+ Metrics/MethodLength:
19
+ Enabled: false
20
+
21
+ Metrics/ModuleLength:
22
+ Enabled: false
23
+
24
+ AllCops:
25
+ NewCops: enable
26
+ Exclude:
27
+ - 'test/assets/yard/*'
28
+ - 'vendor/**/*'
data/.rubocop_todo.yml ADDED
@@ -0,0 +1,41 @@
1
+ # This configuration was generated by
2
+ # `rubocop --auto-gen-config --exclude-limit 30`
3
+ # on 2025-06-12 08:42:10 UTC using RuboCop version 1.76.1.
4
+ # The point is for the user to remove these configuration records
5
+ # one by one as the offenses are removed from the code base.
6
+ # Note that changes in the inspected code, or installation of new
7
+ # versions of RuboCop, may require this file to be generated again.
8
+
9
+ # Offense count: 1
10
+ # This cop supports safe autocorrection (--autocorrect).
11
+ # Configuration parameters: Severity, Include.
12
+ # Include: **/*.gemspec
13
+ Gemspec/RequireMFA:
14
+ Exclude:
15
+ - 'semdiff.gemspec'
16
+
17
+ # Offense count: 2
18
+ # This cop supports safe autocorrection (--autocorrect).
19
+ # Configuration parameters: AutoCorrect, AllowUnusedKeywordArguments, IgnoreEmptyMethods, IgnoreNotImplementedMethods, NotImplementedExceptions.
20
+ # NotImplementedExceptions: NotImplementedError
21
+ Lint/UnusedMethodArgument:
22
+ Exclude:
23
+ - 'lib/semdiff/cli/cli.rb'
24
+
25
+ # Offense count: 4
26
+ # Configuration parameters: AllowedConstants.
27
+ Style/Documentation:
28
+ Exclude:
29
+ - 'spec/**/*'
30
+ - 'test/**/*'
31
+ - 'lib/semdiff.rb'
32
+ - 'lib/semdiff/cli/cli.rb'
33
+ - 'lib/semdiff/utils/compiler_utils.rb'
34
+ - 'lib/semdiff/utils/io_utils.rb'
35
+
36
+ # Offense count: 3
37
+ # This cop supports safe autocorrection (--autocorrect).
38
+ # Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings.
39
+ # URISchemes: http, https
40
+ Layout/LineLength:
41
+ Max: 136
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.1.2
data/Gemfile ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ # Specify your gem's dependencies in semdiff.gemspec
6
+ gemspec
7
+
8
+ group :developement, :test do
9
+ gem 'rake', '~> 13.0'
10
+
11
+ gem 'minitest', '~> 5.0'
12
+
13
+ gem 'rubocop'
14
+
15
+ gem 'bundler-audit'
16
+ end
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Tesorion
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # Semdiff
2
+
3
+ Semantic differences for Ruby code changes.
4
+
5
+ Transforms Prism AST nodes to canonical forms whenever possible, for example: constant folding and reordering operands in expressions based on type signatures (YARD or RBS).
6
+
7
+ Uses GumTree/difftastic to generate the diff on canonical ASTs.
8
+
9
+ ## Installation
10
+
11
+ TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
12
+
13
+ Install the gem and add to the application's Gemfile by executing:
14
+
15
+ ```bash
16
+ bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
17
+ ```
18
+
19
+ If bundler is not being used to manage dependencies, install the gem by executing:
20
+
21
+ ```bash
22
+ gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
23
+ ```
24
+
25
+ ## Usage
26
+ Invoke with 2 target files and the `semdiff` executable, for example:
27
+ ```
28
+ semdiff --yard --gumtree --diff-original test/assets/yard/before.rb test/assets/yard/after.rb
29
+ ```
30
+ All options:
31
+ ```
32
+ Usage: semdiff [options] BEFORE.rb AFTER.rb
33
+
34
+ Specific options:
35
+ -h, --help Prints this help
36
+ -o, --output-directory TARGET Directory to output normalized unparsed files (default temporary)
37
+ --ignore-comments Don't process and preserve comments in the unparsed files
38
+ --check-only Report whether there are any changes, but don't calculate them (much faster).
39
+ --diff-original Show a diff of the original files above the normalized files
40
+ --yard [TARGET] Use YARD (optional existing directory, default reparses input files)
41
+ --yard-files FILE1,FILE2,... Reparse specific YARD files (choose --yard or this, not both)
42
+ --rbs [TARGET] Use RBS signatures (default ./sig/)
43
+ --gumtree Use gumtree webdiff after default difftastic
44
+ --skip-difftastic Skip difftastic text diff
45
+ ```
46
+
47
+ ## Development
48
+
49
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
50
+
51
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
52
+
53
+ ## Contributing
54
+
55
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/semdiff.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rake/testtask'
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << 'test'
8
+ t.libs << 'lib'
9
+ t.test_files = FileList['test/**/test_*.rb']
10
+ end
11
+
12
+ task default: :test
data/bin/semdiff ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'semdiff'
5
+
6
+ Semdiff::CLI.new.parse(ARGV)
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Semdiff
4
+ # AlgebraCompiler is a compiler that simplifies expressions with algebraic
5
+ # relations based on type information.
6
+ #
7
+ # @example
8
+ # ```ruby
9
+ # # Expression Canonical Form
10
+ # b + a a + b # commutativity
11
+ # c * a a * c # commutativity
12
+ # ( a + b ) + c a + b + c # associativity
13
+ # ( x * b ) + ( x * c ) x * ( b + c ) # distributivity
14
+ # x - ( - y ) x + y # negation
15
+ # ( x ** m ) * ( x ** n ) x **( m + n ) # exponentials
16
+ # ( a - b ) - b a - 2* b # optimization
17
+ # ```
18
+ class AlgebraCompiler < ::Prism::MutationCompiler
19
+ include Prism::DSL
20
+ include CompilerUtils
21
+
22
+ COMMUTATIVE_OPS = %i[+ *].freeze
23
+ ASSOCIATIVE_OPS = %i[+ *].freeze
24
+
25
+ def initialize(node_types)
26
+ @node_types = node_types
27
+ super()
28
+ end
29
+
30
+ def visit_call_node(node)
31
+ receiver = visit(node.receiver)
32
+ arguments = visit(node.arguments)
33
+ block = visit(node.block)
34
+ result = nil
35
+
36
+ if COMMUTATIVE_OPS.include?(node.name) &&
37
+ receiver &&
38
+ arguments&.arguments&.size == 1 &&
39
+ block.nil?
40
+ if ASSOCIATIVE_OPS.include?(node.name)
41
+ operands = flatten_associative_operation(node.name, receiver, arguments.arguments.first)
42
+ if operands.all? { |op| numeric?(op) }
43
+ sorted_operands = operands.sort_by { |op| sort_key(op) }
44
+ result = rebuild_associative_operation(node, node.name, sorted_operands)
45
+ end
46
+ else
47
+ lhs = receiver
48
+ rhs = arguments.arguments.first
49
+ if numeric?(lhs) && numeric?(rhs) && should_swap?(lhs, rhs)
50
+ result = node.copy(
51
+ receiver: rhs,
52
+ arguments: arguments_node(
53
+ node_id: arguments.node_id,
54
+ source: arguments.send(:source),
55
+ location: arguments.location,
56
+ flags: arguments.send(:flags),
57
+ arguments: [lhs]
58
+ )
59
+ )
60
+ end
61
+ end
62
+ end
63
+
64
+ if result
65
+ CompilerUtils.inherit_newline(node, result)
66
+ else
67
+ node.copy(receiver: receiver, arguments: arguments, block: block)
68
+ end
69
+ end
70
+
71
+ def visit_parentheses_node(node)
72
+ if node.multiple_statements?
73
+ statements = visit_all(node.body.body)
74
+ body = node.body.copy(body: statements)
75
+ node.copy(body: body)
76
+ else
77
+ inner = visit(node.body.body.first)
78
+ if inner.type == :call_node &&
79
+ ASSOCIATIVE_OPS.include?(inner.name) &&
80
+ inner.arguments&.arguments&.size == 1 &&
81
+ numeric?(inner.receiver) &&
82
+ numeric?(inner.arguments.arguments.first)
83
+ inner
84
+ else
85
+ body = node.body.copy(body: [inner])
86
+ node.copy(body: body)
87
+ end
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ def numeric?(node)
94
+ case node.type
95
+ when :integer_node, :float_node
96
+ true
97
+ else
98
+ types = @node_types[node.node_id]
99
+ types&.anybits?(NodeTypeFlags::NUMERIC)
100
+ end
101
+ end
102
+
103
+ def should_swap?(lhs, rhs)
104
+ sort_key(lhs) > sort_key(rhs)
105
+ end
106
+
107
+ def sort_key(node)
108
+ case node.type
109
+ when :local_variable_read_node, :instance_variable_read_node,
110
+ :class_variable_read_node, :global_variable_read_node,
111
+ :constant_read_node, :call_node
112
+ node.name.to_s
113
+ when :integer_node, :float_node
114
+ "_#{node.value}"
115
+ else
116
+ "~#{node.type}"
117
+ end
118
+ end
119
+
120
+ def flatten_associative_operation(operation, left, right)
121
+ operands = []
122
+
123
+ left = unwrap_parentheses(left)
124
+ if left.type == :call_node &&
125
+ left.name == operation &&
126
+ left.arguments&.arguments&.size == 1 &&
127
+ left.block.nil?
128
+ operands.concat(flatten_associative_operation(operation, left.receiver, left.arguments.arguments.first))
129
+ else
130
+ operands << left
131
+ end
132
+
133
+ right = unwrap_parentheses(right)
134
+ if right.type == :call_node &&
135
+ right.name == operation &&
136
+ right.arguments&.arguments&.size == 1 &&
137
+ right.block.nil?
138
+ operands.concat(flatten_associative_operation(operation, right.receiver, right.arguments.arguments.first))
139
+ else
140
+ operands << right
141
+ end
142
+
143
+ operands
144
+ end
145
+
146
+ def unwrap_parentheses(node)
147
+ node = node.body.body.first while node.type == :parentheses_node && !node.multiple_statements?
148
+ node
149
+ end
150
+
151
+ def rebuild_associative_operation(original_node, operation, operands)
152
+ result = operands.first
153
+
154
+ operands[1..].each do |operand|
155
+ result = call_node(
156
+ node_id: original_node.node_id,
157
+ source: original_node.send(:source),
158
+ location: original_node.location,
159
+ flags: 0,
160
+ receiver: result,
161
+ call_operator_loc: nil,
162
+ name: operation,
163
+ message_loc: original_node.message_loc,
164
+ opening_loc: nil,
165
+ arguments: arguments_node(
166
+ node_id: original_node.arguments.node_id,
167
+ source: original_node.arguments.send(:source),
168
+ location: original_node.arguments.location,
169
+ flags: original_node.arguments.send(:flags),
170
+ arguments: [operand]
171
+ ),
172
+ closing_loc: nil,
173
+ block: nil
174
+ )
175
+ end
176
+
177
+ result
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Semdiff
4
+ # AliasingCompiler is a compiler that unifies method aliases
5
+ # according to the Ruby style guide (https://rubystyle.guide/)
6
+ # based on type information.
7
+ #
8
+ # @example
9
+ # ```ruby
10
+ # # Original        Canonical Form
11
+ # [].collect        [].map
12
+ # {}.detect         {}.find
13
+ # (1..5).find_all   (1..5).select
14
+ # [1, 2].inject(:+) [1, 2].reduce(:+)
15
+ # [1].member?(1)   [1].include?(1)
16
+ # "abc".length     "abc".size
17
+ # [1,2,3].length   [1,2,3].size
18
+ # ```
19
+ class AliasingCompiler < ::Prism::MutationCompiler
20
+ include Prism::DSL
21
+ include CompilerUtils
22
+
23
+ # NOTE: `Enumerables` (and others for `length`/`size`) typically
24
+ # respond to both the key and value method names in this map.
25
+ # We therefore assume that any Ruby object does (and should)
26
+ # have both methods for every pair, if they have at least one.
27
+ # This greatly simplifies the process of accounting for every
28
+ # (standard library) object and also allows us to map custom
29
+ # subclass (e.g., `Foo < Array`) methods automatically.
30
+ UNTYPED_ALIASES = {
31
+ collect: :map,
32
+ detect: :find,
33
+ find_all: :select,
34
+ inject: :reduce,
35
+ member?: :include?,
36
+ length: :size
37
+ }.freeze
38
+
39
+ def visit_call_node(node)
40
+ receiver = visit(node.receiver)
41
+ arguments = visit(node.arguments)
42
+ block = visit(node.block)
43
+ result = nil
44
+
45
+ if UNTYPED_ALIASES.key?(node.name)
46
+ canonical_name = UNTYPED_ALIASES[node.name]
47
+ # It might be worth updating the message_loc to reflect
48
+ # the new size. However, the underlying message buffer
49
+ # would still point to the original name.
50
+ result = node.copy(
51
+ name: canonical_name,
52
+ receiver: receiver,
53
+ arguments: arguments,
54
+ block: block
55
+ )
56
+ end
57
+
58
+ if result
59
+ CompilerUtils.inherit_newline(node, result)
60
+ else
61
+ node.copy(receiver: receiver, arguments: arguments, block: block)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Semdiff
4
+ class CLI
5
+ include IOUtils
6
+
7
+ def initialize
8
+ @options = {}
9
+ end
10
+
11
+ def parse(args)
12
+ require 'optionparser'
13
+
14
+ parser = OptionParser.new do |opts|
15
+ opts.banner = 'Usage: semdiff [options] BEFORE.rb AFTER.rb'
16
+ opts.separator ''
17
+ opts.separator 'Specific options:'
18
+
19
+ opts.on('-h', '--help', 'Prints this help') do
20
+ puts opts
21
+ exit 0
22
+ end
23
+
24
+ opts.on('-o TARGET', '--output-directory', 'Directory to output normalized unparsed files (default temporary)', String) do |dir|
25
+ @options[:output_directory] = dir
26
+ end
27
+
28
+ opts.on('--ignore-comments', "Don't process and preserve comments in the unparsed files") do |p|
29
+ @options[:ignore_comments] = p
30
+ end
31
+
32
+ opts.on('--check-only', "Report whether there are any changes, but don't calculate them (much faster).") do |c|
33
+ @options[:check_only] = c
34
+ end
35
+
36
+ opts.on('--diff-original', 'Show a diff of the original files above the normalized files') do |d|
37
+ @options[:diff_original] = d
38
+ end
39
+
40
+ opts.on('--yard [TARGET]', 'Use YARD (optional existing directory, default reparses input files)') do |target|
41
+ @options[:annotation_type] = :yard
42
+ @options[:annotation_target] = target
43
+ @options[:override] = true if target.nil?
44
+ end
45
+
46
+ opts.on('--yard-files FILE1,FILE2,...', 'Reparse specific YARD files (choose --yard or this, not both)', Array) do |files|
47
+ @options[:annotation_type] = :yard
48
+ @options[:annotation_target] = files
49
+ end
50
+
51
+ opts.on('--rbs [TARGET]', 'Use RBS signatures (default ./sig/)') do |target|
52
+ @options[:annotation_type] = :rbs
53
+ @options[:annotation_target] = target || 'sig'
54
+ end
55
+
56
+ opts.on('--gumtree', 'Use gumtree webdiff after default difftastic') do |g|
57
+ @options[:gumtree] = g
58
+ end
59
+
60
+ opts.on('--skip-difftastic', 'Skip difftastic text diff') do |d|
61
+ @options[:skip_difftastic] = d
62
+ end
63
+ end
64
+
65
+ begin
66
+ files = parser.parse!(args)
67
+ raise ArgumentError, "Expected 2 files but received #{files.size}" if files.size != 2
68
+
69
+ types = case @options[:annotation_type]
70
+ when :yard
71
+ ::Typeguard::TypeModel::Builder.yard
72
+ @options[:annotation_target] ||= files
73
+ ::Typeguard::TypeModel::Builder::IMPLEMENTATION.new(
74
+ @options[:annotation_target],
75
+ @options[:annotation_target].is_a?(Array)
76
+ ).build
77
+ when :rbs
78
+ ::Typeguard::TypeModel::Builder.rbs
79
+ ::Typeguard::TypeModel::Builder::IMPLEMENTATION.new(
80
+ @options[:annotation_target],
81
+ false
82
+ ).build
83
+ end
84
+
85
+ processed_asts = files.map do |file|
86
+ contents = File.read(file)
87
+ original_result = Prism.parse(contents)
88
+ ast = original_result.value
89
+ ast = ast.accept(Prism::DesugarCompiler.new)
90
+ ast = ast.accept(AliasingCompiler.new)
91
+ ast = ast.accept(StructuresCompiler.new)
92
+ node_types = TypeVisitor.new(types).visit(ast) unless types.nil?
93
+ ast = ast.accept(ConstantsCompiler.new)
94
+ ast = ast.accept(AlgebraCompiler.new(node_types)) unless types.nil?
95
+ ast = ast.accept(IdentityCompiler.new(node_types)) unless types.nil?
96
+ ast = ast.accept(ConstantsCompiler.new)
97
+
98
+ # NOTE: Uses the translation parser builder to
99
+ # translate the existing AST to whitequark/parser,
100
+ # so that we can rewrite with unparser and feed the
101
+ # files to difftastic/GumTree.
102
+ translator = Prism::Translation::Parser.new(
103
+ parser: Struct.new(:prism_ast, :original_result) do
104
+ def parse(source, **options)
105
+ Struct.new(:value, :comments, :magic_comments, :data_loc, :errors, :warnings, :source).new(
106
+ prism_ast,
107
+ original_result.comments,
108
+ original_result.magic_comments,
109
+ original_result.data_loc,
110
+ original_result.errors,
111
+ original_result.warnings,
112
+ original_result.source
113
+ )
114
+ end
115
+ end.new(ast, original_result)
116
+ )
117
+ source_buffer = Parser::Source::Buffer.new(file)
118
+ source_buffer.source = contents
119
+ translator.send(@options[:ignore_comments] ? 'parse' : 'parse_with_comments', source_buffer)
120
+ end
121
+
122
+ unparsed = processed_asts.map do |r|
123
+ r.is_a?(Array) ? Unparser.unparse(r.first, comments: r.last) : Unparser.unparse(r)
124
+ end
125
+ file_names = files.map { |f| File.basename(f) }
126
+ if !@options[:skip_difftastic] && @options[:diff_original]
127
+ system <<~CMD
128
+ difft #{files.first} #{files.last} \
129
+ #{'--check-only --exit-code' if @options[:check_only]} \
130
+ #{'--ignore-comments' if @options[:ignore_comments]}
131
+ CMD
132
+ end
133
+ return if @options[:skip_difftastic] && !@options[:gumtree]
134
+
135
+ with_diff_files(unparsed, file_names, output_directory: @options[:output_directory]) do |b, a|
136
+ unless @options[:skip_difftastic]
137
+ system <<~CMD
138
+ difft #{b.path} #{a.path} \
139
+ #{'--check-only --exit-code' if @options[:check_only]} \
140
+ #{'--ignore-comments' if @options[:ignore_comments]}
141
+ CMD
142
+ end
143
+ if @options[:gumtree]
144
+ system <<~CMD
145
+ gumtree webdiff #{b.path} #{a.path} \
146
+ -g ruby-treesitter-ng
147
+ CMD
148
+ end
149
+ end
150
+ rescue OptionParser::InvalidOption => e
151
+ puts e.message
152
+ puts parser
153
+ puts "Invalid argument (use semdiff --help): #{e}"
154
+ exit 1
155
+ end
156
+
157
+ @options
158
+ end
159
+ end
160
+ end