# 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{(?<!\.)[/.](?!\.)}) # dot or slash, but not '...' .reject(&:empty?) end end end end class SourceDeclaration # Names for a symbol. Permits function parameters to be omitted. def index_names [name, name.sub(/\(.*\)/, '(...)')].uniq end end end