diff --git a/lib/utopia/project/base.rb b/lib/utopia/project/base.rb index 67aae4b..e1e3a16 100644 --- a/lib/utopia/project/base.rb +++ b/lib/utopia/project/base.rb @@ -16,6 +16,7 @@ require_relative "releases_document" require_relative "guides" +require_relative "inheritance" require_relative "linkify" module Utopia @@ -203,6 +204,13 @@ def link_for(definition) end end + # Resolve inheritance information for the given definition. + # @parameter definition [Decode::Definition] The definition to inspect. + # @returns [Inheritance] The inheritance information. + def inheritance_for(definition) + Inheritance.new(@index, definition) + end + # Get the guides collection for this project. # @returns [Guides] # diff --git a/lib/utopia/project/inheritance.rb b/lib/utopia/project/inheritance.rb new file mode 100644 index 0000000..915dc3f --- /dev/null +++ b/lib/utopia/project/inheritance.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Utopia + module Project + # Resolves relationships and inherited methods for a definition. + class Inheritance + # A relationship declared by a definition. + Relationship = Struct.new(:kind, :name, :definition) + + # A method inherited from another definition. + Method = Struct.new(:name, :definition) + + # A group of inherited methods with the same origin. + Group = Struct.new(:definition, :methods) + + # Initialize inheritance resolution for the given definition. + # @parameter index [Decode::Index] The index used to resolve relationships. + # @parameter definition [Decode::Definition] The definition to inspect. + def initialize(index, definition) + @index = index + @definition = definition + end + + # The definition being inspected. + # @attribute [Decode::Definition] + attr :definition + + # Enumerate the directly declared relationships. + # @returns [Array(Relationship)] The relationships in declaration order. + def relationships + @relationships ||= begin + relationships = [] + + if @definition.respond_to?(:super_class) && (name = @definition.super_class) + relationships << relationship(:super_class, name) + end + + { + prepends: :prepend, + includes: :include, + extends: :extend, + }.each do |attribute, kind| + if @definition.respond_to?(attribute) + @definition.public_send(attribute).each do |name| + relationships << relationship(kind, name) + end + end + end + + relationships + end + end + + # Enumerate documented public and protected methods inherited by the definition. + # @returns [Array(Group)] Methods grouped by the definition which provides them. + def inherited_methods + @groups = [] + @groups_by_definition = {} + + collect_instance_methods(@definition, false, {}, {}) + collect_class_methods(@definition, false, {}, {}) + + return @groups + end + + private + + def relationship(kind, name) + Relationship.new(kind, name, resolve(name, @definition)) + end + + def resolve(name, relative_to) + reference = relative_to.language.reference_for(name) + @index.lookup(reference, relative_to: relative_to) + end + + def relationship_definitions(definition, attribute) + return [] unless definition.respond_to?(attribute) + + definition.public_send(attribute).filter_map do |name| + resolve(name, definition) + end + end + + def super_class_for(definition) + if definition.respond_to?(:super_class) && (name = definition.super_class) + resolve(name, definition) + end + end + + def methods_for(definition, prefix) + return [] unless node = @index.trie.lookup(definition.full_path) + + methods = node.children.values.flat_map do |child| + child.values || [] + end.select do |child| + child.nested_name.start_with?(prefix) + end + + methods.group_by(&:nested_name).map do |name, definitions| + definitions.find(&:documented?) || definitions.first + end + end + + def singleton_for(definition) + if node = @index.trie.lookup(definition.full_path) + if singleton = node.children[:self] + return singleton.values&.first + end + end + end + + def collect_instance_methods(definition, include_own, seen, visited, display_prefix = "#") + key = [definition.qualified_name, display_prefix] + return if visited[key] + visited[key] = true + + relationship_definitions(definition, :prepends).reverse_each do |relationship| + collect_instance_methods(relationship, true, seen, visited, display_prefix) + end + + collect_methods(definition, "#", display_prefix, include_own, seen) + + relationship_definitions(definition, :includes).reverse_each do |relationship| + collect_instance_methods(relationship, true, seen, visited, display_prefix) + end + + if display_prefix == "#" && (super_class = super_class_for(definition)) + collect_instance_methods(super_class, true, seen, visited, display_prefix) + end + end + + def collect_class_methods(definition, include_own, seen, visited) + key = definition.qualified_name + return if visited[key] + visited[key] = true + + collect_methods(definition, ".", ".", include_own, seen) + if singleton = singleton_for(definition) + collect_methods(singleton, "#", ".", include_own, seen, definition) + end + + relationship_definitions(definition, :extends).reverse_each do |relationship| + collect_instance_methods(relationship, true, seen, {}, ".") + end + + if super_class = super_class_for(definition) + collect_class_methods(super_class, true, seen, visited) + end + end + + def collect_methods(definition, source_prefix, display_prefix, include_own, seen, origin = definition) + methods_for(definition, source_prefix).each do |method| + name = "#{display_prefix}#{method.name}" + next if seen[name] + seen[name] = true + + # Constructors are invoked through `.new` and are not useful as + # inherited methods. + if source_prefix == "#" && method.name == :initialize + next + end + + next unless include_own + next unless method.documented? + next if method.respond_to?(:private?) && method.private? + + add_method(origin, name, method) + end + end + + def add_method(definition, name, method) + group = @groups_by_definition[definition.qualified_name] ||= begin + group = Group.new(definition, []) + @groups << group + group + end + + group.methods << Method.new(name, method) + end + end + end +end diff --git a/pages/reference/_relationships.xnode b/pages/reference/_relationships.xnode new file mode 100644 index 0000000..685e656 --- /dev/null +++ b/pages/reference/_relationships.xnode @@ -0,0 +1,45 @@ + + +
#{relationship_labels.fetch(kind)}
+
0 + ?>, #{base.linkify(relationship.name, symbol)}
+ + + +
+

Inherited Methods

From #{group.definition.qualified_name}:

+
+ diff --git a/pages/reference/show.xnode b/pages/reference/show.xnode index d72fcb9..ec71309 100644 --- a/pages/reference/show.xnode +++ b/pages/reference/show.xnode @@ -9,6 +9,7 @@

#{partial 'content:declaration', symbol: symbol}

+ #{partial 'content:relationships', symbol: symbol} ["#prepended_method"], + "Example::Included" => ["#included_method"], + "Example::Parent" => ["#parent_method", ".parent_class_method", ".singleton_class_method"], + "Example::Extended" => [".extended_method"], + } + end +end diff --git a/test/utopia/project/serve.rb b/test/utopia/project/serve.rb index ebf5cd0..7b70bb6 100644 --- a/test/utopia/project/serve.rb +++ b/test/utopia/project/serve.rb @@ -80,5 +80,8 @@ expect(body).to be(:include?, "def release_names") expect(body).to be(:include?, 'class ReleasesDocument < Document') + expect(body).to be(:include?, '
') + expect(body).to be(:include?, "
Inherits from
") + expect(body).to be(:include?, "/reference/Utopia/Project/Document/index#Utopia%3A%3AProject%3A%3ADocument%23root") end end diff --git a/utopia-project.gemspec b/utopia-project.gemspec index 912f915..1bd340b 100644 --- a/utopia-project.gemspec +++ b/utopia-project.gemspec @@ -25,7 +25,7 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.3" - spec.add_dependency "decode", "~> 0.29" + spec.add_dependency "decode", "~> 0.30" spec.add_dependency "falcon" spec.add_dependency "markly", "~> 0.15" spec.add_dependency "thread-local"