molybdenum-99/infoboxer

View on GitHub
lib/infoboxer/navigation/sections.rb

Summary

Maintainability
A
45 mins
Test Coverage
# frozen_string_literal: true

module Infoboxer
  module Navigation
    # `Sections` module provides logical view on document strcture.
    #
    # From this module's point of view, each {Tree::Document Document} is a
    # {Sections::Container Sections::Container}, which consists of
    # {Sections::Container#intro} (before first heading) and a set of
    # nested {Sections::Container#sections}.
    #
    # Each document node, in turn, provides method {Sections::Node#in_sections},
    # allowing you to receive list of sections, which contains current
    # node.
    #
    # **NB**: Sections are "virtual" nodes, they are not, in fact, in
    # documents tree. So, you can be surprised with:
    #
    # ```ruby
    # document.sections         # => list of Section instances
    # document.lookup(:Section) # => []
    #
    # paragraph.in_sections     # => list of sections
    # paragraph.
    #  lookup_parents(:Section) # => []
    # ```
    module Sections
      # This module is included in {Tree::Document Document}, allowing
      # you to navigate through document's logical sections (and also
      # included in each {Sections::Section} instance, allowing to navigate
      # recursively).
      #
      # See also {Sections parent module} docs.
      module Container
        # All container's paragraph-level nodes before first heading.
        #
        # @return {Tree::Nodes}
        def intro
          children
            .take_while { |n| !n.is_a?(Tree::Heading) }
            .select { |n| n.is_a?(Tree::BaseParagraph) }
        end

        # List of sections inside current container.
        #
        # Examples of usage:
        #
        # ```ruby
        # document.sections                 # all top-level sections
        # document.sections('Culture')      # only "Culture" section
        # document.sections(/^List of/)     # all sections with heading matching pattern
        #
        # document.
        #   sections('Culture').            # long way of recieve nested section
        #     sections('Music')             # (Culture / Music)
        #
        # document.
        #   sections('Culture', 'Music')    # the same as above
        #
        # document.
        #   sections('Culture' => 'Music')  # pretty-looking version for 2 levels of nesting
        # ```
        #
        # @return {Tree::Nodes<Section>}
        def sections(*names)
          @sections ||= make_sections

          if names.first.is_a?(Hash)
            h = names.shift
            h.count == 1 or fail(ArgumentError, "Undefined behavior with #{h}")
            names.unshift(h.keys.first, h.values.first)
          end

          case names.count
          when 0
            @sections
          when 1
            @sections.select { |s| names.first === s.heading.text_ }
          else
            @sections.select { |s| names.first === s.heading.text_ }.sections(*names[1..])
          end
        end

        def subsections(*names)
          sections = names.map { |name|
            heading = lookup_children(:Heading, text_: name).first
            next unless heading

            body = heading.next_siblings
                          .take_while { |n| !n.is_a?(Tree::Heading) || n.level > heading.level }

            Section.new(heading, body)
          }.compact
          Tree::Nodes.new(sections)
        end

        def lookup_children(*arg)
          if arg.include?(:Section)
            sections.find(*(arg - [:Section]))
          else
            super
          end
        end

        private

        def make_sections
          res = Tree::Nodes[]
          return res if headings.empty?

          level = headings.first.level

          children
            .chunk { |n| n.matches?(Tree::Heading, level: level) }
            .drop_while { |is_heading, _nodes| !is_heading }
            .each do |is_heading, nodes|
              if is_heading
                nodes.each do |node|
                  res << Section.new(node)
                end
              else
                res.last.push_children(*nodes)
              end
            end

          res
        end
      end

      # Part of {Sections} navigation, allowing each node to know exact
      # list of sections it contained in.
      #
      # See also {Sections parent module} documentation.
      module Node
        # List of sections current node contained in (bottom-to-top:
        # smallest section first).
        #
        # @return {Tree::Nodes<Section>}
        def in_sections
          return parent.in_sections unless parent.is_a?(Tree::Document)
          return @in_sections if @in_sections

          heading =
            if is_a?(Tree::Heading)
              lookup_prev_sibling(Tree::Heading, level: level - 1)
            else
              lookup_prev_sibling(Tree::Heading)
            end
          unless heading
            @in_sections = Tree::Nodes[]
            return @in_sections
          end

          body = heading.next_siblings
                        .take_while { |n| !n.is_a?(Tree::Heading) || n.level > heading.level }

          section = Section.new(heading, body)
          @in_sections = Tree::Nodes[section, *heading.in_sections]
        end
      end

      # Part of {Sections} navigation, allowing chains of section search.
      #
      # See {Sections parent module} documentation.
      module Nodes
        # @!method sections(*names)
        # @!method in_sections

        %i[sections in_sections].each do |sym|
          define_method(sym) do |*args|
            make_nodes(map { |n| n.send(sym, *args) })
          end
        end

        def lookup_children(*arg)
          if arg.include?(:Section)
            sections.find(*(arg - [:Section]))
          else
            super
          end
        end
      end

      # Virtual node, representing logical section of the document.
      # Is not, in fact, in the tree.
      #
      # See {Sections parent module} documentation for details.
      class Section < Tree::Compound
        def initialize(heading, children = Tree::Nodes[])
          # no super: we don't wont to rewrite children's parent
          @children = Tree::Nodes[*children]
          @heading = heading
          @params = {level: heading.level, heading: heading.text.strip}
        end

        # Section's heading.
        #
        # @return {Tree::Heading}
        attr_reader :heading

        # no rewriting of parent, again
        def push_children(*nodes)
          nodes.each do |n|
            @children << n
          end
        end

        def empty?
          false
        end

        def inspect
          "#<#{descr}: #{children.count} nodes>"
        end

        include Container
      end
    end
  end
end