lib/spoom/file_tree.rb



# typed: strict
# frozen_string_literal: true

module Spoom
  # Build a file hierarchy from a set of file paths.
  class FileTree
    extend T::Sig

    sig { params(paths: T::Enumerable[String]).void }
    def initialize(paths = [])
      @roots = T.let({}, T::Hash[String, Node])
      add_paths(paths)
    end

    # Add all `paths` to the tree
    sig { params(paths: T::Enumerable[String]).void }
    def add_paths(paths)
      paths.each { |path| add_path(path) }
    end

    # Add a `path` to the tree
    #
    # This will create all nodes until the root of `path`.
    sig { params(path: String).returns(Node) }
    def add_path(path)
      parts = path.split("/")
      if path.empty? || parts.size == 1
        return @roots[path] ||= Node.new(parent: nil, name: path)
      end

      parent_path = T.must(parts[0...-1]).join("/")
      parent = add_path(parent_path)
      name = T.must(parts.last)
      parent.children[name] ||= Node.new(parent: parent, name: name)
    end

    # All root nodes
    sig { returns(T::Array[Node]) }
    def roots
      @roots.values
    end

    # All the nodes in this tree
    sig { returns(T::Array[Node]) }
    def nodes
      v = CollectNodes.new
      v.visit_tree(self)
      v.nodes
    end

    # All the paths in this tree
    sig { returns(T::Array[String]) }
    def paths
      nodes.map(&:path)
    end

    # Return a map of strictnesses for each node in the tree
    sig { params(context: Context).returns(T::Hash[Node, T.nilable(String)]) }
    def nodes_strictnesses(context)
      v = CollectStrictnesses.new(context)
      v.visit_tree(self)
      v.strictnesses
    end

    # Return a map of typing scores for each node in the tree
    sig { params(context: Context).returns(T::Hash[Node, Float]) }
    def nodes_strictness_scores(context)
      v = CollectScores.new(context)
      v.visit_tree(self)
      v.scores
    end

    # Return a map of typing scores for each path in the tree
    sig { params(context: Context).returns(T::Hash[String, Float]) }
    def paths_strictness_scores(context)
      nodes_strictness_scores(context).map { |node, score| [node.path, score] }.to_h
    end

    sig { params(out: T.any(IO, StringIO), colors: T::Boolean).void }
    def print(out: $stdout, colors: true)
      printer = Printer.new({}, out: out, colors: colors)
      printer.visit_tree(self)
    end

    sig { params(context: Context, out: T.any(IO, StringIO), colors: T::Boolean).void }
    def print_with_strictnesses(context, out: $stdout, colors: true)
      strictnesses = nodes_strictnesses(context)

      printer = Printer.new(strictnesses, out: out, colors: colors)
      printer.visit_tree(self)
    end

    # A node representing either a file or a directory inside a FileTree
    class Node < T::Struct
      extend T::Sig

      # Node parent or `nil` if the node is a root one
      const :parent, T.nilable(Node)

      # File or dir name
      const :name, String

      # Children of this node (if not empty, it means it's a dir)
      const :children, T::Hash[String, Node], default: {}

      # Full path to this node from root
      sig { returns(String) }
      def path
        parent = self.parent
        return name unless parent

        "#{parent.path}/#{name}"
      end
    end

    # An abstract visitor for FileTree
    class Visitor
      extend T::Sig
      extend T::Helpers

      abstract!

      sig { params(tree: FileTree).void }
      def visit_tree(tree)
        visit_nodes(tree.roots)
      end

      sig { params(node: FileTree::Node).void }
      def visit_node(node)
        visit_nodes(node.children.values)
      end

      sig { params(nodes: T::Array[FileTree::Node]).void }
      def visit_nodes(nodes)
        nodes.each { |node| visit_node(node) }
      end
    end

    # A visitor that collects all the nodes in a tree
    class CollectNodes < Visitor
      extend T::Sig

      sig { returns(T::Array[FileTree::Node]) }
      attr_reader :nodes

      sig { void }
      def initialize
        super()
        @nodes = T.let([], T::Array[FileTree::Node])
      end

      sig { override.params(node: FileTree::Node).void }
      def visit_node(node)
        @nodes << node
        super
      end
    end

    # A visitor that collects the strictness of each node in a tree
    class CollectStrictnesses < Visitor
      extend T::Sig

      sig { returns(T::Hash[Node, T.nilable(String)]) }
      attr_reader :strictnesses

      sig { params(context: Context).void }
      def initialize(context)
        super()
        @context = context
        @strictnesses = T.let({}, T::Hash[Node, T.nilable(String)])
      end

      sig { override.params(node: FileTree::Node).void }
      def visit_node(node)
        path = node.path
        @strictnesses[node] = @context.read_file_strictness(path) if @context.file?(path)

        super
      end
    end

    # A visitor that collects the typing score of each node in a tree
    class CollectScores < CollectStrictnesses
      extend T::Sig

      sig { returns(T::Hash[Node, Float]) }
      attr_reader :scores

      sig { params(context: Context).void }
      def initialize(context)
        super
        @context = context
        @scores = T.let({}, T::Hash[Node, Float])
      end

      sig { override.params(node: FileTree::Node).void }
      def visit_node(node)
        super

        @scores[node] = node_score(node)
      end

      private

      sig { params(node: Node).returns(Float) }
      def node_score(node)
        if @context.file?(node.path)
          strictness_score(@strictnesses[node])
        else
          node.children.values.sum { |child| @scores.fetch(child, 0.0) } / node.children.size.to_f
        end
      end

      sig { params(strictness: T.nilable(String)).returns(Float) }
      def strictness_score(strictness)
        case strictness
        when "true", "strict", "strong"
          1.0
        else
          0.0
        end
      end
    end

    # An internal class used to print a FileTree
    #
    # See `FileTree#print`
    class Printer < Visitor
      extend T::Sig

      sig do
        params(
          strictnesses: T::Hash[FileTree::Node, T.nilable(String)],
          out: T.any(IO, StringIO),
          colors: T::Boolean,
        ).void
      end
      def initialize(strictnesses, out: $stdout, colors: true)
        super()
        @strictnesses = strictnesses
        @colors = colors
        @printer = T.let(Spoom::Printer.new(out: out, colors: colors), Spoom::Printer)
      end

      sig { override.params(node: FileTree::Node).void }
      def visit_node(node)
        @printer.printt
        if node.children.empty?
          strictness = @strictnesses[node]
          if @colors
            @printer.print_colored(node.name, strictness_color(strictness))
          elsif strictness
            @printer.print("#{node.name} (#{strictness})")
          else
            @printer.print(node.name.to_s)
          end
          @printer.print("\n")
        else
          @printer.print_colored(node.name, Color::BLUE)
          @printer.print("/")
          @printer.printn
          @printer.indent
          super
          @printer.dedent
        end
      end

      private

      sig { params(strictness: T.nilable(String)).returns(Color) }
      def strictness_color(strictness)
        case strictness
        when "false"
          Color::RED
        when "true", "strict", "strong"
          Color::GREEN
        else
          Color::CLEAR
        end
      end
    end
  end
end