# frozen_string_literal: true
module RuboCop
module Cop
module Style
# TODO: Write cop description and example of bad / good code. For every
# `SupportedStyle` and unique configuration, there needs to be examples.
# Examples must have valid Ruby syntax. Do not use upticks.
#
# @example EnforcedStyle: PublicMethodDocumentation (default)
# # Description of the `PublicMethodDocumentation` style.
#
# # bad
# def xxx
#
# # bad
# # xxx documentation
# def xxx
#
# # bad
# # xxx documentation
# # the end
# def xxx
#
# # good
# # class xxx documentation
# #
# def xxx
#
# # bad
# # class xxx documentation
# #
# def xxx(p1)
#
class PublicMethodDocumentation < DocumentationMethod
ATTRS_DOC = '# === Attributes:'
RETURNS_DOC = '# === Returns:'
PARMS_DOC = '# === Parameters:'
MSG_ATTRIBUTES_AND_PARAMETERS_NO_COEXIST = 'Attributes and Parameters should not exist on same method.'
MSG_DESCRIPTION_SHOULD_END_WITH_BLANK_COMMENT = 'Description should end with blank comment.'
MSG_MISSING_DOCUMENTATION = 'Missing public method documentation comment for `%s`.'
MSG_MISSING_PARAMETERS = 'Parameter is missing for `%s`.'
MSG_PARAMETERS_ARG_NAME_MISMATCH = 'Parameter name `%s` does not match argument name `%s`.'
MSG_PARAMETERS_ARG_SIZE_MISMATCH = 'Parameter size `%s` does not match argument size `%s`.'
MSG_PARAMETERS_SHOULD_BE_BEFORE_RETURNS = 'Parameters should be before Returns.'
MSG_RANGE_BODY_EMPTY = '%s body is empty.'
MSG_RETURNS_SHOULD_BE_LAST = 'Returns should be last.'
# https://regex101.com/
ATTR_REGEXP = /^ *# *=== *Attributes:/.freeze
DOC_PARM_REGEXP = %r{^# \* :(\w+)}.freeze
DOC_RET_REGEXP = %r{^# \* ([:\w]+)}.freeze
PARMS_REGEXP = /^ *# *=== *Parameters:/.freeze
RETURNS_REGEXP = /^ *# *=== *Returns: */.freeze
# TODO: Implement the cop in here.
#
# In many cases, you can use a node matcher for matching node pattern.
# See https://github.com/rubocop/rubocop-ast/blob/master/lib/rubocop/ast/node_pattern.rb
#
# For example
include DocumentationComment
include DefNode
# checks for public methods to make sure they have proper documentation
# if not it will add an offense
#
# === Parameters:
#
# * :node a def node
#
def on_def(node)
# puts("start-#{node.children.first.to_s}")
check(node)
# puts 'end-on_def'
end
private
def add_format(message)
format(message, @method_name)
end
def add_offense(node, location: :expression, message: nil, severity: nil)
super(node, message: add_format(message), severity: severity)
end
def check(node)
return if non_public?(node)
# return if documentation_comment?(node)
prk_documentation_comment(node)
end
def prk_documentation_comment(node)
@method_name = node.children.first.to_s
# puts " processing: #{@method_name}"
preceding_lines = preceding_lines(node)
return add_offense(node, message: MSG_MISSING_DOCUMENTATION) unless preceding_comment?(node, preceding_lines.last)
description_range, parameters_range, returns_range, attrs_range = parse_documentation(preceding_lines)
add_offense(preceding_lines[0], message: MSG_MISSING_DESCRIPTION) if description_range.nil?
# order
# description_range
# parameters_range || attrs_range
# returns_range
#
grd = description_range.before?(parameters_range) &&
description_range.before?(returns_range) &&
description_range.before?(attrs_range)
add_offense(description_range.start_comment, message: MSG_DESCRIPTIION_SHOULD_BE_FIRST) unless grd
guard = parameters_range.before?(returns_range)
grd = guard && attrs_range.before?(returns_range)
add_offense(description_range.start_comment, message: MSG_RETURNS_SHOULD_BE_LAST) unless grd
grd = attrs_range.missing? || parameters_range.missing?
add_offense(attrs_range.start_comment, message: MSG_ATTRIBUTES_AND_PARAMETERS_NO_COEXIST) unless grd
special_comm = preceding_lines.any? do |comment|
!AnnotationComment.new(comment, annotation_keywords).annotation? &&
!interpreter_directive_comment?(comment) &&
!rubocop_directive_comment?(comment)
end
return add_offense(preceding_lines[index], message: MSG_INVALID_DOCUMENTATION) unless special_comm
add_offense(parameters_range.start_comment, message: MSG_PARAMETERS_SHOULD_BE_BEFORE_RETURNS) unless guard
check_blank_comments(description_range, parameters_range, returns_range, attrs_range)
args = node.arguments
guard = parameters_range.missing? && !args.empty?
return add_offense(preceding_lines[0], message: MSG_MISSING_PARAMETERS) if guard
guard = !parameters_range.missing? && args.empty?
return add_offense(parameters_range.start_comment, message: MSG_UNNECESSARY_PARAMETERS) if guard
check_body(parameters_range) unless parameters_range.missing?
check_body(attrs_range) unless attrs_range.missing?
check_body(returns_range) unless returns_range.missing?
check_parms_and_args(args, parameters_range) unless parameters_range.missing?
end
def parse_documentation(comments)
desc = MethodDocRange.new(comments, 'Description')
returns = MethodDocRange.new(comments, 'Return')
parms = MethodDocRange.new(comments, 'Parameter')
attrs = MethodDocRange.new(comments, 'Attribute')
current = nil
comments.each_with_index do |comment_line, i|
text_line = comment_line.text
if RETURNS_REGEXP.match?(text_line)
current.end = i - 1 unless current.nil?
returns.start = i # [comment_line, i, 0]
current = returns
elsif PARMS_REGEXP.match?(text_line)
current.end = i - 1 unless current.nil?
parms.start = i # [comment_line, i, 0]
current = parms
elsif ATTR_REGEXP.match?(text_line)
current.end = i - 1 unless current.nil?
attrs.start = i # [comment_line, i, 0]
current = attrs
elsif i == 0
current.end = i - 1 unless current.nil?
desc.start = i # [comment_line, i, 0]
current = desc
end
current.end = comments.size - 1
end
# !parms.first_comment? && !returns.first_comment?
add_offense(comments[0], message: MSG_MISSING_DESCRIPTION) if desc.missing?
unless parms.missing?
guard = parms.first_comment_equal?(PARMS_DOC)
add_offense(parms.start_comment, message: MSG_PARAMETERS_DOES_MATCH_MATCH) unless guard
end
unless returns.missing?
guard = returns.first_comment_equal?(RETURNS_DOC)
add_offense(returns.start_comment, message: MSG_RETURNS_DOES_NOT_MATCH) unless guard
end
unless attrs.missing?
add_offense(attrs.start_comment, message: MSG_RETURNS_DOES_NOT_MATCH) unless attrs.first_comment_equal?(ATTRS_DOC)
end
# puts 'parse_document result'
# puts(" desc =#{desc.to_s}")
# puts(" parms=#{parms.to_s}")
# puts(" returns=#{returns.to_s}")
# puts(" attrs=#{attrs.to_s}")
[desc, parms, returns, attrs]
end
def check_blank_comments(description_range, parameters_range, returns_range, attrs_range)
unless description_range.missing?
# rubocop:disable Layout/LineLength
add_offense(description_range.start_comment, message: MSG_DESCRIPTION_SHOULD_NOT_BEGIN_WITH_BLANK_COMMENT) if description_range.starts_with_empty_comment?
add_offense(description_range.end_comment, message: MSG_DESCRIPTION_SHOULD_END_WITH_BLANK_COMMENT) unless description_range.ends_with_empty_comment?
end
add_offense(parameters_range.start_comment, message: MSG_PARAMETERS_IS_MISSING_FIRST_BLANK_COMMENT) unless parameters_range.first_empty_comment?
add_offense(parameters_range.end_comment, message: MSG_PARAMETERS_SHOULD_END_WITH_BLANK_COMMENT) unless parameters_range.ends_with_empty_comment?
add_offense(returns_range.start_comment, message: MSG_RETURNS_IS_MISSING_FIRST_BLANK_COMMENT) unless returns_range.first_empty_comment?
add_offense(returns_range.end_comment, message: MSG_RETURNS_SHOULD_END_WITH_BLANK_COMMENT) unless returns_range.ends_with_empty_comment?
add_offense(attrs_range.start_comment, message: MSG_ATTRIBUTES_IS_MISSING_FIRST_BLANK_COMMENT) unless attrs_range.first_empty_comment?
ends_with_empty_comment_ = attrs_range.ends_with_empty_comment?
add_offense(attrs_range.end_comment, message: MSG_ATTRIBUTES_SHOULD_END_WITH_BLANK_COMMENT) unless ends_with_empty_comment_
# rubocop:enable Layout/LineLength
end
def check_body(range)
# puts "check_body=#{range.type} for #{@method_name}"
# puts range.start_comment.text
body = range.range_body
found = false
body.each_with_index do |line, _i|
# puts "check_body loop text=#{line.text}"
next if range.empty_comm?(line)
found = true
text = line.text.to_s
if range.returns?
# puts "check_body ret DOC_RET_REGEXP) text=#{DOC_RET_REGEXP.match(text)}"
# puts "check_body (DOC_SUB_PARM_REGEXP) text=#{DOC_SUB_PARM_REGEXP.match(text)}"
unless DOC_RET_REGEXP.match(text) || DOC_SUB_PARM_REGEXP.match(text)
add_offense(line, message: format(MSG_ILLEGAL_RANGE_RET_BODY_FORMAT, range.type)) unless text.start_with?('# **')
add_offense(line, message: format(MSG_ILLEGAL_RANGE_BODY_FORMAT_SUB, range.type)) if text.start_with?('# **')
end
else
# puts "check_body DOC_PARM_REGEXP) text=#{DOC_PARM_REGEXP.match(text)}"
# puts "check_body (DOC_SUB_PARM_REGEXP) text=#{DOC_SUB_PARM_REGEXP.match(text)}"
unless DOC_PARM_REGEXP.match(text) || DOC_SUB_PARM_REGEXP.match(text)
add_offense(line, message: format(MSG_ILLEGAL_RANGE_BODY_FORMAT, range.type)) unless text.start_with?('# **')
add_offense(line, message: format(MSG_ILLEGAL_RANGE_BODY_FORMAT_SUB, range.type)) if text.start_with?('# **')
end
end
end
# puts "check_body found=#{found}"
# puts "check_body adding_offense=#{found}" unless found
add_offense(range.start_comment, message: format(MSG_RANGE_BODY_EMPTY, range.type)) unless found
end
def check_parms_and_args(args, parameters_range)
# pns = parm_names(range_lines(preceding_lines, parameters_range))
pns = parameters_range.parm_names
# rubocop:disable Layout/LineLength
add_offense(pns[args.size][0], message: format(MSG_PARAMETERS_ARG_SIZE_MISMATCH, pns.size, args.size)) if pns.size > args.size
add_offense(args[pns.size], message: format(MSG_PARAMETERS_ARG_SIZE_MISMATCH, pns.size, args.size)) if args.size > pns.size
# rubocop:enable Layout/LineLength
match_parms_to_args(args, pns)
end
def match_parms_to_args(args, pns)
pns.each_with_index do |param_pair, i|
break if args[i].nil?
arg_name = get_arg_name(args[i])
param_line = param_pair[0]
param_name = param_pair[1]
# puts "Comparing arg name #{arg_name} with parm name #{param_name} ans=#{param_name == arg_name}"
next if param_name == arg_name
add_offense(
param_line,
message: format(MSG_PARAMETERS_ARG_NAME_MISMATCH, param_name, arg_name)
)
end
end
def get_arg_name(arg)
name = arg.node_parts[0].to_s
# handle unused arguments, which begin with _
return name[1...name.size] if name[0] == '_'
name
end
end
# method doc range checks parts of the comment for correctness
#
class MethodDocRange
attr_accessor(:end, :start, :type)
DOC_PARM_REGEXP = %r{^# \* :(\w+)}.freeze
PARM_START = '# * :'
PARM_END = ''
def initialize(comments, type)
@comments = comments
@type = type
end
def before?(method_doc_range)
return true if missing? || method_doc_range.missing?
@start < method_doc_range.start
end
def empty_comm?(comment)
txt = comment.text
txt.size <= 2
end
def end_comment
@comments[@end]
end
def ends_with_empty_comment?
missing? || empty_comm?(end_comment)
end
def first_comment?
@start == 0
end
def first_empty_comment?
missing? || empty_comm?(@comments[@start + 1])
end
def first_comment_equal?(text)
!missing? && start_comment.text == text
end
def missing?
@start.nil?
end
def parm_names
names = []
range_body.each do |parm_line|
# puts "parm_line.text=#{parm_line}"
# puts "match=#{DOC_PARM_REGEXP.match(parm_line.text).to_s}"
DOC_PARM_REGEXP.match(parm_line.text) do |m|
parm_name = m.to_s[PARM_START.size...m.to_s.index(PARM_END)]
names.push([parm_line, parm_name])
# puts "parm_name=#{parm_name}"
end
end
# puts "names=#{names}"
names
end
def range_body
@comments[@start + (first_empty_comment? ? 2 : 1)...@end + (ends_with_empty_comment? ? 0 : 1)]
end
def returns?
@type == 'Return'
end
def start_comment
@comments[@start]
end
def starts_with_empty_comment?
empty_comm?(@comments[@start])
end
def to_s
[missing? ? nil : start_comment, @start, @end]
end
end
end
end
end