lib/yard/cli/yardoc.rb



# frozen_string_literal: true
require 'digest/sha1'
require 'fileutils'

module YARD
  module CLI
    # Default options used in +yard doc+ command.
    class YardocOptions < Templates::TemplateOptions
      # @return [Array<CodeObjects::ExtraFileObject>]
      #   the list of extra files rendered along with objects
      default_attr :files, lambda { [] }

      # @return [String] the default title appended to each generated page
      default_attr :title, "Documentation by YARD #{YARD::VERSION}"

      # @return [Verifier] the default verifier object to filter queries
      default_attr :verifier, lambda { Verifier.new }

      # @return [Serializers::Base] the default serializer for generating output
      #   to disk.
      default_attr :serializer, lambda { Serializers::FileSystemSerializer.new }

      # @return [Symbol] the default output format (:html).
      default_attr :format, :html

      # @return [Boolean] whether the data should be rendered in a single page,
      #   if the template supports it.
      default_attr :onefile, false

      # @return [CodeObjects::ExtraFileObject] the README file object rendered
      #   along with objects
      attr_accessor :readme

      # @return [Array<CodeObjects::Base>] the list of code objects to render
      #   the templates with.
      attr_accessor :objects

      # @return [Numeric] An index value for rendering sequentially related templates
      attr_accessor :index

      # @return [CodeObjects::Base] an extra item to send to a template that is not
      #   the main rendered object
      attr_accessor :item

      # @return [CodeObjects::ExtraFileObject] the file object being rendered.
      #   The +object+ key is not used so that a file may be rendered in the context
      #   of an object's namespace (for generating links).
      attr_accessor :file

      # @return [String] the current locale
      attr_accessor :locale
    end

    # Yardoc is the default YARD CLI command (+yard doc+ and historic +yardoc+
    # executable) used to generate and output (mainly) HTML documentation given
    # a set of source files.
    #
    # == Usage
    #
    # Main usage for this command is:
    #
    #   $ yardoc [options] [source_files [- extra_files]]
    #
    # See +yardoc --help+ for details on valid options.
    #
    # == Options File (+.yardopts+)
    #
    # If a +.yardopts+ file is found in the source directory being processed,
    # YARD will use the contents of the file as arguments to the command,
    # treating newlines as spaces. You can use shell-style quotations to
    # group space delimited arguments, just like on the command line.
    #
    # A valid +.yardopts+ file might look like:
    #
    #   --no-private
    #   --title "My Title"
    #   --exclude foo --exclude bar
    #   lib/**/*.erb
    #   lib/**/*.rb -
    #   HACKING.rdoc LEGAL COPYRIGHT
    #
    # Note that Yardoc also supports the legacy RDoc style +.document+ file,
    # though this file can only specify source globs to parse, not options.
    #
    # == Queries (+--query+)
    #
    # Yardoc supports queries to select specific code objects for which to
    # generate documentation. For example, you might want to generate
    # documentation only for your public API. If you've documented your public
    # methods with +@api public+, you can use the following query to select
    # all of these objects:
    #
    #   --query '@api.text == "public"'
    #
    # Note that the syntax for queries is mostly Ruby with a few syntactic
    # simplifications for meta-data tags. See the {Verifier} class for an
    # overview of this syntax.
    #
    # == Adding Custom Ad-Hoc Meta-data Tags (+--tag+)
    #
    # YARD allows specification of {file:docs/Tags.md meta-data tags}
    # programmatically via the {YARD::Tags::Library} class, but often this is not
    # practical for users writing documentation. To make adding custom tags
    # easier, Yardoc has a few command-line switches for creating basic tags
    # and displaying them in generated HTML output.
    #
    # To specify a custom tag to be displayed in output, use any of the
    # following:
    #
    # * +--tag+ TAG:TITLE
    # * +--name-tag+ TAG:TITLE
    # * +--type-tag+ TAG:TITLE
    # * +--type-name-tag+ TAG:TITLE
    # * +--title-tag+ TAG:TITLE
    #
    # "TAG:TITLE" is of the form: name:"Display Title", for example:
    #
    #   --tag overload:"Overloaded Method"
    #
    # See +yard help doc+ for a description of the various options.
    #
    # Tags added in this way are automatically displayed in output. To add
    # a meta-data tag that does not show up in output, use +--hide-tag TAG+.
    # Note that you can also use this option on existing tags to hide
    # builtin tags, for instance.
    #
    # == Processed Data Storage (+.yardoc+ directory)
    #
    # When Yardoc parses a source directory, it creates a +.yardoc+ directory
    # (by default, override with +-b+) at the root of the project. This directory
    # contains marshal dumps for all raw object data in the source, so that
    # you can access it later for various commands (+stats+, +graph+, etc.).
    # This directory is also used as a cache for any future calls to +yardoc+
    # so as to process only the files which have changed since the last call.
    #
    # When Yardoc uses the cache in subsequent calls to +yardoc+, methods
    # or classes that have been deleted from source since the last parsing
    # will not be erased from the cache (YARD never deletes objects). In such
    # a case, you should wipe the cache and do a clean parsing of the source tree.
    # You can do this by deleting the +.yardoc+ directory manually, or running
    # Yardoc without +--use-cache+ (+-c+).
    #
    # @since 0.2.1
    # @see Verifier
    class Yardoc < YardoptsCommand
      # @return [Hash] the hash of options passed to the template.
      # @see Templates::Engine#render
      attr_reader :options

      # @return [Array<String>] list of Ruby source files to process
      attr_accessor :files

      # @return [Array<String>] list of excluded paths (regexp matches)
      # @since 0.5.3
      attr_accessor :excluded

      # @return [Boolean] whether to use the existing yardoc db if the
      #   .yardoc already exists. Also makes use of file checksums to
      #   parse only changed files.
      attr_accessor :use_cache

      # @return [Boolean] whether objects should be serialized to .yardoc db
      attr_accessor :save_yardoc

      # @return [Boolean] whether to generate output
      attr_accessor :generate

      # @return [Boolean] whether to print a list of objects
      # @since 0.5.5
      attr_accessor :list

      # Keep track of which visibilities are to be shown
      # @return [Array<Symbol>] a list of visibilities
      # @since 0.5.6
      attr_accessor :visibilities

      # Keep track of which APIs are to be shown
      # @return [Array<String>] a list of APIs
      # @since 0.8.1
      attr_accessor :apis

      # Keep track of which APIs are to be hidden
      # @return [Array<String>] a list of APIs to be hidden
      # @since 0.8.7
      attr_accessor :hidden_apis

      # @return [Array<Symbol>] a list of tags to hide from templates
      # @since 0.6.0
      attr_accessor :hidden_tags

      # @return [Boolean] whether to print statistics after parsing
      # @since 0.6.0
      attr_accessor :statistics

      # @return [Array<String>] a list of assets to copy after generation
      # @since 0.6.0
      attr_accessor :assets

      # @return [Boolean] whether markup option was specified
      # @since 0.7.0
      attr_accessor :has_markup

      # @return [Boolean] whether yard exits with error status code if a warning occurs
      attr_accessor :fail_on_warning

      # Creates a new instance of the commandline utility
      def initialize
        super
        @options = YardocOptions.new
        @options.reset_defaults
        @visibilities = [:public]
        @apis = []
        @hidden_apis = []
        @assets = {}
        @excluded = []
        @files = []
        @hidden_tags = []
        @use_cache = false
        @generate = true
        @statistics = true
        @list = false
        @save_yardoc = true
        @has_markup = false
        @fail_on_warning = false

        if defined?(::Encoding) && ::Encoding.respond_to?(:default_external=)
          utf8 = ::Encoding.find('utf-8')

          ::Encoding.default_external = utf8 unless ::Encoding.default_external == utf8
          ::Encoding.default_internal = utf8 unless ::Encoding.default_internal == utf8
        end
      end

      def description
        "Generates documentation"
      end

      # Runs the commandline utility, parsing arguments and generating
      # output if set.
      #
      # @param [Array<String>] args the list of arguments. If the list only
      #   contains a single nil value, skip calling of {#parse_arguments}
      # @return [void]
      def run(*args)
        log.show_progress = true
        if args.empty? || !args.first.nil?
          # fail early if arguments are not valid
          return unless parse_arguments(*args)
        end

        checksums = nil
        if use_cache
          Registry.load
          checksums = Registry.checksums.dup
        end

        if save_yardoc
          Registry.lock_for_writing do
            YARD.parse(files, excluded)
            Registry.save(use_cache)
          end
        else
          YARD.parse(files, excluded)
        end

        if generate
          run_generate(checksums)
          copy_assets
        elsif list
          print_list
        end

        if !list && statistics && log.level < Logger::ERROR
          Registry.load_all
          log.enter_level(Logger::ERROR) do
            Stats.new(false).run(*args)
          end
        end

        abort if fail_on_warning && log.warned

        true
      ensure
        log.show_progress = false
      end

      # Parses commandline arguments
      # @param [Array<String>] args the list of arguments
      # @return [Boolean] whether or not arguments are valid
      # @since 0.5.6
      def parse_arguments(*args)
        super(*args)

        # Last minute modifications
        self.files = Parser::SourceParser::DEFAULT_PATH_GLOB if files.empty?
        files.delete_if {|x| x =~ /\A\s*\Z/ } # remove empty ones
        readme = Dir.glob('README{,*[^~]}').
          select {|f| extra_file_valid?(f)}.
          sort_by {|r| [r.count('.'), r.index('.'), r] }.first
        readme ||= Dir.glob(files.first).first if options.onefile && !files.empty?
        options.readme ||= CodeObjects::ExtraFileObject.new(readme) if readme && extra_file_valid?(readme)
        options.files.unshift(options.readme).uniq! if options.readme

        Tags::Library.visible_tags -= hidden_tags
        add_visibility_verifier
        add_api_verifier

        apply_locale

        # US-ASCII is invalid encoding for onefile
        if defined?(::Encoding) && options.onefile
          if ::Encoding.default_internal == ::Encoding::US_ASCII
            log.warn "--one-file is not compatible with US-ASCII encoding, using ASCII-8BIT"
            ::Encoding.default_external, ::Encoding.default_internal = ['ascii-8bit'] * 2
          end
        end

        if generate && !verify_markup_options
          false
        else
          true
        end
      end

      # The list of all objects to process. Override this method to change
      # which objects YARD should generate documentation for.
      #
      # @deprecated To hide methods use the +@private+ tag instead.
      # @return [Array<CodeObjects::Base>] a list of code objects to process
      def all_objects
        Registry.all(:root, :module, :class)
      end

      private

      # Generates output for objects
      # @param [Hash, nil] checksums if supplied, a list of checksums for files.
      # @return [void]
      # @since 0.5.1
      def run_generate(checksums)
        if checksums
          changed_files = []
          Registry.checksums.each do |file, hash|
            changed_files << file if checksums[file] != hash
          end
        end
        Registry.load_all if use_cache
        objects = run_verifier(all_objects).reject do |object|
          serialized = !options.serializer || options.serializer.exists?(object)
          if checksums && serialized && !object.files.any? {|f, _line| changed_files.include?(f) }
            true
          else
            log.debug "Re-generating object #{object.path}..."
            false
          end
        end
        Templates::Engine.generate(objects, options)
      end

      # Verifies that the markup options are valid before parsing any code.
      # Failing early is better than failing late.
      #
      # @return (see YARD::Templates::Helpers::MarkupHelper#load_markup_provider)
      def verify_markup_options
        result = false
        lvl = has_markup ? log.level : Logger::FATAL
        obj = Struct.new(:options).new(options)
        obj.extend(Templates::Helpers::MarkupHelper)
        options.files.each do |file|
          markup = file.attributes[:markup] || obj.markup_for_file('', file.filename)
          result = obj.load_markup_provider(markup)
          return false if !result && markup != :rdoc
        end
        options.markup = :rdoc unless has_markup
        log.enter_level(lvl) { result = obj.load_markup_provider }
        if !result && !has_markup
          log.warn "Could not load default RDoc formatter, " \
                   "ignoring any markup (install RDoc to get default formatting)."
          options.markup = :none
          true
        else
          result
        end
      end

      # Copies any assets to the output directory
      # @return [void]
      # @since 0.6.0
      def copy_assets
        return unless options.serializer
        outpath = options.serializer.basepath
        assets.each do |from, to|
          to = File.join(outpath, to)
          log.debug "Copying asset '#{from}' to '#{to}'"
          from += '/.' if File.directory?(from)
          FileUtils.cp_r(from, to)
        end
      end

      # Prints a list of all objects
      # @return [void]
      # @since 0.5.5
      def print_list
        Registry.load_all
        run_verifier(Registry.all).
          sort_by {|item| [item.file || '', item.line || 0] }.each do |item|
          log.puts "#{item.file}:#{item.line}: #{item.path}"
        end
      end

      # Adds a set of extra documentation files to be processed
      # @param [Array<String>] files the set of documentation files
      def add_extra_files(*files)
        files.map! {|f| f.include?("*") ? Dir.glob(f) : f }.flatten!
        files.each do |file|
          if extra_file_valid?(file)
            options.files << CodeObjects::ExtraFileObject.new(file)
          end
        end
      end

      # @param file [String] the filename to validate
      # @param check_exists [Boolean] whether the file should exist on disk
      # @return [Boolean] whether the file is allowed to be used
      def extra_file_valid?(file, check_exists = true)
        if file =~ %r{^(?:\.\./|/)}
          log.warn "Invalid file: #{file}"
          false
        elsif check_exists && !File.file?(file)
          log.warn "Could not find file: #{file}"
          false
        else
          true
        end
      end

      # Parses the file arguments into Ruby files and extra files, which are
      # separated by a '-' element.
      #
      # @example Parses a set of Ruby source files
      #   parse_files %w(file1 file2 file3)
      # @example Parses a set of Ruby files with a separator and extra files
      #   parse_files %w(file1 file2 - extrafile1 extrafile2)
      # @param [Array<String>] files the list of files to parse
      # @return [void]
      def parse_files(*files)
        seen_extra_files_marker = false

        files.each do |file|
          if file == "-"
            seen_extra_files_marker = true
            next
          end

          if seen_extra_files_marker
            add_extra_files(file)
          else
            self.files << file
          end
        end
      end

      # Adds verifier rule for visibilities
      # @return [void]
      # @since 0.5.6
      def add_visibility_verifier
        vis_expr = "#{visibilities.uniq.inspect}.include?(object.visibility)"
        options.verifier.add_expressions(vis_expr)
      end

      # Adds verifier rule for APIs
      # @return [void]
      # @since 0.8.1
      def add_api_verifier
        no_api = true if apis.delete('')
        exprs = []

        exprs << "#{apis.uniq.inspect}.include?(@api.text)" unless apis.empty?

        unless hidden_apis.empty?
          exprs << "!#{hidden_apis.uniq.inspect}.include?(@api.text)"
        end

        exprs = !exprs.empty? ? [exprs.join(' && ')] : []
        exprs << "!@api" if no_api

        expr = exprs.join(' || ')
        options.verifier.add_expressions(expr) unless expr.empty?
      end

      # Applies the specified locale to collected objects
      # @return [void]
      # @since 0.8.3
      def apply_locale
        YARD::I18n::Locale.default = options.locale
        options.files.each do |file|
          file.locale = options.locale
        end
      end

      # (see Templates::Helpers::BaseHelper#run_verifier)
      def run_verifier(list)
        options.verifier ? options.verifier.run(list) : list
      end

      # @since 0.6.0
      def add_tag(tag_data, factory_method = nil)
        tag, title = *tag_data.split(':')
        title ||= tag.capitalize
        Tags::Library.define_tag(title, tag.to_sym, factory_method)
        Tags::Library.visible_tags |= [tag.to_sym]
      end

      # Parses commandline options.
      # @param [Array<String>] args each tokenized argument
      def optparse(*args)
        opts = OptionParser.new
        opts.banner = "Usage: yard doc [options] [source_files [- extra_files]]"

        opts.separator "(if a list of source files is omitted, "
        opts.separator "  {lib,app}/**/*.rb ext/**/*.{c,rb} is used.)"
        opts.separator ""
        opts.separator "Example: yardoc -o documentation/ - FAQ LICENSE"
        opts.separator "  The above example outputs documentation for files in"
        opts.separator "  lib/**/*.rb to documentation/ including the extra files"
        opts.separator "  FAQ and LICENSE."
        opts.separator ""
        opts.separator "A base set of options can be specified by adding a .yardopts"
        opts.separator "file to your base path containing all extra options separated"
        opts.separator "by whitespace."

        general_options(opts)
        output_options(opts)
        tag_options(opts)
        common_options(opts)
        parse_options(opts, args)
        parse_files(*args) unless args.empty?
      end

      # Adds general options
      def general_options(opts)
        opts.separator ""
        opts.separator "General Options:"

        opts.on('-b', '--db FILE', 'Use a specified .yardoc db to load from or save to',
                      '  (defaults to .yardoc)') do |yfile|
          YARD::Registry.yardoc_file = yfile
        end

        opts.on('--[no-]single-db', 'Whether code objects should be stored to single',
                                    '  database file (advanced)') do |use_single_db|
          Registry.single_object_db = use_single_db
        end

        opts.on('-n', '--no-output', 'Only generate .yardoc database, no documentation.') do
          self.generate = false
        end

        opts.on('-c', '--use-cache [FILE]',
                "Use the cached .yardoc db to generate documentation.",
                "  (defaults to no cache)") do |file|
          YARD::Registry.yardoc_file = file if file
          self.use_cache = true
        end

        opts.on('--no-cache', "Clear .yardoc db before parsing source.") do
          self.use_cache = false
        end

        yardopts_options(opts)

        opts.on('--no-save', 'Do not save the parsed data to the yardoc db') do
          self.save_yardoc = false
        end

        opts.on('--exclude REGEXP', 'Ignores a file if it matches path match (regexp)') do |path|
          excluded << path
        end

        opts.on('--fail-on-warning', 'Exit with error status code if a warning occurs') do
          self.fail_on_warning = true
        end
      end

      # Adds output options
      def output_options(opts)
        opts.separator ""
        opts.separator "Output options:"

        opts.on('--one-file', 'Generates output as a single file') do
          options.onefile = true
        end

        opts.on('--list', 'List objects to standard out (implies -n)') do |_format|
          self.generate = false
          self.list = true
        end

        opts.on('--no-public', "Don't show public methods. (default shows public)") do
          visibilities.delete(:public)
        end

        opts.on('--protected', "Show protected methods. (default hides protected)") do
          visibilities.push(:protected)
        end

        opts.on('--private', "Show private methods. (default hides private)") do
          visibilities.push(:private)
        end

        opts.on('--no-private', "Hide objects with @private tag") do
          options.verifier.add_expressions '!object.tag(:private) &&
            (object.namespace.is_a?(CodeObjects::Proxy) || !object.namespace.tag(:private))'
        end

        opts.on('--[no-]api API', 'Generates documentation for a given API',
                                  '(objects which define the correct @api tag).',
                                  'If --no-api is given, displays objects with',
                                  'no @api tag.') do |api|
          api = '' if api == false
          apis.push(api)
        end

        opts.on('--hide-api API', 'Hides given @api tag from documentation') do |api|
          hidden_apis.push(api)
        end

        opts.on('--embed-mixins', "Embeds mixin methods into class documentation") do
          options.embed_mixins << '*'
        end

        opts.on('--embed-mixin [MODULE]', "Embeds mixin methods from a particular",
                                          " module into class documentation") do |mod|
          options.embed_mixins << mod
        end

        opts.on('--no-highlight', "Don't highlight code blocks in output.") do
          options.highlight = false
        end

        opts.on('--default-return TYPE', "Shown if method has no return type. ",
                                         "  (defaults to 'Object')") do |type|
          options.default_return = type
        end

        opts.on('--hide-void-return', "Hides return types specified as 'void'. ",
                                      "  (default is shown)") do
          options.hide_void_return = true
        end

        opts.on('--query QUERY', "Only show objects that match a specific query") do |query|
          next if YARD::Config.options[:safe_mode]
          query.taint if query.respond_to?(:taint)
          options.verifier.add_expressions(query)
        end

        opts.on('--title TITLE', 'Add a specific title to HTML documents') do |title|
          options.title = title
        end

        opts.on('-r', '--readme FILE', '--main FILE', 'The readme file used as the title page',
                                                      '  of documentation.') do |readme|
          if extra_file_valid?(readme)
            options.readme = CodeObjects::ExtraFileObject.new(readme)
          end
        end

        opts.on('--files FILE1,FILE2,...', 'Any extra comma separated static files to be ',
                                           '  included (eg. FAQ)') do |files|
          add_extra_files(*files.split(","))
        end

        opts.on('--asset FROM[:TO]', 'A file or directory to copy over to output ',
                                     '  directory after generating') do |asset|
          from, to = *asset.split(':').map {|f| File.cleanpath(f, true) }
          to ||= from
          if extra_file_valid?(from, false) && extra_file_valid?(to, false)
            assets[from] = to
          end
        end

        opts.on('-o', '--output-dir PATH',
                'The output directory. (defaults to ./doc)') do |dir|
          options.serializer.basepath = dir
        end

        opts.on('-m', '--markup MARKUP',
                'Markup style used in documentation, like textile, ',
                '  markdown or rdoc. (defaults to rdoc)') do |markup|
          self.has_markup = true
          options.markup = markup.to_sym
        end

        opts.on('-M', '--markup-provider MARKUP_PROVIDER',
                'Overrides the library used to process markup ',
                '  formatting (specify the gem name)') do |markup_provider|
          options.markup_provider = markup_provider.to_sym
        end

        opts.on('--charset ENC', 'Character set to use when parsing files ',
                                 '  (default is system locale)') do |encoding|
          begin
            if defined?(Encoding) && Encoding.respond_to?(:default_external=)
              Encoding.default_external = encoding
              Encoding.default_internal = encoding
            end
          rescue ArgumentError => e
            raise OptionParser::InvalidOption, e
          end
        end

        opts.on('-t', '--template TEMPLATE',
                'The template to use. (defaults to "default")') do |template|
          options.template = template.to_sym
        end

        opts.on('-p', '--template-path PATH',
                'The template path to look for templates in.',
                '  (used with -t).') do |path|
          next if YARD::Config.options[:safe_mode]
          YARD::Templates::Engine.register_template_path(File.expand_path(path))
        end

        opts.on('-f', '--format FORMAT',
                'The output format for the template.',
                '  (defaults to html)') do |format|
          options.format = format.to_sym
        end

        opts.on('--no-stats', 'Don\'t print statistics') do
          self.statistics = false
        end

        opts.on('--no-progress', 'Don\'t show progress bar') do
          log.show_progress = false
        end

        opts.on('--locale LOCALE',
                'The locale for generated documentation.',
                '  (defaults to en)') do |locale|
          options.locale = locale
        end

        opts.on('--po-dir DIR',
                'The directory that has .po files.',
                "  (defaults to #{YARD::Registry.po_dir})") do |dir|
          YARD::Registry.po_dir = dir
        end
      end

      # Adds tag options
      # @since 0.6.0
      def tag_options(opts)
        opts.separator ""
        opts.separator "Tag options: (TAG:TITLE looks like: 'overload:Overloaded Method')"

        opts.on('--tag TAG:TITLE', 'Registers a new free-form metadata @tag') do |tag|
          add_tag(tag)
        end

        opts.on('--type-tag TAG:TITLE', 'Tag with an optional types field') do |tag|
          add_tag(tag, :with_types)
        end

        opts.on('--type-name-tag TAG:TITLE', 'Tag with optional types and a name field') do |tag|
          add_tag(tag, :with_types_and_name)
        end

        opts.on('--name-tag TAG:TITLE', 'Tag with a name field') do |tag|
          add_tag(tag, :with_name)
        end

        opts.on('--title-tag TAG:TITLE', 'Tag with first line as title field') do |tag|
          add_tag(tag, :with_title_and_text)
        end

        opts.on('--hide-tag TAG', 'Hides a previously defined tag from templates') do |tag|
          self.hidden_tags |= [tag.to_sym]
        end

        opts.on('--transitive-tag TAG', 'Marks a tag as transitive') do |tag|
          Tags::Library.transitive_tags |= [tag.to_sym]
        end

        opts.on('--non-transitive-tag TAG', 'Marks a tag as not transitive') do |tag|
          Tags::Library.transitive_tags -= [tag.to_sym]
        end
      end
    end
  end
end