diff --git a/.rubocop.yml b/.rubocop.yml index 8d72959f8..de26cbba0 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -221,6 +221,9 @@ Metrics/ModuleLength: Metrics/BlockLength: Enabled: false +Metrics/ParameterLists: + CountKeywordArgs: false + Style/NumericPredicate: Enabled: false diff --git a/CHANGELOG.md b/CHANGELOG.md index d41b6d65f..6ad395320 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ ##### Enhancements +* Support documentation of multiple modules in a single website. Use + `--modules` or the config-file `modules` for more control. See the + README 'Documenting multiple modules' for more details. + [John Fairhurst](https://github.com/johnfairh) + [#564](https://github.com/realm/jazzy/issues/564) + +* Improve page breadcrumbs to include parent pages and indicate source module + of extensions from other modules. + [John Fairhurst](https://github.com/johnfairh) + +* Add `--readme-title` and `--docset-title` to set the titles of the readme + docs page and the Dash docset independently of the module name. + [John Fairhurst](https://github.com/johnfairh) + * Support Swift 5.9 symbolgraph extension symbols. [John Fairhurst](https://github.com/johnfairh) [#1368](https://github.com/realm/jazzy/issues/1368) diff --git a/README.md b/README.md index 72880a414..7444f749e 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,9 @@ one you'd prefer. If this doesn't help, and you're using Xcode, then try passin extra arguments to `xcodebuild`, for example `jazzy --build-tool-arguments -scheme,MyScheme,-target,MyTarget`. +If you want to generate docs for several modules at once then see [Documenting multiple +modules](#documenting-multiple-modules). + You can set options for your project’s documentation in a configuration file, `.jazzy.yaml` by default. For a detailed explanation and an exhaustive list of all available options, run `jazzy --help config`. @@ -85,6 +88,13 @@ Jazzy understands Apple's DocC-style links too, for example: * \`\`\\`\` - a link to a specific overload of `method(_:)`. Jazzy can't tell which overload you intend and links to the first one. +If your documentation is for multiple modules then symbol name resolution works +approximately as though all the modules have been imported: you can use \`TypeName\` +to refer to a top-level type in any of the modules, or \`ModuleName.TypeName\` to +be specific. If there is an ambiguity then you can use a leading slash to +indicate that the first part of the name should be read as a module name: +\`/ModuleName.TypeName\`. + ### Math Jazzy can render math equations written in LaTeX embedded in your markdown: @@ -192,7 +202,7 @@ on troubleshooting. ### Mixed Objective-C / Swift -*This feature is new and has some rough edges.* +*This feature has some rough edges.* To generate documentation for a mixed Swift and Objective-C project you must first generate two [SourceKitten][sourcekitten] files: one for Swift and one for Objective-C. @@ -219,8 +229,6 @@ jazzy --module MyProject --sourcekitten-sourcefile swiftDoc.json,objcDoc.json ### Docs from `.swiftmodule`s or frameworks -*This feature is new: there may be crashes and mistakes. Reports welcome.* - Swift 5.3 added support for symbol graph generation from `.swiftmodule` files. Jazzy can use this to generate API documentation. This is faster than using @@ -274,6 +282,78 @@ See `swift symbolgraph-extract -help` for all the things you can pass via `--build-tool-arguments`: if your module has dependencies then you may need to add various search path options to let Swift load it. +### Documenting multiple modules + +*This feature is new, bugs and feedback welcome* + +Sometimes it's useful to document multiple modules together in the same site, +for example an app and its extensions, or an SDK that happens to be implemented +as several modules. + +Jazzy can build docs for all these together and create a single site with +search, cross-module linking, and navigation. + +#### Build configuration + +If all the modules share the same build flags then the easiest way to do this +is with `--modules`, for example `jazzy --modules ModuleA,ModuleB,ModuleC`. + +If your modules have different build flags then you have to use the config file. +For example: +```yaml +modules: + - module: ModuleA + - module: ModuleB + build_tool_arguments: + - -scheme + - SpecialScheme + - -target + - ModuleB + source_directory: ModuleBProject + - module: ModuleC + objc: true + umbrella_header: ModuleC/ModuleC.h + framework_root: ModuleC + sdk: appletvsimulator + - module: ModuleD + sourcekitten_sourcefile: [ModuleD1.json, ModuleD2.json] +``` +This describes a four-module project of which one is 'normal', one requires +special Xcode treatment, one is Objective-C, and one has prebuilt SourceKitten +JSON. + +Per-module options set at the top level are inherited by each module unless +also set locally -- but you can't set both `--module` and `--modules`. + +Jazzy doesn't support `--podspec` mode in conjunction with the multiple +modules feature. + +#### Presentation + +The `--merge-modules` flag controls how declarations from multiple modules +are arranged into categories. + +The default of `all` has Jazzy combine declarations from the modules so there +is one category of classes, one of structures, and so on. To the user this means +they do not worry about which module exports a particular type, although that +information remains available in the type's page. + +Setting `--merge-modules none` changes this so each module is a top-level +category, with the module's symbols listed under it. + +Setting `--merge-modules extensions` is like `none` except cross-module +extensions are shown as part of their extended type. For example if `ModuleA` +extends `ModuleB.SomeType` then those extension members from `ModuleA` are shown +on the `ModuleB.SomeType` page along with the rest of `SomeType`. + +You can use `--documentation` to include guides, `custom_categories` to customize +the layout with types from whichever modules you want, and `--abstract` to add +additional markdown content to the per-module category pages. + +Use the `--title`, `--readme-title`, and `--docset-title` flags to control the +top-level names of your documentation. Without these, Jazzy uses the name of one +of the modules being documented. + ### Themes Three themes are provided with jazzy: `apple` (default), `fullwidth` and `jony`. @@ -303,6 +383,10 @@ There are a few limitations: - File names must be unique from source files. - Readme should be specified separately using the `readme` option. +You can link to a guide from other guides or doc comments using the name of the page +as it appears in the site. For example, to link to the guide generated from a file +called `My Guide.md` you would write \`My Guide\`. + ### Section description abstracts | Description | Command | @@ -399,7 +483,7 @@ alphabetical by symbol name and USR; the order is stable for the same input. Jazzy does not normally create separate web pages for declarations that do not have any members -- instead they are entirely nested into their parent page. Use -the `--separate-global-declarations` flag to change this and create pages for +the `--separate-global-declarations` flag to change this and create pages for these empty types. ### Choosing the Swift language version @@ -423,6 +507,24 @@ jazzy --swift-version 5.7 DEVELOPER_DIR=/Applications/Xcode_14.app/Contents/Developer jazzy ``` +### Dash Docset Support + +As well as the browsable HTML documentation, Jazzy creates a _docset_ for use +with the [Dash][dash] app. + +By default the docset is created at `docs/docsets/ModuleName.tgz`. Use +`--docset-path` to create it somewhere else; use `--docset-title` to change +the docset's title. + +Use `--docset-playground-url` and `--docset-icon` to further customize the +docset. + +If you set both `--root-url` to be the (https://) URL where you plan to deploy +your documentation and `--version` to give your documentation a version number +then Jazzy also creates a docset feed XML file and includes an "Install in Dash" +button on the site. This lets users who are browsing your documentation on the +web install and start using the docs in Dash locally. + ## Linux Jazzy uses [SourceKitten][sourcekitten] to communicate with the Swift build @@ -469,7 +571,7 @@ See [this document](ObjectiveC.md). **Missing docset** -Jazzy only builds a docset when you set the `--module` flag. +Jazzy only builds a docset when you set the `--module` or `--modules` flag. **Unable to pass --build-tool-arguments containing commas** @@ -576,3 +678,4 @@ read [our blog](https://realm.io/news) or say hi on twitter [bundler]: https://rubygems.org/gems/bundler [mustache]: https://mustache.github.io "Mustache" [spm]: https://swift.org/package-manager/ "Swift Package Manager" +[dash]: https://kapeli.com/dash/ "Dash" diff --git a/lib/jazzy/config.rb b/lib/jazzy/config.rb index e5f387df6..42c7cf3fd 100644 --- a/lib/jazzy/config.rb +++ b/lib/jazzy/config.rb @@ -13,16 +13,17 @@ class Config # rubocop:disable Naming/AccessorMethodName class Attribute attr_reader :name, :description, :command_line, :config_file_key, - :default, :parse + :default, :parse, :per_module def initialize(name, description: nil, command_line: nil, - default: nil, parse: ->(x) { x }) + 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) @@ -134,22 +135,26 @@ def hide_objc? config_attr :objc_mode, command_line: '--[no-]objc', description: 'Generate docs for Objective-C.', - default: false + 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) } + 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) } + 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' + default: 'macosx', + per_module: true config_attr :hide_declarations, command_line: '--hide-declarations [objc|swift] ', @@ -175,6 +180,13 @@ def hide_objc? 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, @@ -185,19 +197,23 @@ def hide_objc? 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) } } + 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) } + 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) } + parse: ->(sd) { expand_path(sd) }, + per_module: true config_attr :excluded_files, command_line: ['-e', '--exclude filepath1,filepath2,…filepathN', Array], @@ -261,7 +277,8 @@ def hide_objc? config_attr :module_name, command_line: ['-m', '--module MODULE_NAME'], description: 'Name of module being documented. (e.g. RealmSwift)', - default: '' + default: '', + per_module: true config_attr :version, command_line: '--module-version VERSION', @@ -284,6 +301,10 @@ def hide_objc? 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', @@ -317,6 +338,13 @@ def hide_objc? 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, @@ -488,6 +516,23 @@ def hide_objc? '--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 @@ -507,12 +552,7 @@ def self.parse! config.parse_config_file PodspecDocumenter.apply_config_defaults(config.podspec, config) - if config.root_url - config.dash_url ||= URI.join( - config.root_url, - "docsets/#{config.module_name}.xml", - ) - end + config.set_module_configs config.validate @@ -569,11 +609,13 @@ def parse_config_file puts "Using config file #{config_path}" config_file = read_config_file(config_path) - attrs_by_conf_key, attrs_by_name = %i[config_file_key name].map do |prop| - self.class.all_config_attrs.group_by(&prop) - end + attrs_by_conf_key, attrs_by_name = grouped_attributes - config_file.each do |key, value| + 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] @@ -583,11 +625,19 @@ def parse_config_file warning message next end - - attr.first.set_if_unconfigured(self, value) + setter = override ? :set : :set_if_unconfigured + attr.first.method(setter).call(self, value) end + end - self.base_path = nil + # 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 @@ -598,6 +648,19 @@ def validate '`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) @@ -608,6 +671,76 @@ def validate # 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 { { 'module' => _1 } } + end + + # Allow per-module overrides of only some config options + attrs_by_conf_key, attrs_by_name = + grouped_attributes { _1.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 @@ -615,7 +748,6 @@ def locate_config_file candidate = dir.join('.jazzy.yaml') return candidate if candidate.exist? end - nil end diff --git a/lib/jazzy/doc.rb b/lib/jazzy/doc.rb index 69b18842b..3add20c82 100644 --- a/lib/jazzy/doc.rb +++ b/lib/jazzy/doc.rb @@ -48,9 +48,9 @@ def docs_title elsif config.version_configured # Fake version for integration tests version = ENV['JAZZY_FAKE_MODULE_VERSION'] || config.version - "#{config.module_name} #{version} Docs" + "#{config.module_configs.first.module_name} #{version} Docs" else - "#{config.module_name} Docs" + "#{config.module_configs.first.module_name} Docs" end end diff --git a/lib/jazzy/doc_builder.rb b/lib/jazzy/doc_builder.rb index 88aef44b4..b7e4b7eca 100644 --- a/lib/jazzy/doc_builder.rb +++ b/lib/jazzy/doc_builder.rb @@ -67,32 +67,32 @@ def self.children_for_doc(doc) # Build documentation from the given options # @param [Config] options - # @return [SourceModule] the documented source module def self.build(options) - if options.sourcekitten_sourcefile_configured - stdout = "[#{options.sourcekitten_sourcefile.map(&:read).join(',')}]" - elsif options.podspec_configured - pod_documenter = PodspecDocumenter.new(options.podspec) - stdout = pod_documenter.sourcekitten_output(options) - elsif options.swift_build_tool == :symbolgraph - stdout = SymbolGraph.build(options) - else - stdout = Dir.chdir(options.source_directory) do - arguments = SourceKitten.arguments_from_options(options) - SourceKitten.run_sourcekitten(arguments) + module_jsons = options.module_configs.map do |module_config| + if module_config.podspec_configured + # Config#validate guarantees not multi-module here + pod_documenter = PodspecDocumenter.new(options.podspec) + pod_documenter.sourcekitten_output(options) + elsif !module_config.sourcekitten_sourcefile.empty? + "[#{module_config.sourcekitten_sourcefile.map(&:read).join(',')}]" + elsif module_config.swift_build_tool == :symbolgraph + SymbolGraph.build(module_config) + else + Dir.chdir(module_config.source_directory) do + arguments = SourceKitten.arguments_from_options(module_config) + SourceKitten.run_sourcekitten(arguments) + end end end - build_docs_for_sourcekitten_output(stdout, options) + build_docs_for_sourcekitten_output(module_jsons, options) end # Build & write HTML docs to disk from structured docs array # @param [String] output_dir Root directory to write docs - # @param [Array] docs Array of structured docs - # @param [Config] options Build options - # @param [Array] doc_structure @see #doc_structure_for_docs - def self.build_docs(output_dir, docs, source_module) - each_doc(output_dir, docs) do |doc, path| + # @param [SourceModule] source_module All info to generate docs + def self.build_docs(output_dir, source_module) + each_doc(output_dir, source_module.docs) do |doc, path| prepare_output_dir(path.parent, false) depth = path.relative_path_from(output_dir).each_filename.count - 1 path_to_root = '../' * depth @@ -124,10 +124,13 @@ def self.build_site(docs, coverage, options) docs << SourceDocument.make_index(options.readme_path) - source_module = SourceModule.new(options, docs, structure, coverage) - output_dir = options.output - build_docs(output_dir, source_module.docs, source_module) + + docset_builder = DocsetBuilder.new(output_dir) + + source_module = SourceModule.new(docs, structure, coverage, docset_builder) + + build_docs(output_dir, source_module) unless options.disable_search warn 'building search index' @@ -137,7 +140,7 @@ def self.build_site(docs, coverage, options) copy_extensions(source_module, output_dir) copy_theme_assets(output_dir) - DocsetBuilder.new(output_dir, source_module).build! + docset_builder.build!(source_module.all_declarations) generate_badge(source_module.doc_coverage, options) @@ -148,14 +151,12 @@ def self.build_site(docs, coverage, options) end # Build docs given sourcekitten output - # @param [String] sourcekitten_output Output of sourcekitten command + # @param [Array] sourcekitten_output Output of sourcekitten command for each module # @param [Config] options Build options - # @return [SourceModule] the documented source module def self.build_docs_for_sourcekitten_output(sourcekitten_output, options) (docs, stats) = SourceKitten.parse( sourcekitten_output, - options.min_acl, - options.skip_undocumented, + options, DocumentationGenerator.source_docs, ) @@ -249,7 +250,8 @@ def self.new_document(source_module, doc_model) doc[:doc_coverage] = source_module.doc_coverage unless Config.instance.hide_documentation_coverage doc[:structure] = source_module.doc_structure - doc[:module_name] = source_module.name + doc[:readme_title] = source_module.readme_title + doc[:module_name] = doc[:readme_title] doc[:author_name] = source_module.author_name if source_host = source_module.host doc[:source_host_name] = source_host.name @@ -259,7 +261,8 @@ def self.new_document(source_module, doc_model) doc[:github_url] = doc[:source_host_url] doc[:github_token_url] = doc[:source_host_item_url] end - doc[:dash_url] = source_module.dash_url + doc[:dash_url] = source_module.dash_feed_url + doc[:breadcrumbs] = make_breadcrumbs(doc_model) end end @@ -269,7 +272,7 @@ def self.new_document(source_module, doc_model) # @param [String] path_to_root def self.document_markdown(source_module, doc_model, path_to_root) doc = new_document(source_module, doc_model) - name = doc_model.name == 'index' ? source_module.name : doc_model.name + name = doc_model.readme? ? source_module.readme_title : doc_model.name doc[:name] = name doc[:overview] = render(doc_model, doc_model.content(source_module)) doc[:path_to_root] = path_to_root @@ -447,5 +450,28 @@ def self.document(source_module, doc_model, path_to_root) doc.render.gsub(ELIDED_AUTOLINK_TOKEN, path_to_root) end # rubocop:enable Metrics/MethodLength + + # Breadcrumbs for a page - doesn't include the top 'readme' crumb + def self.make_breadcrumbs(doc_model) + return [] if doc_model.readme? + + docs_path = doc_model.docs_path + breadcrumbs = docs_path.map do |doc| + { + name: doc.name, + url: doc.url, + last: doc == doc_model, + } + end + + return breadcrumbs if breadcrumbs.count == 1 + + # Add the module name to the outer type if not clear from context + if docs_path[1].ambiguous_module_name?(docs_path[0].name) + breadcrumbs[1][:name] = docs_path[1].fully_qualified_module_name + end + + breadcrumbs + end end end diff --git a/lib/jazzy/doc_index.rb b/lib/jazzy/doc_index.rb new file mode 100644 index 000000000..844a578e1 --- /dev/null +++ b/lib/jazzy/doc_index.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +module Jazzy + # This class stores an index of symbol names for doing name lookup + # when resolving custom categories and autolinks. + class DocIndex + # A node in the index tree. The root has no decl; its children are + # per-module indexed by module names. The second level, where each + # scope is a module, also has no decl; its children are scopes, one + # for each top-level decl in the module. From the third level onwards + # the decl is valid. + class Scope + attr_reader :decl # SourceDeclaration + attr_reader :children # String:Scope + + def initialize(decl, children) + @decl = decl + @children = children + end + + def self.new_root(module_decls) + new(nil, + module_decls.transform_values do |decls| + Scope.new_decl(nil, decls) + end) + end + + # Decl names in a scope are usually unique. The exceptions + # are (1) methods and (2) typealias+extension, which historically + # jazzy does not merge. The logic here and in `merge()` below + # preserves the historical ambiguity-resolution of (1) and tries + # to do the best for (2). + def self.new_decl(decl, child_decls) + child_scopes = {} + child_decls.flat_map do |child_decl| + child_scope = Scope.new_decl(child_decl, child_decl.children) + child_decl.index_names.map do |name| + if curr = child_scopes[name] + curr.merge(child_scope) + else + child_scopes[name] = child_scope + end + end + end + new(decl, child_scopes) + end + + def merge(new_scope) + return unless type = decl&.type + return unless new_type = new_scope.decl&.type + + if type.swift_typealias? && new_type.swift_extension? + @children = new_scope.children + elsif type.swift_extension? && new_type.swift_typealias? + @decl = new_scope.decl + end + end + + # Lookup of a name like `Mod.Type.method(arg:)` requires passing + # an array of name 'parts' eg. ['Mod', 'Type', 'method(arg:)']. + def lookup(parts) + return decl if parts.empty? + + children[parts.first]&.lookup(parts[1...]) + end + + # Get an array of scopes matching the name parts. + def lookup_path(parts) + [self] + + (children[parts.first]&.lookup_path(parts[1...]) || []) + end + end + + attr_reader :root_scope + + def initialize(all_decls) + @root_scope = Scope.new_root(all_decls.group_by(&:module_name)) + end + + # Look up a name and return the matching SourceDeclaration or nil. + # + # `context` is an optional SourceDeclaration indicating where the text + # was found, affects name resolution - see `lookup_context()` below. + def lookup(name, context = nil) + lookup_name = LookupName.new(name) + + return lookup_fully_qualified(lookup_name) if lookup_name.fully_qualified? + return lookup_guess(lookup_name) if context.nil? + + lookup_context(lookup_name, context) + end + + private + + # Look up a fully-qualified name, ie. it starts with the module name. + def lookup_fully_qualified(lookup_name) + root_scope.lookup(lookup_name.parts) + end + + # Look up a top-level name best-effort, searching for a module that + # has it before trying the first name-part as a module name. + def lookup_guess(lookup_name) + root_scope.children.each_value do |module_scope| + if result = module_scope.lookup(lookup_name.parts) + return result + end + end + + lookup_fully_qualified(lookup_name) + end + + # Look up a name from a declaration context, approximately how + # Swift resolves names. + # + # 1 - try and resolve with a common prefix, eg. 'B' from 'T.A' + # can match 'T.B', or 'R' from 'S.T.A' can match 'S.R'. + # 2 - try and resolve as a top-level symbol from a different module + # 3 - (affordance for docs writers) resolve as a child of the context, + # eg. 'B' from 'T.A' can match 'T.A.B' *only if* (1,2) fail. + # Currently disabled for Swift for back-compatibility. + def lookup_context(lookup_name, context) + context_scope_path = + root_scope.lookup_path(context.fully_qualified_module_name_parts) + + context_scope = context_scope_path.pop + context_scope_path.reverse.each do |scope| + if decl = scope.lookup(lookup_name.parts) + return decl + end + end + + lookup_guess(lookup_name) || + (lookup_name.objc? && context_scope.lookup(lookup_name.parts)) + end + + # Helper for name lookup, really a cache for information as we + # try various strategies. + class LookupName + attr_reader :name + + def initialize(name) + @name = name + end + + def fully_qualified? + name.start_with?('/') + end + + def objc? + name.start_with?('-', '+') + end + + def parts + @parts ||= find_parts + end + + private + + # Turn a name as written into a list of components to + # be matched. + # Swift: Strip out odd characters and split + # ObjC: Compound names look like '+[Class(Category) method:]' + # and need to become ['Class(Category)', '+method:'] + def find_parts + if name =~ /([+-])\[(\w+(?: ?\(\w+\))?) ([\w:]+)\]/ + [Regexp.last_match[2], + Regexp.last_match[1] + Regexp.last_match[3]] + else + name + .sub(%r{^[@\/]}, '') # ignore custom attribute reference, fully-qualified + .gsub(/<.*?>/, '') # remove generic parameters + .split(%r{(?#{config.version}#{url}" \ "\n" end end + + # The web URL where the user intends to place the docset XML file. + def dash_url + return nil unless config.dash_url || config.root_url + + config.dash_url || + URI.join( + config.root_url, + "docsets/#{safe_name}.xml", + ) + end + + public + + # The dash-feed:// URL that links from the Dash icon in generated + # docs. This is passed to the Dash app and encodes the actual web + # `dash_url` where the user has placed the XML file. + # + # Unfortunately for historical reasons this is *also* called the + # 'dash_url' where it appears in mustache templates and so on. + def dash_feed_url + dash_url&.then do |url| + "dash-feed://#{ERB::Util.url_encode(url.to_s)}" + end + end end end end diff --git a/lib/jazzy/docset_builder/info_plist.mustache b/lib/jazzy/docset_builder/info_plist.mustache index dca719618..adc1712f2 100644 --- a/lib/jazzy/docset_builder/info_plist.mustache +++ b/lib/jazzy/docset_builder/info_plist.mustache @@ -3,7 +3,7 @@ CFBundleIdentifier - com.jazzy.{{lowercase_name}} + com.jazzy.{{lowercase_safe_name}} CFBundleName {{name}} DocSetPlatformFamily diff --git a/lib/jazzy/grouper.rb b/lib/jazzy/grouper.rb new file mode 100644 index 000000000..a2c96e251 --- /dev/null +++ b/lib/jazzy/grouper.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +module Jazzy + # This module deals with arranging top-level declarations and guides into + # groups automatically and/or using a custom list. + module Grouper + extend Config::Mixin + + # Group root-level docs by custom categories (if any) and type or module + def self.group_docs(docs, doc_index) + custom_categories, docs = group_custom_categories(docs, doc_index) + unlisted_prefix = config.custom_categories_unlisted_prefix + type_category_prefix = custom_categories.any? ? unlisted_prefix : '' + all_categories = + custom_categories + + if config.merge_modules == :all + group_docs_by_type(docs, type_category_prefix) + else + group_docs_by_module(docs, type_category_prefix) + end + merge_consecutive_marks(all_categories) + end + + # Group root-level docs by type + def self.group_docs_by_type(docs, type_category_prefix) + type_groups = SourceDeclaration::Type.all.map do |type| + children, docs = docs.partition { _1.type == type } + make_type_group(children, type, type_category_prefix) + end + merge_categories(type_groups.compact) + docs + end + + # Group root-level docs by module name + def self.group_docs_by_module(docs, type_category_prefix) + guide_categories, docs = group_guides(docs, type_category_prefix) + + module_categories = docs + .group_by(&:doc_module_name) + .map do |name, module_docs| + make_group( + module_docs, + name, + "The following declarations are provided by module #{name}.", + ) + end + + guide_categories + module_categories + end + + def self.group_custom_categories(docs, doc_index) + group = config.custom_categories.map do |category| + children = category['children'].filter_map do |name| + unless doc = doc_index.lookup(name) + warn 'WARNING: No documented top-level declarations match ' \ + "name \"#{name}\" specified in categories file" + next nil + end + + unless doc.parent_in_code.nil? + warn "WARNING: Declaration \"#{doc.fully_qualified_module_name}\" " \ + 'specified in categories file exists but is not top-level and ' \ + 'cannot be included here' + next nil + end + + docs.delete(doc) + end + # Category config overrides alphabetization + children.each.with_index { |child, i| child.nav_order = i } + make_group(children, category['name'], '') + end + [group.compact, docs] + end + + def self.group_guides(docs, prefix) + guides, others = docs.partition { _1.type.markdown? } + return [[], others] unless guides.any? + + [[make_type_group(guides, guides.first.type, prefix)], others] + end + + def self.make_type_group(docs, type, type_category_prefix) + make_group( + docs, + type_category_prefix + type.plural_name, + "The following #{type.plural_name.downcase} are available globally.", + type_category_prefix + type.plural_url_name, + ) + end + + # Join categories with the same name (eg. ObjC and Swift classes) + def self.merge_categories(categories) + merged = [] + categories.each do |new_category| + if existing = merged.find { _1.name == new_category.name } + existing.children += new_category.children + else + merged.append(new_category) + end + end + merged + end + + def self.make_group(group, name, abstract, url_name = nil) + group.reject! { _1.name.empty? } + unless group.empty? + SourceDeclaration.new.tap do |sd| + sd.type = SourceDeclaration::Type.overview + sd.name = name + sd.url_name = url_name + sd.abstract = Markdown.render(abstract) + sd.children = group + end + end + end + + # Merge consecutive sections with the same mark into one section + # Needed because of pulling various decls into groups + def self.merge_consecutive_marks(docs) + prev_mark = nil + docs.each do |doc| + if prev_mark&.can_merge?(doc.mark) + doc.mark = prev_mark + end + prev_mark = doc.mark + merge_consecutive_marks(doc.children) + end + end + end +end diff --git a/lib/jazzy/podspec_documenter.rb b/lib/jazzy/podspec_documenter.rb index c6a3587ec..382d40e33 100644 --- a/lib/jazzy/podspec_documenter.rb +++ b/lib/jazzy/podspec_documenter.rb @@ -52,7 +52,7 @@ def self.apply_config_defaults(podspec, config) config.author_name = author_name(podspec) config.author_name_configured = true end - unless config.module_name_configured + unless config.module_name_known? config.module_name = podspec.module_name config.module_name_configured = true end diff --git a/lib/jazzy/source_declaration.rb b/lib/jazzy/source_declaration.rb index 7643b3351..4fe2c7206 100644 --- a/lib/jazzy/source_declaration.rb +++ b/lib/jazzy/source_declaration.rb @@ -62,6 +62,7 @@ def namespace_ancestors end end + # 'OuterType.NestedType.method(arg:)' def fully_qualified_name namespace_path.map(&:name).join('.') end @@ -74,6 +75,21 @@ def fully_qualified_name_regexp .join('(?:<.*?>)?\.')) end + def fully_qualified_module_name_parts + path = namespace_path + path.map(&:name).prepend(path.first.module_name).compact + end + + # 'MyModule.OuterType.NestedType.method(arg:)' + def fully_qualified_module_name + fully_qualified_module_name_parts.join('.') + end + + # List of doc_parent decls, .last is self + def docs_path + (parent_in_docs&.docs_path || []) + [self] + end + # If this declaration is an objc category, returns an array with the name # of the extended objc class and the category name itself, i.e. # ["NSString", "MyMethods"], nil otherwise. @@ -114,7 +130,7 @@ def display_other_language_declaration attr_accessor :column attr_accessor :usr attr_accessor :type_usr - attr_accessor :modulename + attr_accessor :module_name attr_accessor :name attr_accessor :objc_name attr_accessor :declaration @@ -140,6 +156,11 @@ def display_other_language_declaration attr_accessor :inherited_types attr_accessor :async + # The name of the module being documented that contains this + # declaration. Only different from module_name when this is + # an extension of a type from another module. Nil for guides. + attr_accessor :doc_module_name + def usage_discouraged? unavailable || deprecated end @@ -183,20 +204,60 @@ def other_inherited_types?(unwanted) inherited_types.any? { |t| !unwanted.include?(t) } end - # Pre-Swift 5.6: SourceKit only sets modulename for imported modules - # Swift 5.6+: modulename is always set + # Pre-Swift 5.6: SourceKit only sets module_name for imported modules + # Swift 5.6+: module_name is always set def type_from_doc_module? !type.extension? || (swift? && usr && - (modulename.nil? || modulename == Config.instance.module_name)) + (module_name.nil? || module_name == doc_module_name)) + end + + # Don't ask the user to write documentation for types being extended + # from other modules. Compile errors leave no docs and a `nil` USR. + def mark_undocumented? + !swift? || (usr && !extension_of_external_type?) + end + + def extension_of_external_type? + !module_name.nil? && + !Config.instance.module_name?(module_name) + end + + # Is it unclear from context what module the (top-level) decl is from? + def ambiguous_module_name?(group_name) + extension_of_external_type? || + (Config.instance.multiple_modules? && + !module_name.nil? && + group_name != module_name) + end + + # Does the user need help understanding how to get this declaration? + def need_doc_module_note? + return false unless Config.instance.multiple_modules? + return false if docs_path.first.name == doc_module_name + + if parent_in_code.nil? + # Top-level decls with no page of their own + !render_as_page? + else + # Members added by extension + parent_in_code.module_name != doc_module_name + end end # Info text for contents page by collapsed item name def declaration_note - notes = [default_impl_abstract ? 'default implementation' : nil, - from_protocol_extension ? 'extension method' : nil, - async ? 'asynchronous' : nil].compact - notes.join(', ').humanize unless notes.empty? + notes = [ + default_impl_abstract ? 'default implementation' : nil, + from_protocol_extension ? 'extension method' : nil, + async ? 'asynchronous' : nil, + need_doc_module_note? ? "from #{doc_module_name}" : nil, + ].compact + notes.join(', ').upcase_first unless notes.empty? + end + + def readme? + false end def alternative_abstract diff --git a/lib/jazzy/source_declaration/type.rb b/lib/jazzy/source_declaration/type.rb index 5f2b4ddd9..809917664 100644 --- a/lib/jazzy/source_declaration/type.rb +++ b/lib/jazzy/source_declaration/type.rb @@ -164,8 +164,14 @@ def objc_unexposed? kind == 'sourcekitten.source.lang.objc.decl.unexposed' end + OVERVIEW_KIND = 'Overview' + def self.overview - Type.new('Overview') + Type.new(OVERVIEW_KIND) + end + + def overview? + kind == OVERVIEW_KIND end MARKDOWN_KIND = 'document.markdown' @@ -194,7 +200,8 @@ def ==(other) dash: 'Guide', }.freeze, - 'Overview' => { + # Group/Overview + OVERVIEW_KIND => { jazzy: nil, dash: 'Section', }.freeze, diff --git a/lib/jazzy/source_document.rb b/lib/jazzy/source_document.rb index 06667a890..f2bc4e113 100644 --- a/lib/jazzy/source_document.rb +++ b/lib/jazzy/source_document.rb @@ -27,6 +27,10 @@ def self.make_index(readme_path) end end + def readme? + url == 'index.html' + end + def render_as_page? true end @@ -89,7 +93,7 @@ def generated_readme(source_module) README else <<-README -# #{source_module.name} +# #{source_module.readme_title} ### Authors diff --git a/lib/jazzy/source_module.rb b/lib/jazzy/source_module.rb index c0cc63376..f4c3fe69c 100644 --- a/lib/jazzy/source_module.rb +++ b/lib/jazzy/source_module.rb @@ -7,28 +7,30 @@ require 'jazzy/source_host' module Jazzy + # A cache of info that is common across all page templating, gathered + # from other parts of the program. class SourceModule - attr_accessor :name + include Config::Mixin + + attr_accessor :readme_title attr_accessor :docs attr_accessor :doc_coverage attr_accessor :doc_structure attr_accessor :author_name attr_accessor :author_url - attr_accessor :dash_url + attr_accessor :dash_feed_url attr_accessor :host - def initialize(options, docs, doc_structure, doc_coverage) + def initialize(docs, doc_structure, doc_coverage, docset_builder) self.docs = docs self.doc_structure = doc_structure self.doc_coverage = doc_coverage - self.name = options.module_name - self.author_name = options.author_name - self.author_url = options.author_url - self.host = SourceHost.create(options) - return unless options.dash_url - - self.dash_url = - "dash-feed://#{ERB::Util.url_encode(options.dash_url.to_s)}" + title = config.readme_title || config.module_names.first + self.readme_title = title.empty? ? 'Index' : title + self.author_name = config.author_name + self.author_url = config.author_url + self.host = SourceHost.create(config) + self.dash_feed_url = docset_builder.dash_feed_url end def all_declarations diff --git a/lib/jazzy/sourcekitten.rb b/lib/jazzy/sourcekitten.rb index d0a43f8c2..24e2c98b7 100644 --- a/lib/jazzy/sourcekitten.rb +++ b/lib/jazzy/sourcekitten.rb @@ -13,6 +13,8 @@ require 'jazzy/source_declaration' require 'jazzy/source_mark' require 'jazzy/stats' +require 'jazzy/grouper' +require 'jazzy/doc_index' ELIDED_AUTOLINK_TOKEN = '36f8f5912051ae747ef441d6511ca4cb' @@ -60,83 +62,9 @@ def self.undocumented_abstract ).freeze end - # Group root-level docs by custom categories (if any) and type - def self.group_docs(docs) - custom_categories, docs = group_custom_categories(docs) - unlisted_prefix = Config.instance.custom_categories_unlisted_prefix - type_categories, uncategorized = group_type_categories( - docs, custom_categories.any? ? unlisted_prefix : '' - ) - custom_categories + merge_categories(type_categories) + uncategorized - end - - def self.group_custom_categories(docs) - group = Config.instance.custom_categories.map do |category| - children = category['children'].flat_map do |name| - docs_with_name, docs = docs.partition { |doc| doc.name == name } - if docs_with_name.empty? - warn 'WARNING: No documented top-level declarations match ' \ - "name \"#{name}\" specified in categories file" - end - docs_with_name - end - # Category config overrides alphabetization - children.each.with_index { |child, i| child.nav_order = i } - make_group(children, category['name'], '') - end - [group.compact, docs] - end - - def self.group_type_categories(docs, type_category_prefix) - group = SourceDeclaration::Type.all.map do |type| - children, docs = docs.partition { |doc| doc.type == type } - make_group( - children, - type_category_prefix + type.plural_name, - "The following #{type.plural_name.downcase} are available globally.", - type_category_prefix + type.plural_url_name, - ) - end - [group.compact, docs] - end - - # Join categories with the same name (eg. ObjC and Swift classes) - def self.merge_categories(categories) - merged = [] - categories.each do |new_category| - if existing = merged.find { |c| c.name == new_category.name } - existing.children += new_category.children - else - merged.append(new_category) - end - end - merged - end - - def self.make_group(group, name, abstract, url_name = nil) - group.reject! { |doc| doc.name.empty? } - unless group.empty? - SourceDeclaration.new.tap do |sd| - sd.type = SourceDeclaration::Type.overview - sd.name = name - sd.url_name = url_name - sd.abstract = Markdown.render(abstract) - sd.children = group - end - end - end - - # Merge consecutive sections with the same mark into one section - def self.merge_consecutive_marks(docs) - prev_mark = nil - docs.each do |doc| - if prev_mark&.can_merge?(doc.mark) - doc.mark = prev_mark - end - prev_mark = doc.mark - merge_consecutive_marks(doc.children) - end - end + # + # URL assignment + # def self.sanitize_filename(doc) unsafe_filename = doc.docs_filename @@ -149,7 +77,7 @@ def self.sanitize_filename(doc) end # rubocop:disable Metrics/MethodLength - # Generate doc URL by prepending its parents URLs + # Generate doc URL by prepending its parents' URLs # @return [Hash] input docs with URLs def self.make_doc_urls(docs) docs.each do |doc| @@ -189,18 +117,50 @@ def self.make_doc_urls(docs) # Guides in the root for back-compatibility. # Declarations under outer namespace type (Structures, Classes, etc.) def self.subdir_for_doc(doc) - return [] if doc.type.markdown? - - top_level_decl = doc.namespace_path.first - if top_level_decl.type.name - [top_level_decl.type.plural_url_name] + - doc.namespace_ancestors.map(&:name) + if Config.instance.multiple_modules? + subdir_for_doc_multi_module(doc) else - # Category - in the root - [] + # Back-compatibility layout version + subdir_for_doc_single_module(doc) end end + # Pre-multi-module site layout, does not allow for + # types with the same name. + def self.subdir_for_doc_single_module(doc) + # Guides + Groups in the root + return [] if doc.type.markdown? || doc.type.overview? + + [doc.namespace_path.first.type.plural_url_name] + + doc.namespace_ancestors.map(&:name) + end + + # Multi-module site layout, separate each module that + # is being documented. + def self.subdir_for_doc_multi_module(doc) + # Guides + Groups in the root + return [] if doc.type.markdown? || doc.type.overview? + + root_decl = doc.namespace_path.first + + # Extensions need an extra dir to allow for extending + # ExternalModule1.TypeName and ExternalModule2.TypeName + namespace_subdir = + if root_decl.type.swift_extension? + ['Extensions', root_decl.module_name] + else + [doc.namespace_path.first.type.plural_url_name] + end + + [root_decl.doc_module_name] + + namespace_subdir + + doc.namespace_ancestors.map(&:name) + end + + # + # CLI argument calculation + # + # returns all subdirectories of specified path def self.rec_path(path) path.children.collect do |child| @@ -210,42 +170,42 @@ def self.rec_path(path) end.select { |x| x }.flatten(1) end - def self.use_spm?(options) - options.swift_build_tool == :spm || - (!options.swift_build_tool_configured && + def self.use_spm?(module_config) + module_config.swift_build_tool == :spm || + (!module_config.swift_build_tool_configured && Dir['*.xcodeproj', '*.xcworkspace'].empty? && - !options.build_tool_arguments.include?('-project') && - !options.build_tool_arguments.include?('-workspace')) + !module_config.build_tool_arguments.include?('-project') && + !module_config.build_tool_arguments.include?('-workspace')) end # Builds SourceKitten arguments based on Jazzy options - def self.arguments_from_options(options) + def self.arguments_from_options(module_config) arguments = ['doc'] - if options.objc_mode - arguments += objc_arguments_from_options(options) + if module_config.objc_mode + arguments += objc_arguments_from_options(module_config) else - arguments += ['--spm'] if use_spm?(options) - unless options.module_name.empty? - arguments += ['--module-name', options.module_name] + arguments += ['--spm'] if use_spm?(module_config) + unless module_config.module_name.empty? + arguments += ['--module-name', module_config.module_name] end arguments += ['--'] end - arguments + options.build_tool_arguments + arguments + module_config.build_tool_arguments end - def self.objc_arguments_from_options(options) + def self.objc_arguments_from_options(module_config) arguments = [] - if options.build_tool_arguments.empty? - arguments += ['--objc', options.umbrella_header.to_s, '--', '-x', + if module_config.build_tool_arguments.empty? + arguments += ['--objc', module_config.umbrella_header.to_s, '--', '-x', 'objective-c', '-isysroot', - `xcrun --show-sdk-path --sdk #{options.sdk}`.chomp, - '-I', options.framework_root.to_s, + `xcrun --show-sdk-path --sdk #{module_config.sdk}`.chomp, + '-I', module_config.framework_root.to_s, '-fmodules'] end # add additional -I arguments for each subdirectory of framework_root - unless options.framework_root.nil? - rec_path(Pathname.new(options.framework_root.to_s)).collect do |child| + unless module_config.framework_root.nil? + rec_path(Pathname.new(module_config.framework_root.to_s)).collect do |child| if child.directory? arguments += ['-I', child.to_s] end @@ -270,6 +230,10 @@ def self.run_sourcekitten(arguments) output end + # + # SourceDeclaration generation + # + def self.make_default_doc_info(declaration) # @todo: Fix these declaration.abstract = '' @@ -352,18 +316,10 @@ def self.should_document_swift_extension?(doc) end end - # Call things undocumented if they were compiled properly - # and came from our module. - def self.should_mark_undocumented(declaration) - declaration.usr && - (declaration.modulename.nil? || - declaration.modulename == Config.instance.module_name) - end - def self.process_undocumented_token(doc, declaration) make_default_doc_info(declaration) - if !declaration.swift? || should_mark_undocumented(declaration) + if declaration.mark_undocumented? @stats.add_undocumented(declaration) return nil if @skip_undocumented @@ -621,7 +577,15 @@ def self.make_source_declarations(docs, parent = nil, mark = SourceMark.new) declaration.file = Pathname(doc['key.filepath']) if doc['key.filepath'] declaration.usr = doc['key.usr'] declaration.type_usr = doc['key.typeusr'] - declaration.modulename = doc['key.modulename'] + declaration.module_name = + if declaration.swift? + # Filter out Apple sub-framework implementation names + doc['key.modulename']&.sub(/\..*$/, '') + else + # ObjC best effort, category original module is unavailable + @current_module_name + end + declaration.doc_module_name = @current_module_name declaration.name = documented_name declaration.mark = current_mark declaration.access_control_level = @@ -665,6 +629,10 @@ def self.find_generic_requirements(parsed_declaration) Regexp.last_match[1].gsub(/\s+/, ' ') end + # + # SourceDeclaration generation - extension management + # + # Expands extensions of nested types declared at the top level into # a tree so they can be deduplicated properly def self.expand_extensions(decls) @@ -689,7 +657,8 @@ def self.expand_extension(extension, name_parts, decls) SourceDeclaration.new.tap do |decl| make_default_doc_info(decl) decl.name = name - decl.modulename = extension.modulename + decl.module_name = extension.module_name + decl.doc_module_name = extension.doc_module_name decl.type = extension.type decl.mark = extension.mark decl.usr = candidates.first.usr unless candidates.empty? @@ -720,9 +689,10 @@ def self.deduplicate_declarations(declarations) # Returns true if an Objective-C declaration is mergeable. def self.mergeable_objc?(decl, root_decls) - decl.type.objc_class? \ - || (decl.type.objc_category? \ - && name_match(decl.objc_category_name[0], root_decls)) + decl.type.objc_class? || + (decl.type.objc_category? && + (category_classname = decl.objc_category_name[0]) && + root_decls.any? { _1.name == category_classname }) end # Returns if a Swift declaration is mergeable. @@ -733,22 +703,45 @@ def self.mergeable_swift?(decl) decl.type.swift_typealias? end + # Normally merge all extensions into their types and each other. + # + # :none means only merge within a module -- so two extensions to + # some type get merged, but an extension to a type from + # another documented module does not get merged into that type + # :extensions means extensions of documented modules get merged, + # but if we're documenting ModA and ModB, and they both provide + # extensions to Swift.String, then those two extensions still + # appear separately. + # + # (The USR part of the dedup key means ModA.Foo and ModB.Foo do not + # get merged.) + def self.module_deduplication_key(decl) + if (Config.instance.merge_modules == :none) || + (Config.instance.merge_modules == :extensions && + decl.extension_of_external_type?) + decl.doc_module_name + else + '' + end + end + # Two declarations get merged if they have the same deduplication key. def self.deduplication_key(decl, root_decls) + mod_key = module_deduplication_key(decl) # Swift extension of objc class if decl.swift_objc_extension? - [decl.swift_extension_objc_name, :objc_class_and_categories] + [decl.swift_extension_objc_name, :objc_class_and_categories, mod_key] # Swift type or Swift extension of Swift type elsif mergeable_swift?(decl) - [decl.usr, decl.name] + [decl.usr, decl.name, mod_key] # Objc categories and classes elsif mergeable_objc?(decl, root_decls) # Using the ObjC name to match swift_objc_extension. name, _ = decl.objc_category_name || decl.objc_name - [name, :objc_class_and_categories] + [name, :objc_class_and_categories, mod_key] # Non-mergable declarations (funcs, typedefs etc...) else - [decl.usr, decl.name, decl.type.kind] + [decl.usr, decl.name, decl.type.kind, ''] end end @@ -904,81 +897,45 @@ def self.move_merged_extension_marks(decls) # declaration: public protocol conformances and, for top-level extensions, # further conditional extensions of the same type. def self.merge_code_declaration(decls) - first = decls.first - declarations = decls[1..].select do |decl| decl.type.swift_extension? && (decl.other_inherited_types?(@inaccessible_protocols) || - (first.type.swift_extension? && decl.constrained_extension?)) - end.map(&:declaration) - - unless declarations.empty? - first.declaration = declarations.prepend(first.declaration).uniq.join - end - end - - # Apply filtering based on the "included" and "excluded" flags. - def self.filter_files(json) - json = filter_included_files(json) if Config.instance.included_files.any? - json = filter_excluded_files(json) if Config.instance.excluded_files.any? - json.map do |doc| - key = doc.keys.first - doc[key] - end.compact - end - - # Filter based on the "included" flag. - def self.filter_included_files(json) - included_files = Config.instance.included_files - json.map do |doc| - key = doc.keys.first - doc if included_files.detect do |include| - File.fnmatch?(include, key) + (decls.first.type.swift_extension? && decl.constrained_extension?)) + end.prepend(decls.first) + + html_declaration = '' + until declarations.empty? + module_decls, declarations = next_doc_module_group(declarations) + first = module_decls.first + if need_doc_module_note?(first, html_declaration) + html_declaration += "From #{first.doc_module_name}:" end - end.compact - end + html_declaration += module_decls.map(&:declaration).uniq.join + end - # Filter based on the "excluded" flag. - def self.filter_excluded_files(json) - excluded_files = Config.instance.excluded_files - json.map do |doc| - key = doc.keys.first - doc unless excluded_files.detect do |exclude| - File.fnmatch?(exclude, key) - end - end.compact + # Must preserve `nil` for edge cases + decls.first.declaration = html_declaration unless html_declaration.empty? end - def self.name_match(name_part, docs) - return nil unless name_part - - wildcard_expansion = Regexp.escape(name_part) - .gsub('\.\.\.', '[^)]*') - .gsub(/<.*>/, '') - - whole_name_pat = /\A#{wildcard_expansion}\Z/ - docs.find do |doc| - whole_name_pat =~ doc.name - end + # Grab all the extensions from the same doc module + def self.next_doc_module_group(decls) + decls.partition { _1.doc_module_name == decls.first.doc_module_name } end - # Find the first ancestor of doc whose name matches name_part. - def self.ancestor_name_match(name_part, doc) - doc.namespace_ancestors.reverse_each do |ancestor| - if match = name_match(name_part, ancestor.children) - return match - end - end - nil + # Does this extension/type need a note explaining which doc module it is from? + # Only for extensions, if there actually are multiple modules. + # Last condition avoids it for simple 'extension Array'. + def self.need_doc_module_note?(decl, html_declaration) + Config.instance.multiple_modules? && + decl.type.swift_extension? && + !(html_declaration.empty? && + !decl.constrained_extension? && + !decl.inherited_types?) end - def self.name_traversal(name_parts, doc) - while doc && !name_parts.empty? - next_part = name_parts.shift - doc = name_match(next_part, doc.children) - end - doc - end + # + # Autolinking + # # Links recognized top-level declarations within # - inlined code within docs @@ -987,85 +944,97 @@ def self.name_traversal(name_parts, doc) # The `after_highlight` flag is used to differentiate between the two modes. # # DocC link format - follow Xcode and don't display slash-separated parts. - # rubocop:disable Metrics/MethodLength - def self.autolink_text(text, doc, root_decls, after_highlight: false) + def self.autolink_text(text, doc, after_highlight: false) text.autolink_block(doc.url, '[^\s]+', after_highlight) do |raw_name| sym_name = (raw_name[/^$/, 1] || raw_name).sub(/(? header}}
diff --git a/lib/jazzy/themes/fullwidth/assets/css/jazzy.css.scss b/lib/jazzy/themes/fullwidth/assets/css/jazzy.css.scss index c6bed2de5..ded61cec0 100644 --- a/lib/jazzy/themes/fullwidth/assets/css/jazzy.css.scss +++ b/lib/jazzy/themes/fullwidth/assets/css/jazzy.css.scss @@ -454,12 +454,12 @@ pre code { margin-left: 20px; font-size: 1rem; } +} - .declaration-note { - font-size: .85em; - color: #808080; - font-style: italic; - } +.declaration-note { + font-size: .85em; + color: #808080; + font-style: italic; } .pointer-container { diff --git a/lib/jazzy/themes/fullwidth/templates/doc.mustache b/lib/jazzy/themes/fullwidth/templates/doc.mustache index 4d7955c47..ca657acae 100644 --- a/lib/jazzy/themes/fullwidth/templates/doc.mustache +++ b/lib/jazzy/themes/fullwidth/templates/doc.mustache @@ -31,9 +31,16 @@ {{> header}}
diff --git a/lib/jazzy/themes/jony/assets/css/jazzy.css.scss b/lib/jazzy/themes/jony/assets/css/jazzy.css.scss index 692990f19..8140d29fc 100644 --- a/lib/jazzy/themes/jony/assets/css/jazzy.css.scss +++ b/lib/jazzy/themes/jony/assets/css/jazzy.css.scss @@ -450,11 +450,12 @@ header { .token-open { margin-left: 45px; } - .declaration-note { - font-size: .85em; - color: rgba(128,128,128,1); - font-style: italic; - } +} + +.declaration-note { + font-size: .85em; + color: rgba(128,128,128,1); + font-style: italic; } .pointer-container { diff --git a/lib/jazzy/themes/jony/templates/doc.mustache b/lib/jazzy/themes/jony/templates/doc.mustache index b5a4d8a3d..195a58978 100644 --- a/lib/jazzy/themes/jony/templates/doc.mustache +++ b/lib/jazzy/themes/jony/templates/doc.mustache @@ -26,10 +26,17 @@
diff --git a/spec/integration_spec.rb b/spec/integration_spec.rb index 71e5f3c26..c1abc5695 100644 --- a/spec/integration_spec.rb +++ b/spec/integration_spec.rb @@ -249,6 +249,10 @@ def configure_cocoapods '--build-tool-arguments ' \ "-emit-extension-block-symbols,-I,#{module_path}" end + + describe 'Creates docs for a multiple-module project' do + behaves_like cli_spec 'jazzy_multi_modules' + end end if !spec_subset || spec_subset == 'swift' describe 'jazzy cocoapods' do diff --git a/spec/integration_specs b/spec/integration_specs index eec146448..d1e725975 160000 --- a/spec/integration_specs +++ b/spec/integration_specs @@ -1 +1 @@ -Subproject commit eec146448d04deef52f4a53de3fb03f8464a1f8f +Subproject commit d1e725975cf03a2fd33b60f73fadc7a89f3a7f8a