# frozen_string_literal: true require 'optparse' require 'pathname' require 'uri' require 'jazzy/podspec_documenter' require 'jazzy/source_declaration/access_control_level' module Jazzy # rubocop:disable Metrics/ClassLength class Config # rubocop:disable Naming/AccessorMethodName class Attribute attr_reader :name, :description, :command_line, :config_file_key, :default, :parse, :per_module def initialize(name, description: nil, command_line: nil, default: nil, parse: ->(x) { x }, per_module: false) @name = name.to_s @description = Array(description) @command_line = Array(command_line) @default = default @parse = parse @config_file_key = full_command_line_name || @name @per_module = per_module end def get(config) config.method(name).call end def set_raw(config, val) config.method("#{name}=").call(val) end def set(config, val, mark_configured: true) set_raw(config, config.instance_exec(val, &parse)) config.method("#{name}_configured=").call(true) if mark_configured end def set_to_default(config) set(config, default, mark_configured: false) if default end def set_if_unconfigured(config, val) set(config, val) unless configured?(config) end def configured?(config) config.method("#{name}_configured").call end def attach_to_option_parser(config, opt) return if command_line.empty? opt.on(*command_line, *description) do |val| set(config, val) end end private def full_command_line_name long_option_names = command_line.map do |opt| Regexp.last_match(1) if opt.to_s =~ %r{ ^-- # starts with double dash (?:\[no-\])? # optional prefix for booleans ([^\s]+) # long option name }x end if long_option_name = long_option_names.compact.first long_option_name.tr('-', '_') end end end # rubocop:enable Naming/AccessorMethodName def self.config_attr(name, **opts) attr_accessor name attr_accessor "#{name}_configured" @all_config_attrs ||= [] @all_config_attrs << Attribute.new(name, **opts) end def self.alias_config_attr(name, forward, **opts) alias_method name.to_s, forward.to_s alias_method "#{name}=", "#{forward}=" alias_method "#{name}_configured", "#{forward}_configured" alias_method "#{name}_configured=", "#{forward}_configured=" @all_config_attrs << Attribute.new(name, **opts) end class << self attr_reader :all_config_attrs end attr_accessor :base_path def expand_glob_path(path) Pathname(path).expand_path(base_path) # nil means Pathname.pwd end def expand_path(path) abs_path = expand_glob_path(path) Pathname(Dir[abs_path][0] || abs_path) # Use existing filesystem spelling end def hide_swift? hide_declarations == 'swift' end def hide_objc? hide_declarations == 'objc' end # ──────── Build ──────── # rubocop:disable Layout/ArgumentAlignment config_attr :output, description: 'Folder to output the HTML docs to', command_line: ['-o', '--output FOLDER'], default: 'docs', parse: ->(o) { expand_path(o) } config_attr :clean, command_line: ['-c', '--[no-]clean'], description: ['Delete contents of output directory before running. ', 'WARNING: If --output is set to ~/Desktop, this will ' \ 'delete the ~/Desktop directory.'], default: false config_attr :objc_mode, command_line: '--[no-]objc', description: 'Generate docs for Objective-C.', default: false, per_module: true config_attr :umbrella_header, command_line: '--umbrella-header PATH', description: 'Umbrella header for your Objective-C framework.', parse: ->(uh) { expand_path(uh) }, per_module: true config_attr :framework_root, command_line: '--framework-root PATH', description: 'The root path to your Objective-C framework.', parse: ->(fr) { expand_path(fr) }, per_module: true config_attr :sdk, command_line: '--sdk [iphone|watch|appletv][os|simulator]|macosx', description: 'The SDK for which your code should be built.', default: 'macosx', per_module: true config_attr :hide_declarations, command_line: '--hide-declarations [objc|swift] ', description: 'Hide declarations in the specified language. Given that ' \ 'generating Swift docs only generates Swift declarations, ' \ 'this is useful for hiding a specific interface for ' \ 'either Objective-C or mixed Objective-C and Swift ' \ 'projects.', default: '' config_attr :keep_property_attributes, command_line: '--[no-]keep-property-attributes', description: 'Include the default Objective-C property attributes.', default: false config_attr :config_file, command_line: '--config PATH', description: ['Configuration file (.yaml or .json)', 'Default: .jazzy.yaml in source directory or ancestor'], parse: ->(cf) { expand_path(cf) } config_attr :build_tool_arguments, command_line: ['-b', '--build-tool-arguments arg1,arg2,…argN', Array], description: 'Arguments to forward to xcodebuild, swift build, or ' \ 'sourcekitten.', default: [], per_module: true config_attr :modules, command_line: ['--modules Mod1,Mod2,…ModN', Array], description: 'List of modules to document. Use the config file to set per-module ' \ "build flags, see 'Documenting multiple modules' in the README.", default: [] alias_config_attr :xcodebuild_arguments, :build_tool_arguments, command_line: ['-x', '--xcodebuild-arguments arg1,arg2,…argN', Array], description: 'Back-compatibility alias for build_tool_arguments.' config_attr :sourcekitten_sourcefile, command_line: ['-s', '--sourcekitten-sourcefile filepath1,…filepathN', Array], description: 'File(s) generated from sourcekitten output to parse', parse: ->(paths) { [paths].flatten.map { |path| expand_path(path) } }, default: [], per_module: true config_attr :source_directory, command_line: '--source-directory DIRPATH', description: 'The directory that contains the source to be documented', default: Pathname.pwd, parse: ->(sd) { expand_path(sd) }, per_module: true config_attr :symbolgraph_directory, command_line: '--symbolgraph-directory DIRPATH', description: 'A directory containing a set of Swift Symbolgraph files ' \ 'representing the module to be documented', parse: ->(sd) { expand_path(sd) }, per_module: true config_attr :excluded_files, command_line: ['-e', '--exclude filepath1,filepath2,…filepathN', Array], description: 'Source file pathnames to be excluded from documentation. ' \ 'Supports wildcards.', default: [], parse: ->(files) do Array(files).map { |f| expand_glob_path(f).to_s } end config_attr :included_files, command_line: ['-i', '--include filepath1,filepath2,…filepathN', Array], description: 'Source file pathnames to be included in documentation. ' \ 'Supports wildcards.', default: [], parse: ->(files) do Array(files).map { |f| expand_glob_path(f).to_s } end config_attr :swift_version, command_line: '--swift-version VERSION', default: nil, parse: ->(v) do if v.to_s.empty? nil elsif v.to_f < 2 raise 'jazzy only supports Swift 2.0 or later.' else v end end SWIFT_BUILD_TOOLS = %w[spm xcodebuild symbolgraph].freeze config_attr :swift_build_tool, command_line: "--swift-build-tool #{SWIFT_BUILD_TOOLS.join(' | ')}", description: 'Control whether Jazzy uses Swift Package Manager, ' \ 'xcodebuild, or swift-symbolgraph to build the module ' \ 'to be documented. By default it uses xcodebuild if ' \ 'there is a .xcodeproj file in the source directory.', parse: ->(tool) do return tool.to_sym if SWIFT_BUILD_TOOLS.include?(tool) raise "Unsupported swift_build_tool #{tool}, " \ "supported values: #{SWIFT_BUILD_TOOLS.join(', ')}" end # ──────── Metadata ──────── config_attr :author_name, command_line: ['-a', '--author AUTHOR_NAME'], description: 'Name of author to attribute in docs (e.g. Realm)', default: '' config_attr :author_url, command_line: ['-u', '--author_url URL'], description: 'Author URL of this project (e.g. https://realm.io)', default: '', parse: ->(u) { URI(u) } config_attr :module_name, command_line: ['-m', '--module MODULE_NAME'], description: 'Name of module being documented. (e.g. RealmSwift)', default: '', per_module: true config_attr :version, command_line: '--module-version VERSION', description: 'Version string to use as part of the default docs ' \ 'title and inside the docset.', default: '1.0' config_attr :title, command_line: '--title TITLE', description: 'Title to display at the top of each page, overriding the ' \ 'default generated from module name and version.', default: '' config_attr :copyright, command_line: '--copyright COPYRIGHT_MARKDOWN', description: 'copyright markdown rendered at the bottom of the docs pages' config_attr :readme_path, command_line: '--readme FILEPATH', description: 'The path to a markdown README file', parse: ->(rp) { expand_path(rp) } config_attr :readme_title, command_line: '--readme-title TITLE', description: 'The title for the README in the generated documentation' config_attr :documentation_glob, command_line: '--documentation GLOB', description: 'Glob that matches available documentation', parse: ->(dg) { Pathname.glob(dg) } config_attr :abstract_glob, command_line: '--abstract GLOB', description: 'Glob that matches available abstracts for categories', parse: ->(ag) { Pathname.glob(ag) } config_attr :podspec, command_line: '--podspec FILEPATH', description: 'A CocoaPods Podspec that describes the Swift library to ' \ 'document', parse: ->(ps) { PodspecDocumenter.create_podspec(Pathname(ps)) if ps }, default: Dir['*.podspec{,.json}'].first config_attr :pod_sources, command_line: ['--pod-sources url1,url2,…urlN', Array], description: 'A list of sources to find pod dependencies. Used only ' \ 'with --podspec when the podspec contains references to ' \ 'privately hosted pods. You must include the default pod ' \ 'source if public pods are also used.', default: [] config_attr :docset_icon, command_line: '--docset-icon FILEPATH', parse: ->(di) { expand_path(di) } config_attr :docset_path, command_line: '--docset-path DIRPATH', description: 'The relative path for the generated docset' config_attr :docset_title, command_line: '--docset-title TITLE', description: 'The title of the generated docset. A simplified version ' \ 'is used for the filenames associated with the docset. If the ' \ 'option is not set then the name of the module being documented is ' \ 'used as the docset title.' # ──────── URLs ──────── config_attr :root_url, command_line: ['-r', '--root-url URL'], description: 'Absolute URL root where these docs will be stored', # ensure trailing slash for correct URI.join() parse: ->(r) { URI(r.sub(%r{/?$}, '/')) } config_attr :dash_url, command_line: ['-d', '--dash_url URL'], description: 'Location of the dash XML feed ' \ 'e.g. https://realm.io/docsets/realm.xml)', parse: ->(d) { URI(d) } SOURCE_HOSTS = %w[github gitlab bitbucket].freeze config_attr :source_host, command_line: "--source-host #{SOURCE_HOSTS.join(' | ')}", description: ['The source-code hosting site to be linked from documentation.', 'This setting affects the logo image and link format.', "Default: 'github'"], default: 'github', parse: ->(host) do return host.to_sym if SOURCE_HOSTS.include?(host) raise "Unsupported source_host '#{host}', " \ "supported values: #{SOURCE_HOSTS.join(', ')}" end config_attr :source_host_url, command_line: ['--source-host-url URL'], description: ["URL to link from the source host's logo.", 'For example https://github.com/realm/realm-cocoa'], parse: ->(g) { URI(g) } alias_config_attr :github_url, :source_host_url, command_line: ['-g', '--github_url URL'], description: 'Back-compatibility alias for source_host_url.' config_attr :source_host_files_url, command_line: '--source-host-files-url PREFIX', description: [ "The base URL on the source host of the project's files, to link " \ 'from individual declarations.', 'For example https://github.com/realm/realm-cocoa/tree/v0.87.1', ] alias_config_attr :github_file_prefix, :source_host_files_url, command_line: '--github-file-prefix PREFIX', description: 'Back-compatibility alias for source_host_files_url' config_attr :docset_playground_url, command_line: '--docset-playground-url URL', description: 'URL of an interactive playground to demonstrate the ' \ 'framework, linked to from the docset.' # ──────── Doc generation options ──────── config_attr :disable_search, command_line: '--disable-search', description: 'Avoid generating a search index. ' \ 'Search is available in some themes.', default: false config_attr :skip_documentation, command_line: '--skip-documentation', description: 'Will skip the documentation generation phase.', default: false config_attr :min_acl, command_line: '--min-acl [private | fileprivate | internal | package | public | open]', description: 'minimum access control level to document', default: 'public', parse: ->(acl) do SourceDeclaration::AccessControlLevel.from_human_string(acl) end config_attr :skip_undocumented, command_line: '--[no-]skip-undocumented', description: "Don't document declarations that have no documentation " \ 'comments.', default: false config_attr :hide_documentation_coverage, command_line: '--[no-]hide-documentation-coverage', description: 'Hide "(X% documented)" from the generated documents', default: false config_attr :custom_categories, description: 'Custom navigation categories to replace the standard ' \ "'Classes', 'Protocols', etc. Types not explicitly named " \ 'in a custom category appear in generic groups at the ' \ 'end. Example: https://git.io/v4Bcp', default: [] config_attr :custom_categories_unlisted_prefix, description: "Prefix for navigation section names that aren't " \ 'explicitly listed in `custom_categories`.', default: 'Other ' config_attr :hide_unlisted_documentation, command_line: '--[no-]hide-unlisted-documentation', description: "Don't include documentation in the sidebar from the " \ "`documentation` config value that aren't explicitly " \ 'listed in `custom_categories`.', default: false config_attr :custom_head, command_line: '--head HTML', description: 'Custom HTML to inject into .', default: '' BUILTIN_THEME_DIR = Pathname(__dir__) + 'themes' BUILTIN_THEMES = BUILTIN_THEME_DIR.children(false).map(&:to_s) config_attr :theme_directory, command_line: "--theme [#{BUILTIN_THEMES.join(' | ')} | DIRPATH]", description: "Which theme to use. Specify either 'apple' (default), " \ 'one of the other built-in theme names, or the path to ' \ 'your mustache templates and other assets for a custom ' \ 'theme.', default: 'apple', parse: ->(t) do if BUILTIN_THEMES.include?(t) BUILTIN_THEME_DIR + t else expand_path(t) end end config_attr :use_safe_filenames, command_line: '--use-safe-filenames', description: 'Replace unsafe characters in filenames with an encoded ' \ 'representation. This will reduce human readability of ' \ 'some URLs, but may be necessary for projects that ' \ 'expose filename-unfriendly functions such as /(_:_:)', default: false config_attr :template_directory, command_line: ['-t', '--template-directory DIRPATH'], description: 'DEPRECATED: Use --theme instead.', parse: ->(_) do raise '--template-directory (-t) is deprecated: use --theme instead.' end config_attr :assets_directory, command_line: '--assets-directory DIRPATH', description: 'DEPRECATED: Use --theme instead.', parse: ->(_) do raise '--assets-directory is deprecated: use --theme instead.' end config_attr :undocumented_text, command_line: '--undocumented-text UNDOCUMENTED_TEXT', description: 'Default text for undocumented symbols. The default ' \ 'is "Undocumented", put "" if no text is required', default: 'Undocumented' config_attr :separate_global_declarations, command_line: '--[no-]separate-global-declarations', description: 'Create separate pages for all global declarations ' \ "(classes, structures, enums etc.) even if they don't " \ 'have children.', default: false config_attr :include_spi_declarations, command_line: '--[no-]include-spi-declarations', description: 'Include Swift declarations marked `@_spi` even if ' \ '--min-acl is set to `public` or `open`.', default: false MERGE_MODULES = %w[all extensions none].freeze config_attr :merge_modules, command_line: "--merge-modules #{MERGE_MODULES.join(' | ')}", description: 'Control how to display declarations from multiple ' \ 'modules. `all`, the default, places all declarations of the ' \ "same kind together. `none` keeps each module's declarations " \ 'separate. `extensions` is like `none` but merges ' \ 'cross-module extensions into their extended type.', default: 'all', parse: ->(merge) do return merge.to_sym if MERGE_MODULES.include?(merge) raise "Unsupported merge_modules #{merge}, " \ "supported values: #{MERGE_MODULES.join(', ')}" end # rubocop:enable Layout/ArgumentAlignment def initialize self.class.all_config_attrs.each do |attr| attr.set_to_default(self) end end def theme_directory=(theme_directory) @theme_directory = theme_directory Doc.template_path = theme_directory + 'templates' end def self.parse! config = new config.parse_command_line config.parse_config_file PodspecDocumenter.apply_config_defaults(config.podspec, config) config.set_module_configs config.validate config end def warning(message) warn "WARNING: #{message}" end # rubocop:disable Metrics/MethodLength def parse_command_line OptionParser.new do |opt| opt.banner = 'Usage: jazzy' opt.separator '' opt.separator 'Options' self.class.all_config_attrs.each do |attr| attr.attach_to_option_parser(self, opt) end opt.on('-v', '--version', 'Print version number') do puts "jazzy version: #{Jazzy::VERSION}" exit end opt.on('-h', '--help [TOPIC]', 'Available topics:', ' usage Command line options (this help message)', ' config Configuration file options', '...or an option keyword, e.g. "dash"') do |topic| case topic when 'usage', nil puts opt when 'config' print_config_file_help else print_option_help(topic) end exit end end.parse! unless ARGV.empty? warning "Leftover unused command-line text: #{ARGV}" end end def parse_config_file config_path = locate_config_file return unless config_path self.base_path = config_path.parent puts "Using config file #{config_path}" config_file = read_config_file(config_path) attrs_by_conf_key, attrs_by_name = grouped_attributes parse_config_hash(config_file, attrs_by_conf_key, attrs_by_name) end def parse_config_hash(hash, attrs_by_conf_key, attrs_by_name, override: false) hash.each do |key, value| unless attr = attrs_by_conf_key[key] message = "Unknown config file attribute #{key.inspect}" if matching_name = attrs_by_name[key] message += " (Did you mean #{matching_name.first.config_file_key.inspect}?)" end warning message next end setter = override ? :set : :set_if_unconfigured attr.first.method(setter).call(self, value) end end # Find keyed versions of the attributes, by config file key and then name-in-code # Optional block allows filtering/overriding of attribute list. def grouped_attributes attrs = self.class.all_config_attrs attrs = yield attrs if block_given? %i[config_file_key name].map do |property| attrs.group_by(&property) end end def validate if source_host_configured && source_host_url.nil? && source_host_files_url.nil? warning 'Option `source_host` is set but has no effect without either ' \ '`source_host_url` or `source_host_files_url`.' end if modules_configured && module_name_configured raise 'Options `modules` and `module` are both set which is not supported. ' \ 'To document multiple modules, use just `modules`.' end if modules_configured && podspec_configured raise 'Options `modules` and `podspec` are both set which is not supported.' end module_configs.each(&:validate_module) end def validate_module if objc_mode && build_tool_arguments_configured && (framework_root_configured || umbrella_header_configured) warning 'Option `build_tool_arguments` is set: values passed to ' \ '`framework_root` or `umbrella_header` may be ignored.' end end # rubocop:enable Metrics/MethodLength # Module Configs # # The user can enter module information in three different ways. This # consolidates them into one view for the rest of the code. # # 1) Single module, back-compatible # --module Foo etc etc (or not given at all) # # 2) Multiple modules, simple, sharing build params # --modules Foo,Bar,Baz --source-directory Xyz # # 3) Multiple modules, custom, different build params but # inheriting others from the top level. # This is config-file only. # - modules # - module: Foo # source_directory: Xyz # build_tool_arguments: [a, b, c] # # After this we're left with `config.module_configs` that is an # array of `Config` objects. attr_reader :module_configs attr_reader :module_names def set_module_configs @module_configs = parse_module_configs @module_names = module_configs.map(&:module_name) @module_names_set = Set.new(module_names) end def module_name?(name) @module_names_set.include?(name) end def multiple_modules? @module_names.count > 1 end def parse_module_configs return [self] unless modules_configured raise 'Config file key `modules` must be an array' unless modules.is_a?(Array) if modules.first.is_a?(String) # Massage format (2) into (3) self.modules = modules.map { |mod| { 'module' => mod } } end # Allow per-module overrides of only some config options attrs_by_conf_key, attrs_by_name = grouped_attributes { |attr| attr.select(&:per_module) } modules.map do |module_hash| mod_name = module_hash['module'] || '' raise 'Missing `modules.module` config key' if mod_name.empty? dup.tap do |module_config| module_config.parse_config_hash( module_hash, attrs_by_conf_key, attrs_by_name, override: true ) end end end # For podspec query def module_name_known? module_name_configured || modules_configured end def locate_config_file return config_file if config_file source_directory.ascend do |dir| candidate = dir.join('.jazzy.yaml') return candidate if candidate.exist? end nil end def read_config_file(file) case File.extname(file) when '.json' JSON.parse(File.read(file)) when '.yaml', '.yml' YAML.safe_load(File.read(file)) else raise "Config file must be .yaml or .json, but got #{file.inspect}" end end def print_config_file_help puts <<-_EOS_ By default, jazzy looks for a file named ".jazzy.yaml" in the source directory and its ancestors. You can override the config file location with --config. (The source directory is the current working directory by default. You can override that with --source-directory.) The config file can be in YAML or JSON format. Available options are: _EOS_ .gsub(/^ +/, '') print_option_help end def print_option_help(topic = '') found = false self.class.all_config_attrs.each do |attr| match = ([attr.name] + attr.command_line).any? do |opt| opt.to_s.include?(topic) end if match found = true puts puts attr.name.to_s.tr('_', ' ').upcase puts puts " Config file: #{attr.config_file_key}" cmd_line_forms = attr.command_line.select { |opt| opt.is_a?(String) } if cmd_line_forms.any? puts " Command line: #{cmd_line_forms.join(', ')}" end puts print_attr_description(attr) end end warn "Unknown help topic #{topic.inspect}" unless found end def print_attr_description(attr) attr.description.each { |line| puts " #{line}" } if attr.default && attr.default != '' puts " Default: #{attr.default}" end end #-------------------------------------------------------------------------# # @!group Singleton # @return [Config] the current config instance creating one if needed. # def self.instance @instance ||= new end # Sets the current config instance. If set to nil the config will be # recreated when needed. # # @param [Config, Nil] the instance. # # @return [void] # class << self attr_writer :instance end # Provides support for accessing the configuration instance in other # scopes. # module Mixin def config Config.instance end end end # rubocop:enable Metrics/ClassLength end