Skip to content

Commit

Permalink
Support non-model files in CustomParser (#88)
Browse files Browse the repository at this point in the history
This PR adds support for other Ruby files types that should be able to
get model annotations added to them (i.e. factories, fabricators, etc).

In #72, AnnotateRb was changed to use `FileParser::CustomParser` to
parse Ruby files instead of relying on regexes. That PR only added
support for model files that used either `module Namespace...` or `class
ModelName...` as the first line of Ruby. The parser did not have support
for other file structures like FactoryBot factories or Fabrication
fabricators.

There may be other Ruby files that have code that is not currently
handled by `CustomParser`, so those will have to be added in the future.
  • Loading branch information
drwl committed Feb 18, 2024
1 parent 22675b2 commit 20036db
Show file tree
Hide file tree
Showing 4 changed files with 487 additions and 55 deletions.
92 changes: 90 additions & 2 deletions lib/annotate_rb/model_annotator/file_parser/custom_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,22 @@ module ModelAnnotator
module FileParser
class CustomParser < Ripper
# Overview of Ripper: https://kddnewton.com/2022/02/14/formatting-ruby-part-1.html
# Ripper API: https://kddnewton.com/ripper-docs/events
# Ripper API: https://kddnewton.com/ripper-docs/

class << self
def parse(string)
_parser = new(string).tap(&:parse)
_parser = new(string, "", 0).tap(&:parse)
end
end

attr_reader :comments

def initialize(input, ...)
super
@_stack_code_block = []
@_input = input
@_const_event_map = {}

@comments = []
@block_starts = []
@block_ends = []
Expand Down Expand Up @@ -48,47 +51,132 @@ def on_program(...)
end

def on_const_ref(const)
add_event(__method__, const, lineno)
@block_starts << [const, lineno]
super
end

# Used for `class Foo::User`
def on_const_path_ref(_left, const)
add_event(__method__, const, lineno)
@block_starts << [const, lineno]
super
end

def on_module(const, _bodystmt)
add_event(__method__, const, lineno)
@const_type_map[const] = :module unless @const_type_map[const]
@block_ends << [const, lineno]
super
end

def on_class(const, _superclass, _bodystmt)
add_event(__method__, const, lineno)
@const_type_map[const] = :class unless @const_type_map[const]
@block_ends << [const, lineno]
super
end

def on_method_add_block(method, block)
add_event(__method__, method, lineno)

if @_stack_code_block.last == method
@block_ends << [method, lineno]
@_stack_code_block.pop
else
@block_starts << [method, lineno]
end
super
end

def on_method_add_arg(method, args)
add_event(__method__, method, lineno)
@block_starts << [method, lineno]

# We keep track of blocks using a stack
@_stack_code_block << method
super
end

# Gets the `FactoryBot` line in:
# ```ruby
# FactoryBot.define do
# factory :user do
# ...
# end
# end
# ```
def on_call(receiver, operator, message)
# We only want to add the parsed line if the beginning of the Ruby
if @block_starts.empty?
add_event(__method__, receiver, lineno)
@block_starts << [receiver, lineno]
end

super
end

# Gets the `factory` block start in:
# ```ruby
# factory :user, aliases: [:author, :commenter] do
# ...
# end
# ```
def on_command(message, args)
add_event(__method__, message, lineno)
@block_starts << [message, lineno]
super
end

# Matches the `end` in:
# ```ruby
# factory :user, aliases: [:author, :commenter] do
# first_name { "John" }
# last_name { "Doe" }
# date_of_birth { 18.years.ago }
# end
# ```
def on_do_block(block_var, bodystmt)
if block_var.blank? && bodystmt.blank?
@block_ends << ["end", lineno]
add_event(__method__, "end", lineno)
end
super
end

def on_embdoc_beg(value)
add_event(__method__, value, lineno)
@comments << [value.strip, lineno]
super
end

def on_embdoc_end(value)
add_event(__method__, value, lineno)
@comments << [value.strip, lineno]
super
end

def on_embdoc(value)
add_event(__method__, value, lineno)
@comments << [value.strip, lineno]
super
end

def on_comment(value)
add_event(__method__, value, lineno)
@comments << [value.strip, lineno]
super
end

private

def add_event(event, const, lineno)
if !@_const_event_map[lineno]
@_const_event_map[lineno] = []
end

@_const_event_map[lineno] << [const, event]
end
end
end
end
Expand Down
134 changes: 134 additions & 0 deletions spec/lib/annotate_rb/model_annotator/annotated_file/generator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -400,5 +400,139 @@ class User < ApplicationRecord
end
end
end

context 'when position is "before" for a FactoryBot factory' do
let(:options) { AnnotateRb::Options.new({position_in_class: "before"}) }

let(:file_content) do
<<~FILE
FactoryBot.define do
factory :user do
admin { false }
end
end
FILE
end

let(:expected_content) do
<<~CONTENT
# == Schema Information
#
# Table name: users
#
# id :bigint not null, primary key
#
FactoryBot.define do
factory :user do
admin { false }
end
end
CONTENT
end

it "returns the annotated file content" do
is_expected.to eq(expected_content)
end
end

context 'when position is "after" for a FactoryBot factory' do
let(:options) { AnnotateRb::Options.new({position_in_class: "after"}) }

let(:file_content) do
<<~FILE
FactoryBot.define do
factory :user do
admin { false }
end
end
FILE
end

let(:expected_content) do
<<~CONTENT
FactoryBot.define do
factory :user do
admin { false }
end
end
# == Schema Information
#
# Table name: users
#
# id :bigint not null, primary key
#
CONTENT
end

it "returns the annotated file content" do
is_expected.to eq(expected_content)
end
end

context 'when position is "before" for a Fabrication fabricator' do
let(:options) { AnnotateRb::Options.new({position_in_class: "before"}) }

let(:file_content) do
<<~FILE
Fabricator(:user) do
name
reminder_at { 1.day.from_now.iso8601 }
end
FILE
end

let(:expected_content) do
<<~CONTENT
# == Schema Information
#
# Table name: users
#
# id :bigint not null, primary key
#
Fabricator(:user) do
name
reminder_at { 1.day.from_now.iso8601 }
end
CONTENT
end

it "returns the annotated file content" do
is_expected.to eq(expected_content)
end
end

context 'when position is "after" for a Fabrication fabricator' do
let(:options) { AnnotateRb::Options.new({position_in_class: "after"}) }

let(:file_content) do
<<~FILE
Fabricator(:user) do
name
reminder_at { 1.day.from_now.iso8601 }
end
FILE
end

let(:expected_content) do
<<~CONTENT
Fabricator(:user) do
name
reminder_at { 1.day.from_now.iso8601 }
end
# == Schema Information
#
# Table name: users
#
# id :bigint not null, primary key
#
CONTENT
end

it "returns the annotated file content" do
is_expected.to eq(expected_content)
end
end
end
end
Loading

0 comments on commit 20036db

Please sign in to comment.