annotaterb 4.5.0 → 4.6.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 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