lsegal / yard

YARD is a Ruby Documentation tool. The Y stands for "Yay!"
http://yardoc.org
MIT License
1.94k stars 397 forks source link

Any way to ignore Undocumentable? #1542

Closed jjb closed 1 month ago

jjb commented 6 months ago

i want to run yard doc --fail-on-warning --no-output in CI and confirm success

i have several instances of undocumentable code

one instance is this in a rails model, also the subject of this 12-year-old SO question which i just put a bounty on 😄

include Rails.application.routes.url_helpers

is there any way to ignore or document these so that there are no warnings?

i researched high and low before writing this, i hope i didn't miss something.

thanks for a great project!

jjb commented 6 months ago

in case helplful to someone finding this, here's what i'm trying in CircleCI

set +e # do not exit right away if a command fails https://circleci.com/docs/configuration-reference/#default-shell-options
yard doc --fail-on-warning --no-output &> yard_out
grep "\[warn\]" yard_out | grep -ivq undocumentable
if [ $? -eq 0 ]; then # grep found a warning that is not undocumentable? that's bad
  echo "IMPORTANT: Do not try to fix 'Undocumentable' errors, only fix the others"
  echo
  cat yard_out
  echo
  echo "IMPORTANT: Do not try to fix 'Undocumentable' errors, only fix the others"
  exit 1
fi
cat yard_out # all good! show output and exit success
lsegal commented 6 months ago

is there any way to ignore or document these so that there are no warnings?

This is a bit of a loaded question. There is no way to retain the same syntax and not have YARD display warnings. By the same token, if you do not want warnings to fail your CI build, you should not use the built-in --fail-on-warning option. YARD's fail-on-warning option is intentionally simplistic, since YARD's core codebase itself is not a documentation linter; the fail on warning option is a convenience to provide an error code if any warning text was printed. tl;dr, fail-on-warning does not allow for customization.

In general, the solution to providing documentable code is to reduce your use of dynamic calls such as the one you've listed. In almost all cases, there is a non-dynamic alternative to dynamic Ruby syntaxes. In the case of your specific example, you could use Rails.application.routes.url_helpers.YOUR_ROUTE directly. Another Rails specific solution would be to wrap the include statement inside of a Concern:

module WithRoutes
  extend ActiveSupport::Concern

  included do
    include Rails.application.routes.url_helpers
  end
end

class MyController < ApplicationController
  include WithRoutes
end

This will bypass top-level inclusion in the YARD parser.

If you do not want to change your syntax, the solution you've come up with is basically the simplest "recommended" path as far as implementing simple custom linting in CI. If you want something a little more robust, you can consider using YARD extensions, like overriding YARD::Logger#warn to do this type of parsing in Ruby, or via an extension to specific handlers. The latter would afford you a lot more flexibility about what type of code is allowed and when, for example:

# yard_extensions/ignore_rails_mixin.rb
module IgnoreRailsMixin
  def process
    super
  rescue YARD::Parser::UndocumentableError => e
    raise e unless statement.last.source.start_with?("Rails.")
  end
end

YARD::Handlers::Ruby::MixinHandler.prepend(IgnoreRailsMixin)

You can use the above via yard -e yard_extensions/ignore_rails_mixin.rb or by adding -e yard_extensions/ignore_rails_mixin.rb to your .yardopts.

jjb commented 6 months ago

Wow, thank you so much for these solutions!

jjb commented 6 months ago

Here's what I came up with, I couldn't figure out a way to get it into the top level process method, since it's done with metaprogramming

# https://github.com/lsegal/yard/issues/1542
module YardIgnoreUndocumentable
  def process
    super
  rescue YARD::Parser::UndocumentableError => e
    puts '😎'
  end
end

YARD::Handlers::Ruby::MixinHandler.prepend(YardIgnoreUndocumentable)
YARD::Handlers::Ruby::AttributeHandler.prepend(YardIgnoreUndocumentable)
YARD::Handlers::Ruby::MethodHandler.prepend(YardIgnoreUndocumentable)

and then running this in CI

yard doc -e lib/yard_extensions/ignore_undocumentable.rb --fail-on-warning # do not add --no-output
chriscz commented 2 months ago

I found this thread really useful. Here is a version that comes with a config file. Probably worth making it a plugin in the future.

Extension

# yard_extensions/ignore_warnings.rb
module IgnoreWarnings
  def process
    super
  rescue YARD::Parser::UndocumentableError => e
    raise e unless ::IgnoreWarnings::Registry.instance.ignore?(exception: e, statement: statement)
  end
end

require_relative "ignore_warnings/registry"

IgnoreWarnings::Registry.initialize_instance(File.join(File.dirname(__FILE__), "../yard.yml"))

YARD::Handlers::Ruby::ClassHandler.prepend(IgnoreWarnings)
YARD::Handlers::Ruby::MixinHandler.prepend(IgnoreWarnings)
# yard_extensions/ignore_warnings/registry.rb
class IgnoreWarnings::Registry
  attr_reader :rules
  attr_accessor :debug

  def initialize
    @debug = false
    @rules = []
  end

  def add_rule(hash = {})
    if hash.nil? || hash.empty?
      raise ArgumentError, "Invalid rule: #{hash}"
    end

    unknown_keys = hash.keys - %w[file line error_class source]

    unless unknown_keys.empty?
      raise ArgumentError, "Unknown rule keys #{unknown_keys.map(&:inspect).join(", ")} in #{hash}"
    end

    @rules << (hash.map { |k, v| [k.to_sym, v] }.to_h)
  end

  def ignore?(exception:, statement:)
    file = statement.last.file
    source = statement.last.source
    error_class = exception.class.name
    line = statement.last.line

    @rules.each do |r|
      next if r.key?(:error) && r[:error] != error_class
      next if r.key?(:file) && r[:file] != file
      next if r.key?(:line) && r[:line] != line
      next if r.key?(:source) && r[:source] != source

      if debug
        puts "* [IgnoreWarnings] Rule Matched:\n  rule: #{r}\n  error_class: #{error_class.inspect}\n  file: #{file}\n  line: #{line}\n  source: #{source.inspect}"
      end

      return true
    end

    if debug
      puts "* [IgnoreWarnings] No rules matched:\n  error_class: #{error_class.inspect}\n  file: #{file}\n  line: #{line}\n  source: #{source.inspect}"
    end

    false
  end

  private

  class << self
    def new_from_config(path)
      require "yaml"
      new.tap do |registry|
        config = YAML.safe_load_file(path)

        rules = config.dig("yard", "ignore_warnings", "rules")

        registry.debug = config.dig("yard", "ignore_warnings", "debug")

        if rules
          rules.each { |r| registry.add_rule(r) }
        else
          raise "No rules found in: #{config}"
        end
      end
    end

    def initialize_instance(config_path)
      @instance = new_from_config(config_path)
    end

    def instance
      @instance || raise("Did you forget to call IgnoreWarnings::Registry.initialize_instance?")
    end
  end
end

Configuration

# yard.yml
yard:
  ignore_warnings:
    # debug: true
    rules:
      - file: app/lib/route_helpers.rb
        error_class: YARD::Parser::UndocumentableError
        source: Rails.application.routes.url_helpers
      - file: lib/core_ext/to_bool.rb
        error_class: YARD::Parser::UndocumentableError
        line: 21

Hope it helps someone else!