annotaterb 4.5.0 → 4.6.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ab5413ba1fb4c02274c37e7629b0553deed339067624ef96e1ad9a59ecf726d1
4
- data.tar.gz: 6439540d1c152651735313e80839acddc79d202d8b0171edb6c424e932dde37a
3
+ metadata.gz: 2c9e326fa73164a6a2e43edc402e6990cfbb5d953ac5fd1ccf8c71fa6d358ee1
4
+ data.tar.gz: dabf1afd54a8dec7a96ed8c259ca84e0a6085618713be78f43f075a51a22db7f
5
5
  SHA512:
6
- metadata.gz: 40353034b577b5a44a1204edbb02e25d01b4d99efcb8de1a50fd8698981767c4f5444079b851441db60d16c175237e63f80453370f38007613aae1da5b613f71
7
- data.tar.gz: 5edb0a811149b9187fd0b2066cf1843a84fde281239e2628ba643fa1ce4c906e914b387d4f461e62c84d5323e21a51223f3d5e838f5f4da300743180d6dabb8e
6
+ metadata.gz: 12e277c2d09e37e1001e64b0f08542ff03981bf5bbe37e86ed3dca01b1ec52c5b1805ce6f5cb1a8e5b25c3b9d655cb2345d0c9d878ecf4ae6bbfd265870445ac
7
+ data.tar.gz: 88d7c9ba17c3abbf38e635365314d1c4b372e67ec5ef0dcba9f7f4f5b6ef90d0e285a2150887127fa75f847a1ee2e8e4c7a30548729a4f903cf71f42be793177
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## [v4.5.0](https://github.com/drwl/annotaterb/tree/v4.5.0) (2024-02-08)
4
+
5
+ [Full Changelog](https://github.com/drwl/annotaterb/compare/v4.4.1...v4.5.0)
6
+
7
+ **Closed issues:**
8
+
9
+ - Add an automated way to migrate from the old annotate gem [\#73](https://github.com/drwl/annotaterb/issues/73)
10
+ - Default array value is double-quoted/escaped [\#57](https://github.com/drwl/annotaterb/issues/57)
11
+
12
+ **Merged pull requests:**
13
+
14
+ - Bump version to v4.5.0 [\#79](https://github.com/drwl/annotaterb/pull/79) ([drwl](https://github.com/drwl))
15
+ - Update README on the new Rails generator commands [\#78](https://github.com/drwl/annotaterb/pull/78) ([drwl](https://github.com/drwl))
16
+ - Bump github/codeql-action from 2 to 3 [\#77](https://github.com/drwl/annotaterb/pull/77) ([dependabot[bot]](https://github.com/apps/dependabot))
17
+ - Add command to generate a configuration file [\#76](https://github.com/drwl/annotaterb/pull/76) ([drwl](https://github.com/drwl))
18
+ - Bump actions/checkout from 3 to 4 [\#75](https://github.com/drwl/annotaterb/pull/75) ([dependabot[bot]](https://github.com/apps/dependabot))
19
+ - CI: Configure dependabot to update GH Actions [\#74](https://github.com/drwl/annotaterb/pull/74) ([olleolleolle](https://github.com/olleolleolle))
20
+ - Refactor `FileBuilder` and `MagicCommentParser` [\#71](https://github.com/drwl/annotaterb/pull/71) ([drwl](https://github.com/drwl))
21
+ - Test running annotations after a migration [\#70](https://github.com/drwl/annotaterb/pull/70) ([drwl](https://github.com/drwl))
22
+ - Add integration test for rake task installer [\#69](https://github.com/drwl/annotaterb/pull/69) ([drwl](https://github.com/drwl))
23
+ - Add integration test for annotating routes [\#68](https://github.com/drwl/annotaterb/pull/68) ([drwl](https://github.com/drwl))
24
+ - Remove optional args [\#67](https://github.com/drwl/annotaterb/pull/67) ([drwl](https://github.com/drwl))
25
+ - Remove optional arg from `AnnotationPatternGenerator` [\#66](https://github.com/drwl/annotaterb/pull/66) ([drwl](https://github.com/drwl))
26
+ - Remove `ARGV` use during runtime [\#65](https://github.com/drwl/annotaterb/pull/65) ([drwl](https://github.com/drwl))
27
+ - Add integration test for annotating a singular file [\#64](https://github.com/drwl/annotaterb/pull/64) ([drwl](https://github.com/drwl))
28
+ - Generate changelog for v4.4.1 [\#63](https://github.com/drwl/annotaterb/pull/63) ([drwl](https://github.com/drwl))
29
+ - Add support for factory\_bot's default suffixed pattern [\#59](https://github.com/drwl/annotaterb/pull/59) ([drwl](https://github.com/drwl))
30
+
3
31
  ## [v4.4.1](https://github.com/drwl/annotaterb/tree/v4.4.1) (2023-09-11)
4
32
 
5
33
  [Full Changelog](https://github.com/drwl/annotaterb/compare/v4.4.0...v4.4.1)
data/VERSION CHANGED
@@ -1 +1 @@
1
- 4.5.0
1
+ 4.6.0
@@ -8,8 +8,12 @@ module AnnotateRb
8
8
  options[:require].count > 0 && options[:require].each { |path| require path }
9
9
 
10
10
  if defined?(::Rails::Application)
11
- klass = ::Rails::Application.send(:subclasses).first
12
- klass.eager_load!
11
+ if defined?(::Zeitwerk)
12
+ # Delegate to Zeitwerk to load stuff as needed
13
+ else
14
+ klass = ::Rails::Application.send(:subclasses).first
15
+ klass.eager_load!
16
+ end
13
17
  else
14
18
  options[:model_dir].each do |dir|
15
19
  ::Rake::FileList["#{dir}/**/*.rb"].each do |fname|
@@ -5,29 +5,72 @@ module AnnotateRb
5
5
  module AnnotatedFile
6
6
  # Generates the file with fresh annotations
7
7
  class Generator
8
- def initialize(file_components, annotation_position, options)
9
- @file_components = file_components
8
+ def initialize(file_content, new_annotations, annotation_position, options)
10
9
  @annotation_position = annotation_position
11
10
  @options = options
12
11
 
13
- @new_wrapped_annotations = wrapped_content(@file_components.new_annotations)
12
+ @new_wrapped_annotations = wrapped_content(new_annotations)
13
+
14
+ @new_annotations = new_annotations
15
+ @file_content = file_content
16
+
17
+ @parsed_file = FileParser::ParsedFile.new(@file_content, @new_annotations, options).parse
14
18
  end
15
19
 
20
+ # @return [String] Returns the annotated file content to be written back to a file
16
21
  def generate
17
22
  # Need to keep `.to_s` for now since the it can be either a String or Symbol
18
23
  annotation_write_position = @options[@annotation_position].to_s
19
24
 
25
+ # New method: first remove annotations
26
+ content_without_annotations = if @parsed_file.has_annotations?
27
+ @file_content.sub(@parsed_file.annotations_with_whitespace, "")
28
+ elsif @options[:force]
29
+ @file_content.sub(@parsed_file.annotations_with_whitespace, "")
30
+ else
31
+ @file_content
32
+ end
33
+
34
+ # We need to get class start and class end depending on the position
35
+ parsed = FileParser::CustomParser.new(content_without_annotations, "", 0).tap(&:parse)
36
+
20
37
  _content = if %w[after bottom].include?(annotation_write_position)
21
- @file_components.magic_comments + (@file_components.pure_file_content.rstrip + "\n\n" + @new_wrapped_annotations)
22
- elsif @file_components.magic_comments.empty?
23
- @file_components.magic_comments + @new_wrapped_annotations + @file_components.pure_file_content.lstrip
38
+ content_annotated_after(parsed, content_without_annotations)
24
39
  else
25
- @file_components.magic_comments + "\n" + @new_wrapped_annotations + @file_components.pure_file_content.lstrip
40
+ content_annotated_before(parsed, content_without_annotations, annotation_write_position)
26
41
  end
27
42
  end
28
43
 
29
44
  private
30
45
 
46
+ def content_annotated_before(parsed, content_without_annotations, write_position)
47
+ same_write_position = @parsed_file.has_annotations? && @parsed_file.annotation_position.to_s == write_position
48
+
49
+ # Could error if there's no class or module declaration
50
+ _constant_name, line_number_before = parsed.starts.first
51
+
52
+ content_with_annotations_written_before = []
53
+ content_with_annotations_written_before << content_without_annotations.lines[0...line_number_before]
54
+ content_with_annotations_written_before << $/ if @parsed_file.has_leading_whitespace? && same_write_position
55
+ content_with_annotations_written_before << @new_wrapped_annotations.lines
56
+ content_with_annotations_written_before << $/ if @parsed_file.has_trailing_whitespace? && same_write_position
57
+ content_with_annotations_written_before << content_without_annotations.lines[line_number_before..]
58
+
59
+ content_with_annotations_written_before.join
60
+ end
61
+
62
+ def content_annotated_after(parsed, content_without_annotations)
63
+ _constant_name, line_number_after = parsed.ends.last
64
+
65
+ content_with_annotations_written_after = []
66
+ content_with_annotations_written_after << content_without_annotations.lines[0..line_number_after]
67
+ content_with_annotations_written_after << $/
68
+ content_with_annotations_written_after << @new_wrapped_annotations.lines
69
+ content_with_annotations_written_after << content_without_annotations.lines[(line_number_after + 1)..]
70
+
71
+ content_with_annotations_written_after.join
72
+ end
73
+
31
74
  def wrapped_content(content)
32
75
  wrapper_open = if @options[:wrapper_open]
33
76
  "# #{@options[:wrapper_open]}\n"
@@ -5,22 +5,22 @@ module AnnotateRb
5
5
  module AnnotatedFile
6
6
  # Updates existing annotations
7
7
  class Updater
8
- def initialize(file_components, annotation_position, options)
9
- @file_components = file_components
10
- @annotation_position = annotation_position
8
+ def initialize(file_content, new_annotations, _annotation_position, options)
11
9
  @options = options
12
10
 
13
- @new_wrapped_annotations = wrapped_content(@file_components.new_annotations)
11
+ @new_annotations = new_annotations
12
+ @file_content = file_content
13
+
14
+ @parsed_file = FileParser::ParsedFile.new(@file_content, @new_annotations, options).parse
14
15
  end
15
16
 
17
+ # @return [String] Returns the annotated file content to be written back to a file
16
18
  def update
17
- return "" if !@file_components.has_annotations?
18
-
19
- annotation_pattern = AnnotationPatternGenerator.call(@options)
19
+ return "" if !@parsed_file.has_annotations?
20
20
 
21
- new_annotation = @file_components.space_before_annotation + @new_wrapped_annotations + @file_components.space_after_annotation
21
+ new_annotation = wrapped_content(@new_annotations)
22
22
 
23
- _content = @file_components.current_file_content.sub(annotation_pattern, new_annotation)
23
+ _content = @file_content.sub(@parsed_file.annotations, new_annotation)
24
24
  end
25
25
 
26
26
  private
@@ -5,8 +5,13 @@ module AnnotateRb
5
5
  class FileNameResolver
6
6
  class << self
7
7
  def call(filename_template, model_name, table_name)
8
+ # e.g. with a model file name like "app/models/collapsed/example/test_model.rb"
9
+ # and using a collapsed `model_name` such as "collapsed/test_model"
10
+ model_name_without_namespace = model_name.split("/").last
11
+
8
12
  filename_template
9
13
  .gsub("%MODEL_NAME%", model_name)
14
+ .gsub("%MODEL_NAME_WITHOUT_NS%", model_name_without_namespace)
10
15
  .gsub("%PLURALIZED_MODEL_NAME%", model_name.pluralize)
11
16
  .gsub("%TABLE_NAME%", table_name || model_name.pluralize)
12
17
  end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnnotateRb
4
+ module ModelAnnotator
5
+ module FileParser
6
+ class AnnotationFinder
7
+ COMPAT_PREFIX = "== Schema Info"
8
+ COMPAT_PREFIX_MD = "## Schema Info"
9
+ DEFAULT_ANNOTATION_ENDING = "#"
10
+
11
+ class MalformedAnnotation < StandardError; end
12
+
13
+ class NoAnnotationFound < StandardError; end
14
+
15
+ # Returns the line index (not the line number) that the annotation starts.
16
+ attr_reader :annotation_start
17
+ # Returns the line index (not the line number) that the annotation ends, inclusive.
18
+ attr_reader :annotation_end
19
+
20
+ attr_reader :parser
21
+
22
+ def initialize(content, wrapper_open, wrapper_close)
23
+ @content = content
24
+ @wrapper_open = wrapper_open
25
+ @wrapper_close = wrapper_close
26
+ @annotation_start = nil
27
+ @annotation_end = nil
28
+ @parser = nil
29
+ end
30
+
31
+ # Find the annotation's line start and line end
32
+ def run
33
+ # CustomParser returns line numbers as 0-indexed
34
+ @parser = FileParser::CustomParser.new(@content, "", 0).tap(&:parse)
35
+ comments = @parser.comments
36
+
37
+ start = comments.find_index { |comment, _| comment.include?(COMPAT_PREFIX) || comment.include?(COMPAT_PREFIX_MD) }
38
+ raise NoAnnotationFound if start.nil? # Stop execution because we did not find
39
+
40
+ if @wrapper_open
41
+ prev_comment, _prev_line_number = comments[start - 1]
42
+
43
+ # Change start to the line before if wrapper_open is defined and we find the wrapper open comment
44
+ if prev_comment&.include?(@wrapper_open)
45
+ start -= 1
46
+ end
47
+ end
48
+
49
+ # Find a contiguous block of comments from the starting point
50
+ ending = start
51
+ while ending < comments.size - 1
52
+ _comment, line_number = comments[ending]
53
+ _next_comment, next_line_number = comments[ending + 1]
54
+
55
+ if next_line_number - line_number == 1
56
+ ending += 1
57
+ else
58
+ break
59
+ end
60
+ end
61
+
62
+ raise MalformedAnnotation if start == ending
63
+
64
+ if @wrapper_close
65
+ if comments[ending].first.include?(@wrapper_close)
66
+ # We can end here because it's the end of the annotation block
67
+ else
68
+ # Walk back until we find the end of the annotation comment block or the wrapper close to be flexible
69
+ # We check if @wrapper_close is a substring because `comments` contains strings with the comment character
70
+ while ending > start && comments[ending].first != DEFAULT_ANNOTATION_ENDING && !comments[ending].first.include?(@wrapper_close)
71
+ ending -= 1
72
+ end
73
+ end
74
+ else
75
+ # Walk back until we find the end of the annotation comment block
76
+ while ending > start && comments[ending].first != DEFAULT_ANNOTATION_ENDING
77
+ ending -= 1
78
+ end
79
+ end
80
+
81
+ # We want .last because we want the line indexes
82
+ @annotation_start = comments[start].last
83
+ @annotation_end = comments[ending].last
84
+
85
+ [@annotation_start, @annotation_end]
86
+ end
87
+
88
+ def annotation
89
+ @annotation ||=
90
+ begin
91
+ lines = @content.lines
92
+ lines[@annotation_start..@annotation_end].join
93
+ end
94
+ end
95
+
96
+ # Returns true if annotations are detected in the file content
97
+ def annotated?
98
+ @annotation_start && @annotation_end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ripper"
4
+
5
+ module AnnotateRb
6
+ module ModelAnnotator
7
+ module FileParser
8
+ class CustomParser < Ripper
9
+ # Overview of Ripper: https://kddnewton.com/2022/02/14/formatting-ruby-part-1.html
10
+ # Ripper API: https://kddnewton.com/ripper-docs/
11
+
12
+ class << self
13
+ def parse(string)
14
+ _parser = new(string, "", 0).tap(&:parse)
15
+ end
16
+ end
17
+
18
+ attr_reader :comments
19
+
20
+ def initialize(input, ...)
21
+ super
22
+ @_stack_code_block = []
23
+ @_input = input
24
+ @_const_event_map = {}
25
+
26
+ @comments = []
27
+ @block_starts = []
28
+ @block_ends = []
29
+ @const_type_map = {}
30
+ end
31
+
32
+ def starts
33
+ @block_starts
34
+ end
35
+
36
+ def ends
37
+ @block_ends
38
+ end
39
+
40
+ def type_map
41
+ @const_type_map
42
+ end
43
+
44
+ def on_program(...)
45
+ {
46
+ comments: @comments,
47
+ starts: @block_starts,
48
+ ends: @block_ends,
49
+ type_map: @const_type_map
50
+ }
51
+ end
52
+
53
+ def on_const_ref(const)
54
+ add_event(__method__, const, lineno)
55
+ @block_starts << [const, lineno]
56
+ super
57
+ end
58
+
59
+ # Used for `class Foo::User`
60
+ def on_const_path_ref(_left, const)
61
+ add_event(__method__, const, lineno)
62
+ @block_starts << [const, lineno]
63
+ super
64
+ end
65
+
66
+ def on_module(const, _bodystmt)
67
+ add_event(__method__, const, lineno)
68
+ @const_type_map[const] = :module unless @const_type_map[const]
69
+ @block_ends << [const, lineno]
70
+ super
71
+ end
72
+
73
+ def on_class(const, _superclass, _bodystmt)
74
+ add_event(__method__, const, lineno)
75
+ @const_type_map[const] = :class unless @const_type_map[const]
76
+ @block_ends << [const, lineno]
77
+ super
78
+ end
79
+
80
+ # Gets the `RSpec` opening in:
81
+ # ```ruby
82
+ # RSpec.describe "Collapsed::TestModel" do
83
+ # # Deliberately left empty
84
+ # end
85
+ # ```
86
+ # receiver: "RSpec", operator: ".", method: "describe"
87
+ def on_command_call(receiver, operator, method, args)
88
+ add_event(__method__, receiver, lineno)
89
+ @block_starts << [receiver, lineno]
90
+
91
+ # We keep track of blocks using a stack
92
+ @_stack_code_block << receiver
93
+ super
94
+ end
95
+
96
+ def on_method_add_block(method, block)
97
+ # When parsing a line with no explicit receiver, the method will be presented in an Array.
98
+ # It's not immediately clear why.
99
+ #
100
+ # Example:
101
+ # ```ruby
102
+ # describe "Collapsed::TestModel" do
103
+ # # Deliberately left empty
104
+ # end
105
+ # ```
106
+ #
107
+ # => method = ["describe"]
108
+ if method.is_a?(Array) && method.size == 1
109
+ method = method.first
110
+ end
111
+
112
+ add_event(__method__, method, lineno)
113
+
114
+ if @_stack_code_block.last == method
115
+ @block_ends << [method, lineno]
116
+ @_stack_code_block.pop
117
+ else
118
+ @block_starts << [method, lineno]
119
+ end
120
+ super
121
+ end
122
+
123
+ def on_method_add_arg(method, args)
124
+ add_event(__method__, method, lineno)
125
+ @block_starts << [method, lineno]
126
+
127
+ # We keep track of blocks using a stack
128
+ @_stack_code_block << method
129
+ super
130
+ end
131
+
132
+ # Gets the `FactoryBot` line in:
133
+ # ```ruby
134
+ # FactoryBot.define do
135
+ # factory :user do
136
+ # ...
137
+ # end
138
+ # end
139
+ # ```
140
+ def on_call(receiver, operator, message)
141
+ # We only want to add the parsed line if the beginning of the Ruby
142
+ if @block_starts.empty?
143
+ add_event(__method__, receiver, lineno)
144
+ @block_starts << [receiver, lineno]
145
+ end
146
+
147
+ super
148
+ end
149
+
150
+ # Gets the `factory` block start in:
151
+ # ```ruby
152
+ # factory :user, aliases: [:author, :commenter] do
153
+ # ...
154
+ # end
155
+ # ```
156
+ def on_command(message, args)
157
+ add_event(__method__, message, lineno)
158
+ @block_starts << [message, lineno]
159
+
160
+ # We keep track of blocks using a stack
161
+ @_stack_code_block << message
162
+ super
163
+ end
164
+
165
+ # Matches the `end` in:
166
+ # ```ruby
167
+ # factory :user, aliases: [:author, :commenter] do
168
+ # first_name { "John" }
169
+ # last_name { "Doe" }
170
+ # date_of_birth { 18.years.ago }
171
+ # end
172
+ # ```
173
+ def on_do_block(block_var, bodystmt)
174
+ if block_var.blank? && bodystmt.blank?
175
+ @block_ends << ["end", lineno]
176
+ add_event(__method__, "end", lineno)
177
+ end
178
+ super
179
+ end
180
+
181
+ def on_embdoc_beg(value)
182
+ add_event(__method__, value, lineno)
183
+ @comments << [value.strip, lineno]
184
+ super
185
+ end
186
+
187
+ def on_embdoc_end(value)
188
+ add_event(__method__, value, lineno)
189
+ @comments << [value.strip, lineno]
190
+ super
191
+ end
192
+
193
+ def on_embdoc(value)
194
+ add_event(__method__, value, lineno)
195
+ @comments << [value.strip, lineno]
196
+ super
197
+ end
198
+
199
+ def on_comment(value)
200
+ add_event(__method__, value, lineno)
201
+ @comments << [value.strip, lineno]
202
+ super
203
+ end
204
+
205
+ private
206
+
207
+ def add_event(event, const, lineno)
208
+ if !@_const_event_map[lineno]
209
+ @_const_event_map[lineno] = []
210
+ end
211
+
212
+ @_const_event_map[lineno] << [const, event]
213
+ end
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnnotateRb
4
+ module ModelAnnotator
5
+ module FileParser
6
+ class ParsedFile
7
+ SKIP_ANNOTATION_STRING = "# -*- SkipSchemaAnnotations"
8
+
9
+ def initialize(file_content, new_annotations, options)
10
+ @file_content = file_content
11
+ @file_lines = @file_content.lines
12
+ @new_annotations = new_annotations
13
+ @options = options
14
+ end
15
+
16
+ def parse
17
+ @finder = AnnotationFinder.new(@file_content, @options[:wrapper_open], @options[:wrapper_close])
18
+ has_annotations = false
19
+
20
+ begin
21
+ @finder.run
22
+ has_annotations = @finder.annotated?
23
+ rescue AnnotationFinder::NoAnnotationFound => _e
24
+ end
25
+
26
+ annotations = if has_annotations
27
+ @file_lines[(@finder.annotation_start)..(@finder.annotation_end)].join
28
+ else
29
+ ""
30
+ end
31
+
32
+ @diff = AnnotationDiffGenerator.new(annotations, @new_annotations).generate
33
+ @file_parser = @finder.parser
34
+
35
+ has_skip_string = @file_parser.comments.any? { |comment, _lineno| comment.include?(SKIP_ANNOTATION_STRING) }
36
+ annotations_changed = @diff.changed?
37
+
38
+ has_leading_whitespace = false
39
+ has_trailing_whitespace = false
40
+
41
+ annotations_with_whitespace = if has_annotations
42
+ begin
43
+ annotation_start = @finder.annotation_start
44
+ annotation_end = @finder.annotation_end
45
+
46
+ if @file_lines[annotation_start - 1]&.strip&.empty?
47
+ annotation_start -= 1
48
+ has_leading_whitespace = true
49
+ end
50
+
51
+ if @file_lines[annotation_end + 1]&.strip&.empty?
52
+ annotation_end += 1
53
+ has_trailing_whitespace = true
54
+ end
55
+
56
+ @file_lines[annotation_start..annotation_end].join
57
+ end
58
+ else
59
+ ""
60
+ end
61
+
62
+ # :before or :after when it's set
63
+ annotation_position = nil
64
+
65
+ if has_annotations
66
+ const_declaration = @file_parser.starts.first
67
+
68
+ # If the file does not have any class or module declaration then const_declaration can be nil
69
+ _const, line_number = const_declaration
70
+
71
+ if line_number
72
+ annotation_position = if @finder.annotation_start < line_number
73
+ :before
74
+ else
75
+ :after
76
+ end
77
+ end
78
+ end
79
+
80
+ _result = ParsedFileResult.new(
81
+ has_annotations: has_annotations,
82
+ has_skip_string: has_skip_string,
83
+ annotations_changed: annotations_changed,
84
+ annotations: annotations,
85
+ annotations_with_whitespace: annotations_with_whitespace,
86
+ has_leading_whitespace: has_leading_whitespace,
87
+ has_trailing_whitespace: has_trailing_whitespace,
88
+ annotation_position: annotation_position
89
+ )
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnnotateRb
4
+ module ModelAnnotator
5
+ module FileParser
6
+ class ParsedFileResult
7
+ def initialize(
8
+ has_annotations:,
9
+ has_skip_string:,
10
+ annotations_changed:,
11
+ annotations:,
12
+ annotations_with_whitespace:,
13
+ has_leading_whitespace:,
14
+ has_trailing_whitespace:,
15
+ annotation_position:
16
+ )
17
+ @has_annotations = has_annotations
18
+ @has_skip_string = has_skip_string
19
+ @annotations_changed = annotations_changed
20
+ @annotations = annotations
21
+ @annotations_with_whitespace = annotations_with_whitespace
22
+ @has_leading_whitespace = has_leading_whitespace
23
+ @has_trailing_whitespace = has_trailing_whitespace
24
+ @annotation_position = annotation_position
25
+ end
26
+
27
+ attr_reader :annotations, :annotation_position
28
+
29
+ # Returns annotations with new line before and after if they exist
30
+ attr_reader :annotations_with_whitespace
31
+
32
+ def annotations_changed?
33
+ @annotations_changed
34
+ end
35
+
36
+ def has_annotations?
37
+ @has_annotations
38
+ end
39
+
40
+ def has_skip_string?
41
+ @has_skip_string
42
+ end
43
+
44
+ def has_leading_whitespace?
45
+ @has_leading_whitespace
46
+ end
47
+
48
+ def has_trailing_whitespace?
49
+ @has_trailing_whitespace
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -3,7 +3,10 @@
3
3
  module AnnotateRb
4
4
  module ModelAnnotator
5
5
  module FileParser
6
- autoload :MagicCommentParser, "annotate_rb/model_annotator/file_parser/magic_comment_parser"
6
+ autoload :AnnotationFinder, "annotate_rb/model_annotator/file_parser/annotation_finder"
7
+ autoload :CustomParser, "annotate_rb/model_annotator/file_parser/custom_parser"
8
+ autoload :ParsedFile, "annotate_rb/model_annotator/file_parser/parsed_file"
9
+ autoload :ParsedFileResult, "annotate_rb/model_annotator/file_parser/parsed_file_result"
7
10
  end
8
11
  end
9
12
  end
@@ -8,6 +8,13 @@ module AnnotateRb
8
8
  # Check for namespaced models in subdirectories as well as models
9
9
  # in subdirectories without namespacing.
10
10
  def call(file, options)
11
+ use_zeitwerk = defined?(::Rails) && ::Rails.try(:autoloaders).try(:zeitwerk_enabled?)
12
+
13
+ if use_zeitwerk
14
+ klass = ZeitwerkClassGetter.call(file, options)
15
+ return klass if klass
16
+ end
17
+
11
18
  model_path = file.gsub(/\.rb$/, "")
12
19
  options[:model_dir].each { |dir| model_path = model_path.gsub(/^#{dir}/, "").gsub(/^\//, "") }
13
20
 
@@ -115,7 +115,16 @@ module AnnotateRb
115
115
 
116
116
  # Try to search the table without prefix
117
117
  table_name_without_prefix = table_name.to_s.sub(@klass.table_name_prefix, "")
118
- @klass.connection.indexes(table_name_without_prefix)
118
+ begin
119
+ @klass.connection.indexes(table_name_without_prefix)
120
+ rescue ActiveRecord::StatementInvalid => _e
121
+ # Mysql2 adapter behaves differently than Sqlite3 and Postgres adapter.
122
+ # If `table_name_without_prefix` does not exist, Mysql2 will raise,
123
+ # the other adapters will return an empty array.
124
+ #
125
+ # See: https://github.com/rails/rails/issues/51205
126
+ []
127
+ end
119
128
  end
120
129
 
121
130
  def with_comments?
@@ -24,10 +24,10 @@ module AnnotateRb
24
24
  add_related_scaffold_files if !@options[:exclude_scaffolds]
25
25
  add_related_controller_files if !@options[:exclude_controllers]
26
26
  add_related_helper_files if !@options[:exclude_helpers]
27
- add_related_admin_files if !@options[:active_admin]
28
- add_additional_file_patterns if !@options[:additional_file_patterns].present?
27
+ add_related_admin_files if @options[:active_admin]
28
+ add_additional_file_patterns if @options[:additional_file_patterns].present?
29
29
 
30
- @list
30
+ @list.uniq
31
31
  end
32
32
 
33
33
  private
@@ -12,20 +12,18 @@ module AnnotateRb
12
12
  return false unless File.exist?(file_name)
13
13
  old_content = File.read(file_name)
14
14
 
15
- file_components = FileComponents.new(old_content, "", options)
16
-
17
- return false if file_components.has_skip_string?
18
- # TODO: Uncomment below after tests are fixed
19
- # return false if !file_components.has_annotations?
20
-
21
- wrapper_open = if options[:wrapper_open]
22
- "# #{options[:wrapper_open]}\n"
23
- else
24
- ""
15
+ begin
16
+ parsed_file = FileParser::ParsedFile.new(old_content, "", options).parse
17
+ rescue FileParser::AnnotationFinder::MalformedAnnotation => e
18
+ warn "Unable to process #{file_name}: #{e.message}"
19
+ warn "\t" + e.backtrace.join("\n\t") if @options[:trace]
20
+ return false
25
21
  end
26
22
 
27
- generated_pattern = AnnotationPatternGenerator.call(options)
28
- updated_file_content = old_content.sub!(/(#{wrapper_open})?#{generated_pattern}/, "")
23
+ return false if !parsed_file.has_annotations?
24
+ return false if parsed_file.has_skip_string?
25
+
26
+ updated_file_content = old_content.sub(parsed_file.annotations_with_whitespace, "")
29
27
 
30
28
  File.open(file_name, "wb") { |f| f.puts updated_file_content }
31
29
 
@@ -25,17 +25,25 @@ module AnnotateRb
25
25
  return false unless File.exist?(file_name)
26
26
  old_content = File.read(file_name)
27
27
 
28
- file_components = FileComponents.new(old_content, annotation, options)
28
+ begin
29
+ parsed_file = FileParser::ParsedFile.new(old_content, annotation, options).parse
30
+ rescue FileParser::AnnotationFinder::MalformedAnnotation => e
31
+ warn "Unable to process #{file_name}: #{e.message}"
32
+ warn "\t" + e.backtrace.join("\n\t") if @options[:trace]
33
+ return false
34
+ end
29
35
 
30
- return false if file_components.has_skip_string?
31
- return false if !file_components.annotations_changed? && !options[:force]
36
+ return false if parsed_file.has_skip_string?
37
+ return false if !parsed_file.annotations_changed? && !options[:force]
32
38
 
33
39
  abort "AnnotateRb error. #{file_name} needs to be updated, but annotaterb was run with `--frozen`." if options[:frozen]
34
40
 
35
- updated_file_content = if !file_components.has_annotations? || options[:force]
36
- AnnotatedFile::Generator.new(file_components, annotation_position, options).generate
41
+ updated_file_content = if !parsed_file.has_annotations?
42
+ AnnotatedFile::Generator.new(old_content, annotation, annotation_position, options).generate
43
+ elsif options[:force]
44
+ AnnotatedFile::Generator.new(old_content, annotation, annotation_position, options).generate
37
45
  else
38
- AnnotatedFile::Updater.new(file_components, annotation_position, options).update
46
+ AnnotatedFile::Updater.new(old_content, annotation, annotation_position, options).update
39
47
  end
40
48
 
41
49
  File.open(file_name, "wb") { |f| f.puts updated_file_content }
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnnotateRb
4
+ module ModelAnnotator
5
+ class ZeitwerkClassGetter
6
+ class << self
7
+ def call(file, options)
8
+ new(file, options).call
9
+ end
10
+ end
11
+
12
+ def initialize(file, options)
13
+ @file = file
14
+ @options = options
15
+ end
16
+
17
+ # @return [Constant, nil] Attempts to return the model class constant (e.g. User) defined in the model file
18
+ # can return `nil` if the file does not define the constant.
19
+ def call
20
+ return unless defined?(::Zeitwerk)
21
+
22
+ @absolute_file_path = File.expand_path(@file)
23
+ loader = ::Rails.autoloaders.main
24
+
25
+ if supports_cpath?
26
+ constant_using_cpath(loader)
27
+ else
28
+ constant(loader)
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def constant(loader)
35
+ root_dirs = loader.dirs(namespaces: true) # or `root_dirs = loader.root_dirs` with zeitwerk < 2.6.1
36
+ expanded_file = @absolute_file_path
37
+
38
+ # root_dir: "/home/dummyapp/app/models"
39
+ root_dir, namespace = root_dirs.find do |dir, _namespace|
40
+ expanded_file.start_with?(dir)
41
+ end
42
+
43
+ # expanded_file: "/home/dummyapp/app/models/collapsed/example/test_model.rb"
44
+ # filepath_relative_to_root_dir: "/collapsed/example/test_model.rb"
45
+ _, filepath_relative_to_root_dir = expanded_file.split(root_dir)
46
+
47
+ # Remove leading / and the .rb extension
48
+ filepath_relative_to_root_dir = filepath_relative_to_root_dir[1..].sub(/\.rb$/, "")
49
+
50
+ # once we have the filepath_relative_to_root_dir, we need to see if it
51
+ # falls within one of our Zeitwerk "collapsed" paths.
52
+ if loader.collapse.any? { |path| path.include?(root_dir) && file.include?(path.split(root_dir)[1]) }
53
+ # if the file is within a collapsed path, we then need to, for each
54
+ # collapsed path, remove the root dir
55
+ collapsed = loader.collapse.map { |path| path.split(root_dir)[1].sub(/^\//, "") }.to_set
56
+
57
+ collapsed.each do |collapse|
58
+ # next, we split the collapsed directory, e.g. `domain_name/models`, by
59
+ # slash, and discard the domain_name
60
+ _, *collapsed_namespace = collapse.split("/")
61
+
62
+ # if there are any collapsed namespaces, e.g. `models`, we then remove
63
+ # that from `filepath_relative_to_root_dir`.
64
+ #
65
+ # This would result in:
66
+ #
67
+ # previous filepath_relative_to_root_dir: domain_name/models/model_name
68
+ # new filepath_relative_to_root_dir: domain_name/model_name
69
+ if collapsed_namespace.any?
70
+ filepath_relative_to_root_dir.sub!("/#{collapsed_namespace.last}", "")
71
+ end
72
+ end
73
+ end
74
+
75
+ camelize = loader.inflector.camelize(filepath_relative_to_root_dir, nil)
76
+ namespace.const_get(camelize)
77
+ rescue NameError => e
78
+ warn e
79
+ nil
80
+ end
81
+
82
+ def constant_using_cpath(loader)
83
+ begin
84
+ constant = loader.cpath_expected_at(@absolute_file_path)
85
+ rescue ::Zeitwerk::Error => e
86
+ # Raises when file does not exist
87
+ warn "Zeitwerk unable to find file #{@file}, error:\n#{e.message}"
88
+ return
89
+ end
90
+
91
+ begin
92
+ # This uses ActiveSupport::Inflector.constantize
93
+ klass = constant.constantize
94
+ rescue NameError => e
95
+ warn e
96
+ return
97
+ end
98
+
99
+ klass
100
+ end
101
+
102
+ def supports_cpath?
103
+ @supports_cpath ||=
104
+ begin
105
+ current_version = ::Gem::Version.new(::Zeitwerk::VERSION)
106
+ required_version = ::Gem::Version.new("2.6.9")
107
+
108
+ current_version >= required_version
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -7,7 +7,6 @@ module AnnotateRb
7
7
  autoload :BadModelFileError, "annotate_rb/model_annotator/bad_model_file_error"
8
8
  autoload :FileNameResolver, "annotate_rb/model_annotator/file_name_resolver"
9
9
  autoload :SingleFileAnnotationRemover, "annotate_rb/model_annotator/single_file_annotation_remover"
10
- autoload :AnnotationPatternGenerator, "annotate_rb/model_annotator/annotation_pattern_generator"
11
10
  autoload :ModelClassGetter, "annotate_rb/model_annotator/model_class_getter"
12
11
  autoload :ModelFilesGetter, "annotate_rb/model_annotator/model_files_getter"
13
12
  autoload :SingleFileAnnotator, "annotate_rb/model_annotator/single_file_annotator"
@@ -22,10 +21,10 @@ module AnnotateRb
22
21
  autoload :SingleFileRemoveAnnotationInstruction, "annotate_rb/model_annotator/single_file_remove_annotation_instruction"
23
22
  autoload :AnnotationDiffGenerator, "annotate_rb/model_annotator/annotation_diff_generator"
24
23
  autoload :AnnotationDiff, "annotate_rb/model_annotator/annotation_diff"
25
- autoload :FileComponents, "annotate_rb/model_annotator/file_components"
26
24
  autoload :ProjectAnnotator, "annotate_rb/model_annotator/project_annotator"
27
25
  autoload :ProjectAnnotationRemover, "annotate_rb/model_annotator/project_annotation_remover"
28
26
  autoload :AnnotatedFile, "annotate_rb/model_annotator/annotated_file"
29
27
  autoload :FileParser, "annotate_rb/model_annotator/file_parser"
28
+ autoload :ZeitwerkClassGetter, "annotate_rb/model_annotator/zeitwerk_class_getter"
30
29
  end
31
30
  end
@@ -6,6 +6,11 @@
6
6
 
7
7
  # Migration tasks are tasks that we'll "hook" into
8
8
  migration_tasks = %w[db:migrate db:migrate:up db:migrate:down db:migrate:reset db:migrate:redo db:rollback]
9
+
10
+ # Support for data_migrate gem (https://github.com/ilyakatz/data-migrate)
11
+ migration_tasks_with_data = migration_tasks.map { |task| "#{task}:with_data" }
12
+ migration_tasks += migration_tasks_with_data
13
+
9
14
  if defined?(Rails::Application) && Rails.version.split(".").first.to_i >= 6
10
15
  require "active_record"
11
16
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: annotaterb
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.5.0
4
+ version: 4.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew W. Lee
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-02-08 00:00:00.000000000 Z
11
+ date: 2024-02-27 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Annotates Rails/ActiveRecord Models, routes, fixtures, and others based
14
14
  on the database schema.
@@ -44,7 +44,6 @@ files:
44
44
  - lib/annotate_rb/model_annotator/annotation_decider.rb
45
45
  - lib/annotate_rb/model_annotator/annotation_diff.rb
46
46
  - lib/annotate_rb/model_annotator/annotation_diff_generator.rb
47
- - lib/annotate_rb/model_annotator/annotation_pattern_generator.rb
48
47
  - lib/annotate_rb/model_annotator/annotator.rb
49
48
  - lib/annotate_rb/model_annotator/bad_model_file_error.rb
50
49
  - lib/annotate_rb/model_annotator/column_annotation.rb
@@ -53,10 +52,12 @@ files:
53
52
  - lib/annotate_rb/model_annotator/column_annotation/column_wrapper.rb
54
53
  - lib/annotate_rb/model_annotator/column_annotation/default_value_builder.rb
55
54
  - lib/annotate_rb/model_annotator/column_annotation/type_builder.rb
56
- - lib/annotate_rb/model_annotator/file_components.rb
57
55
  - lib/annotate_rb/model_annotator/file_name_resolver.rb
58
56
  - lib/annotate_rb/model_annotator/file_parser.rb
59
- - lib/annotate_rb/model_annotator/file_parser/magic_comment_parser.rb
57
+ - lib/annotate_rb/model_annotator/file_parser/annotation_finder.rb
58
+ - lib/annotate_rb/model_annotator/file_parser/custom_parser.rb
59
+ - lib/annotate_rb/model_annotator/file_parser/parsed_file.rb
60
+ - lib/annotate_rb/model_annotator/file_parser/parsed_file_result.rb
60
61
  - lib/annotate_rb/model_annotator/foreign_key_annotation.rb
61
62
  - lib/annotate_rb/model_annotator/foreign_key_annotation/annotation_builder.rb
62
63
  - lib/annotate_rb/model_annotator/index_annotation.rb
@@ -72,6 +73,7 @@ files:
72
73
  - lib/annotate_rb/model_annotator/single_file_annotator.rb
73
74
  - lib/annotate_rb/model_annotator/single_file_annotator_instruction.rb
74
75
  - lib/annotate_rb/model_annotator/single_file_remove_annotation_instruction.rb
76
+ - lib/annotate_rb/model_annotator/zeitwerk_class_getter.rb
75
77
  - lib/annotate_rb/options.rb
76
78
  - lib/annotate_rb/parser.rb
77
79
  - lib/annotate_rb/rake_bootstrapper.rb
@@ -116,7 +118,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
116
118
  - !ruby/object:Gem::Version
117
119
  version: '0'
118
120
  requirements: []
119
- rubygems_version: 3.2.33
121
+ rubygems_version: 3.5.6
120
122
  signing_key:
121
123
  specification_version: 4
122
124
  summary: A gem for generating annotations for Rails projects.
@@ -1,19 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module AnnotateRb
4
- module ModelAnnotator
5
- class AnnotationPatternGenerator
6
- COMPAT_PREFIX = "== Schema Info"
7
- COMPAT_PREFIX_MD = "## Schema Info"
8
-
9
- class << self
10
- def call(options)
11
- if options[:wrapper_open]
12
- return /(?:^(\n|\r\n)?# (?:#{options[:wrapper_open]}).*(\n|\r\n)?# (?:#{COMPAT_PREFIX}|#{COMPAT_PREFIX_MD}).*?(\n|\r\n)(#.*(\n|\r\n))*(\n|\r\n)*)|^(\n|\r\n)?# (?:#{COMPAT_PREFIX}|#{COMPAT_PREFIX_MD}).*?(\n|\r\n)(#.*(\n|\r\n))*(\n|\r\n)*/
13
- end
14
- /^(\n|\r\n)?# (?:#{COMPAT_PREFIX}|#{COMPAT_PREFIX_MD}).*?(\n|\r\n)(#.*(\n|\r\n))*(\n|\r\n)*/o
15
- end
16
- end
17
- end
18
- end
19
- end
@@ -1,81 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module AnnotateRb
4
- module ModelAnnotator
5
- class FileComponents
6
- SKIP_ANNOTATION_STRING = "# -*- SkipSchemaAnnotations"
7
- SOME_PATTERN = /\A(?<start>\s*).*?\n(?<end>\s*)\z/m # Unsure what this pattern is
8
-
9
- attr_reader :new_annotations
10
-
11
- def initialize(file_content, new_annotations, options)
12
- @file_content = file_content
13
- @diff = AnnotationDiffGenerator.new(file_content, new_annotations).generate
14
- @options = options
15
- @annotation_pattern = AnnotationPatternGenerator.call(options)
16
- @new_annotations = new_annotations
17
- end
18
-
19
- def current_file_content
20
- @file_content
21
- end
22
-
23
- # TODO: Rename method once it's clear what this actually does
24
- def space_before_annotation
25
- return @space_before_annotation if defined?(@space_before_annotation)
26
-
27
- match = current_annotations.match(SOME_PATTERN)
28
- @space_before_annotation = if match
29
- match[:start]
30
- end
31
- end
32
-
33
- # TODO: Rename method once it's clear what this actually does
34
- def space_after_annotation
35
- return @space_after_annotation if defined?(@space_after_annotation)
36
-
37
- match = current_annotations.match(SOME_PATTERN)
38
- @space_after_annotation = if match
39
- match[:end]
40
- end
41
- end
42
-
43
- def pure_file_content
44
- @pure_file_content ||=
45
- begin
46
- content_without_magic_comments = @file_content.gsub(FileParser::MagicCommentParser::MAGIC_COMMENTS_REGEX, "")
47
- content_without_annotations = content_without_magic_comments.sub(@annotation_pattern, "")
48
-
49
- content_without_annotations
50
- end
51
- end
52
-
53
- def magic_comments
54
- @magic_comments ||= FileParser::MagicCommentParser.call(@file_content)
55
- end
56
-
57
- def has_skip_string?
58
- @has_skip_string ||= @file_content.include?(SKIP_ANNOTATION_STRING)
59
- end
60
-
61
- def has_annotations?
62
- @has_annotations ||= @diff.current_columns.present?
63
- end
64
-
65
- def annotations_changed?
66
- @has_annotations_changed ||= @diff.changed?
67
- end
68
-
69
- def current_annotations
70
- @current_annotations ||=
71
- if has_annotations?
72
- # `#has_annotations?` uses a different regex pattern than the one in `@annotation_pattern`,
73
- # this could lead to unexpected behavior
74
- @file_content.match(@annotation_pattern).to_s
75
- else
76
- ""
77
- end
78
- end
79
- end
80
- end
81
- end
@@ -1,34 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module AnnotateRb
4
- module ModelAnnotator
5
- module FileParser
6
- # Extracts magic comments strings and returns them
7
- class MagicCommentParser
8
- MAGIC_COMMENTS = [
9
- HASH_ENCODING = /(^#\s*encoding:.*(?:\n|r\n))/,
10
- HASH_CODING = /(^# coding:.*(?:\n|\r\n))/,
11
- HASH_FROZEN_STRING = /(^#\s*frozen_string_literal:.+(?:\n|\r\n))/,
12
- STAR_ENCODING = /(^# -\*- encoding\s?:.*(?:\n|\r\n))/,
13
- STAR_CODING = /(^# -\*- coding:.*(?:\n|\r\n))/,
14
- STAR_FROZEN_STRING = /(^# -\*- frozen_string_literal\s*:.+-\*-(?:\n|\r\n))/,
15
- SORBET_TYPED_STRING = /(^#\s*typed:.*(?:\n|r\n))/.freeze
16
- ].freeze
17
-
18
- MAGIC_COMMENTS_REGEX = Regexp.union(*MAGIC_COMMENTS).freeze
19
-
20
- class << self
21
- def call(content)
22
- magic_comments = content.scan(MAGIC_COMMENTS_REGEX).flatten.compact
23
-
24
- if magic_comments.any?
25
- magic_comments.join
26
- else
27
- ""
28
- end
29
- end
30
- end
31
- end
32
- end
33
- end
34
- end