From 3069856d29d793d53467072c038fb60ac38fa205 Mon Sep 17 00:00:00 2001 From: Anders Rillbert Date: Mon, 30 Sep 2024 08:41:56 +0200 Subject: [PATCH] init the refactoring of tree/converter operations to separate gem --- gran/.gitignore | 8 + gran/CHANGELOG.md | 5 + gran/CODE_OF_CONDUCT.md | 84 ++++++ gran/Gemfile | 16 + gran/LICENSE.txt | 21 ++ gran/README.md | 37 +++ gran/Rakefile | 14 + gran/bin/console | 15 + gran/bin/setup | 8 + gran/gran.gemspec | 39 +++ gran/lib/gran.rb | 8 + gran/lib/gran/pathtree.rb | 518 ++++++++++++++++++++++++++++++++ gran/lib/gran/treeconverter.rb | 333 ++++++++++++++++++++ gran/lib/gran/version.rb | 5 + gran/test/test_gran.rb | 13 + gran/test/test_helper.rb | 6 + gran/test/test_pathtree.rb | 473 +++++++++++++++++++++++++++++ gran/test/test_treeconverter.rb | 193 ++++++++++++ 18 files changed, 1796 insertions(+) create mode 100644 gran/.gitignore create mode 100644 gran/CHANGELOG.md create mode 100644 gran/CODE_OF_CONDUCT.md create mode 100644 gran/Gemfile create mode 100644 gran/LICENSE.txt create mode 100644 gran/README.md create mode 100644 gran/Rakefile create mode 100755 gran/bin/console create mode 100755 gran/bin/setup create mode 100644 gran/gran.gemspec create mode 100644 gran/lib/gran.rb create mode 100755 gran/lib/gran/pathtree.rb create mode 100644 gran/lib/gran/treeconverter.rb create mode 100644 gran/lib/gran/version.rb create mode 100644 gran/test/test_gran.rb create mode 100644 gran/test/test_helper.rb create mode 100644 gran/test/test_pathtree.rb create mode 100644 gran/test/test_treeconverter.rb diff --git a/gran/.gitignore b/gran/.gitignore new file mode 100644 index 0000000..9106b2a --- /dev/null +++ b/gran/.gitignore @@ -0,0 +1,8 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ diff --git a/gran/CHANGELOG.md b/gran/CHANGELOG.md new file mode 100644 index 0000000..8f48510 --- /dev/null +++ b/gran/CHANGELOG.md @@ -0,0 +1,5 @@ +## [Unreleased] + +## [0.1.0] - 2024-09-29 + +- Initial release diff --git a/gran/CODE_OF_CONDUCT.md b/gran/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..36ffd37 --- /dev/null +++ b/gran/CODE_OF_CONDUCT.md @@ -0,0 +1,84 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at anders.rillbert@kutso.se. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, +available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. diff --git a/gran/Gemfile b/gran/Gemfile new file mode 100644 index 0000000..105c0bc --- /dev/null +++ b/gran/Gemfile @@ -0,0 +1,16 @@ +source "https://rubygems.org" + +# Use the gemspec for all runtime dependencies and other +# metadata on the gem +gemspec + +group :development, :test do + gem "yard", "~> 0.9" + gem "ruby-lsp", "~> 0.18" + gem "standard", "~> 1.0" + gem "rake", "~> 13.0" +end + +group :test do + gem "minitest", "~> 5.0" +end diff --git a/gran/LICENSE.txt b/gran/LICENSE.txt new file mode 100644 index 0000000..06f3e0f --- /dev/null +++ b/gran/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2024 Anders Rillbert + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/gran/README.md b/gran/README.md new file mode 100644 index 0000000..d393106 --- /dev/null +++ b/gran/README.md @@ -0,0 +1,37 @@ +# Gran + +Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/gran`. To experiment with that code, run `bin/console` for an interactive prompt. + +TODO: Delete this and the text above, and describe your gem + +## Installation + +Install the gem and add to the application's Gemfile by executing: + + $ bundle add gran + +If bundler is not being used to manage dependencies, install the gem by executing: + + $ gem install gran + +## Usage + +TODO: Write usage instructions here + +## Development + +After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. + +To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/gran. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/gran/blob/main/CODE_OF_CONDUCT.md). + +## License + +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). + +## Code of Conduct + +Everyone interacting in the Gran project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/gran/blob/main/CODE_OF_CONDUCT.md). diff --git a/gran/Rakefile b/gran/Rakefile new file mode 100644 index 0000000..5bb6087 --- /dev/null +++ b/gran/Rakefile @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rake/testtask" + +Rake::TestTask.new(:test) do |t| + t.libs << "test" + t.libs << "lib" + t.test_files = FileList["test/**/test_*.rb"] +end + +require "standard/rake" + +task default: %i[test standard] diff --git a/gran/bin/console b/gran/bin/console new file mode 100755 index 0000000..6263540 --- /dev/null +++ b/gran/bin/console @@ -0,0 +1,15 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "gran" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +# (If you use this, don't forget to add pry to your Gemfile!) +# require "pry" +# Pry.start + +require "irb" +IRB.start(__FILE__) diff --git a/gran/bin/setup b/gran/bin/setup new file mode 100755 index 0000000..dce67d8 --- /dev/null +++ b/gran/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/gran/gran.gemspec b/gran/gran.gemspec new file mode 100644 index 0000000..1dfb551 --- /dev/null +++ b/gran/gran.gemspec @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require_relative "lib/gran/version" + +Gem::Specification.new do |spec| + spec.name = "gran" + spec.version = Gran::VERSION + spec.authors = ["Anders Rillbert"] + spec.email = ["anders.rillbert@kutso.se"] + + spec.summary = "Provides utilities for working with trees of file paths" + spec.description = "Provides utilities for working with trees of file paths" + spec.homepage = "https://github.com/rillbert/giblish" + spec.license = "MIT" + + spec.metadata = { + "homepage_uri" => spec.homepage, + "bug_tracker_uri" => "https://github.com/rillbert/giblish/issues", + "source_code_uri" => "https://github.com/rillbert/giblish", + "allowed_push_host" => "https://rubygems.org" + } + + # Specify which files should be added to the gem when it is released. + # The `git ls-files -z` loads the files in the RubyGem that have been added into git. + spec.files = Dir.chdir(__dir__) do + `git ls-files -z`.split("\x0").reject do |f| + (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) + end + end + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + # Uncomment to register a new dependency of your gem + # spec.add_dependency "example-gem", "~> 1.0" + + # For more information and examples about making a new gem, check out our + # guide at: https://bundler.io/guides/creating_gem.html +end diff --git a/gran/lib/gran.rb b/gran/lib/gran.rb new file mode 100644 index 0000000..a9d94a6 --- /dev/null +++ b/gran/lib/gran.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require_relative "gran/version" + +module Gran + class Error < StandardError; end + # Your code goes here... +end diff --git a/gran/lib/gran/pathtree.rb b/gran/lib/gran/pathtree.rb new file mode 100755 index 0000000..2847821 --- /dev/null +++ b/gran/lib/gran/pathtree.rb @@ -0,0 +1,518 @@ +require "pathname" +require "set" + +# +# Provides a tree structure where each node is the basename of either +# a directory or a file. The pathname of a node is the concatenation of +# all basenames from the root node to the node in question, given as a +# Pathname object. +# +# Each node must have a unique pathname within the tree it is part of. +# +# A node can contain an associated 'data' object. +# +# The following paths: +# basedir/file_1 +# basedir/file_2 +# basedir/dir1/file_3 +# basedir/dir1/file_4 +# basedir/dir2/dir3/file_5 +# +# are thus represented by the following path tree: +# +# basedir +# file_1 +# file_2 +# dir1 +# file_3 +# file_4 +# dir2 +# dir3 +# file_5 +# +# == Tree info +# see https://www.geeksforgeeks.org/tree-traversals-inorder-preorder-and-postorder/ +# +class PathTree + attr_reader :data, :name, :children, :parent, :abs_root + attr_writer :parent, :data + + def initialize(path, data = nil, parent = nil) + p = clean(path) + raise ArgumentError, "Can not instantiate node with path == '.'" if p.to_s == "." + raise ArgumentError, "Trying to create a non-root node using an absolute path" if p.absolute? && !parent.nil? + + head = p.descend.first + + @name = head + @children = [] + @data = nil + @parent = parent + + tail = p.relative_path_from(head) + if tail.to_s == "." + @data = data + return + end + + add_descendants(tail, data) + end + + # duplicate this node and all its children but keep the same data references + # as the originial nodes. + # + # parent:: the parent node of the copy, default = nil (the copy + # is a root node) + # returns:: a copy of this node and all its descendents. The copy will + # share any 'data' references with the original. + def dup(parent: nil) + d = PathTree.new(@name.dup, @data, parent) + + @children.each { |c| d.children << c.dup(parent: d) } + d + end + + def name=(name) + name = Pathname.new(name) + + if !parent.nil? && @parent.children.any? { |c| c.name == name } + raise ArgumentError, "Can not rename to #{name}. An existing node already use that name" + end + + @name = name + end + + # return:: a String with the path segment for this node + def segment + @name.to_s + end + + # return:: a Pathname with the complete path from the root of the + # tree where this node is a member to this node (inclusive). + def pathname + return @name if @parent.nil? + + (@parent.pathname / @name).cleanpath + end + + # create a subtree from the given path and add it to this node + # + # return:: the leaf node for the added subtree + def add_descendants(path, data = nil) + p = clean(path) + raise ArgumentError, "Can not add absolute path as descendant!!" if p.absolute? + + # invoked with 'current' name, ignore + return self if p.to_s == "." + + head = p.descend.first + tail = p.relative_path_from(head) + last_segment = tail.to_s == "." + + ch = get_child(head) + if ch.nil? + @children << PathTree.new(head, last_segment ? data : nil, self) + ch = @children.last + end + + last_segment ? @children.last : ch.add_descendants(tail, data) + end + + # adds a new path to the root of the tree where this node is a member + # and associates the given data to the leaf of that path. + def add_path(path, data = nil) + p = clean(path) + raise ArgumentError, "Trying to add already existing path: #{path}" unless node(p, from_root: true).nil? + + # prune any part of the given path that already exists in this + # tree + p.ascend do |q| + n = node(q, from_root: true) + next if n.nil? + + t = PathTree.new(p.relative_path_from(q).to_s, data) + n.append_tree(t) + return self + end + + # no part of the given path existed within the tree + raise ArgumentError, "Trying to add path with other root is not supported" + end + + # Visits depth-first by root -> left -> right + # + # level:: the number of hops from the root node + # block:: the user supplied block that is executed for every visited node + # + # the level and node are given as block parameters + # + # === Returns + # A new array containing the values returned by the block + # + # === Examples + # Get an array with name of each node together with the level of the node + # traverse_preorder{ |level, n| "#{level} #{n.segment}" } + # + def traverse_preorder(level = 0, &block) + result = [yield(level, self)] + @children.each do |c| + result.append(*c.traverse_preorder(level + 1, &block)) + end + result + end + + # Visits depth-first by left -> right -> root + # + # level:: the number of hops from the root node + # block:: the user supplied block that is executed for every visited node + # + # the level and node are given as block parameters + # + # === Returns + # A new array containing the values returned by the block + # + # === Examples + # + # Get an array of each node together with the level of the node + # traverse_postorder{ |level, n| "#{level} #{n.segment}" } + def traverse_postorder(level = 0, &block) + result = [] + @children.each do |c| + result.concat(c.traverse_postorder(level + 1, &block)) + end + result << yield(level, self) + end + + # Visits bredth-first left -> right for each level top-down + # + # level:: the number of hops from the root node + # block:: the user supplied block that is executed for every visited node + # + # the level and node are given as block parameters + # + # === Returns + # A new array containing the values returned by the block + # + # === Examples + # Get an array with the name of each node together with the level of the node + # traverse_levelorder { |level, n| "#{level} #{n.segment}" } + def traverse_levelorder(level = 0, &block) + result = [] + # the node of the original call + result << yield(level, self) if level == 0 + + # this level + @children.each do |c| + result << yield(level + 1, c) + end + + # next level + @children.each do |c| + result.concat(c.traverse_levelorder(level + 1, &block)) + end + + result + end + + # Sort the nodes on each level in the tree in lexical order but put + # leafs before non-leafs. + def sort_leaf_first! + @children.sort! { |a, b| leaf_first(a, b) } + @children.each(&:sort_leaf_first!) + self + end + + # returns:: the number of nodes in the subtree with this node as + # root + def count + result = 0 + traverse_preorder do |level, node| + result += 1 + end + result + end + + # return:: true if the node is a leaf, false otherwise + def leaf? + @children.length.zero? + end + + # return:: an array with Pathnames of each full + # path for the leaves in this tree + def leave_pathnames(prune: false) + paths = [] + traverse_postorder do |l, n| + next unless n.leaf? + + paths << (prune ? n.pathname.relative_path_from(pathname) : n.pathname) + end + paths + end + + # return:: true if this node does not have a parent node + def root? + @parent.nil? + end + + # return:: the root node of the tree where this node is a member + def root + return self if root? + + @parent.root + end + + # Finds the node corresponding to the given path. + # + # path:: a String or Pathname with the path to search for + # from_root:: if true start the search from the root of the tree where + # this node is a member. If false, start the search from this node's + # children. + # + # return:: the node with the given path or nil if the path + # does not exist within this pathtree + def node(path, from_root: false) + p = clean(path) + root = nil + + traverse_preorder do |level, node| + q = from_root ? node.pathname : node.pathname.relative_path_from(pathname) + if q == p + root = node + break + end + end + root + end + + # adds a copy of the given Pathtree as a subtree to this node. the subtree can not + # contain nodes that will end up having the same pathname as any existing + # node in the target tree. Note that 'data' attributes will not be copied. The copied + # Pathtree nodes will thus point to the same data attributes as the original. + # + # == Example + # + # 1. Add my/new/tree to /1/2 -> /1/2/my/new/tree + # 2. Add /my/new/tree to /1/2 -> ArgumentError - can not add root as subtree + # 3. Trying to add 'new/tree' to '/my' node in a tree with '/my/new/tree' raises + # ArgumentError since the pathname that would result already exists within the + # target tree. + def append_tree(root_node) + raise ArgumentError, "Trying to append a root node as subtree!" if root_node.pathname.root? + + # make a copy to make sure it is a self-sustaining PathTree + c = root_node.dup + + # get all leaf paths prepended with this node's name to check for + # previous existance in this tree. + p = c.leave_pathnames.collect { |p| Pathname.new(@name) / p } + + # duplicate ourselves to compare paths + t = dup + + # check that no path in c would collide with existing paths + common = Set.new(t.leave_pathnames) & Set.new(p) + unless common.empty? + str = common.collect { |p| p.to_s }.join(",") + raise ArgumentError, "Can not append tree due to conflicting paths: #{str}" + end + + # hook the subtree into this tree + @children << c + c.parent = self + end + + # Splits the node's path into + # - a 'stem', the common path to all nodes in this tree that are on the + # same level as this node or closer to the root. + # - a 'crown', the remaining path when the stem has been removed from this + # node's pathname + # + # === Example + # n.split_stem for the following tree: + # + # base + # |- dir + # |- leaf_1 + # |- branch + # |- leaf_2 + # + # yields + # ["base/dir", "leaf_1"] when n == leaf_1 + # ["base/dir", "branch/leaf_2"] when n == leaf_2 + # ["base", "dir"] when n == "dir" + # [nil, "base"] when n == "base" + # + # return:: [stem, crown] + def split_stem + r = root + s = pathname.descend do |stem| + n = r.node(stem, from_root: true) + break n if n.children.count != 1 || n == self + end + + if s == self + [root? ? nil : s.parent.pathname, @name] + else + [s.pathname, pathname.relative_path_from(s.pathname)] + end + end + + # return:: a Pathname containing the relative path to this node as seen from the + # given node + def relative_path_from(node) + pathname.relative_path_from(node.pathname) + end + + # Builds a PathTree with its root as the given file system dir or file + # + # fs_point:: an absolute or relative path to a file or directory that + # already exists in the file system. + # prune:: if false, add the entire, absolute, path to the fs_point to + # the PathTree. If true, use only the basename of the fs_point as the + # root of the PathTree + # + # You can submit a filter predicate that determine if a specific path + # shall be part of the PathTree or not ->(Pathname) { return true/false} + # + # return:: the node corresponding to the given fs_point in the resulting + # pathtree or nil if no nodes matched the given predicate filter + # + # === Example + # + # Build a pathtree containing all files under the "mydir" directory that + # ends with '.jpg'. The resulting tree will contain the absolute path + # to 'mydir' as nodes (eg '/home/gunnar/mydir') + # + # t = PathTree.build_from_fs("./mydir",true ) { |p| p.extname == ".jpg" } + def self.build_from_fs(fs_point, prune: false) + top_node = Pathname.new(fs_point).cleanpath + raise ArgumentError, "The path '#{fs_point}' does not exist in the file system!" unless top_node.exist? + + t = nil + top_node.find do |path| + p = Pathname.new(path) + + if (block_given? && yield(p)) || !block_given? + t.nil? ? t = PathTree.new(p) : t.add_path(p) + end + end + return nil if t.nil? + + # always return the entry node but prune the parents if + # users wishes + entry_node = t.node(top_node, from_root: true) + (prune ? entry_node.dup : entry_node) + end + + # delegate method calls not implemented by PathTree to the associated 'data' + # object + def method_missing(m, ...) + return super if data.nil? + + data.send(m, ...) + end + + def respond_to_missing?(method_name, include_private = false) + return super if data.nil? + + data.respond_to?(method_name) + end + + def to_s + traverse_preorder do |level, n| + str = " " * 4 * level + "|-- " + n.segment.to_s + str += " <#{n.data}>" unless n.data.nil? + str + end.join("\n") + end + + # Return a new PathTree with the nodes whith pathname matching the + # given regex. + # + # The copy will point to the same node data as the original. + # + # regex:: a Regex matching the pathname of the nodes to be included in + # the copy + # prune:: remove all parents to this node in the returned copy + # + # === Returns + # the entry node in a new PathTree with the nodes with pathnames matching the given regex + # or nil if no nodes match + def match(regex, prune: false) + copy = nil + + traverse_preorder do |level, n| + p = n.pathname + next unless regex&.match?(p.to_s) + + copy.nil? ? copy = PathTree.new(p, n.data) : copy.add_path(p, n.data) + end + return nil if copy.nil? + + # always return the entry node but return a pruned version if + # the user wishes + entry_node = copy.node(pathname, from_root: true) + (prune ? entry_node.dup : entry_node) + end + + # Return a new PathTree with the nodes matching the given block + # + # The copy will point to the same node data as the original. + # + # prune:: prune all parents to this node from the returned copy + # + # === Block + # + # The given block will receive the level (from the entry node) and + # the node itself for each node. + # + # === Returns + # the entry node to the new Pathtree or nil if no nodes matched the + # given block. + # + # === Example + # + # copy = original.filter { |l, n| n.data == "smurf" } + # + # The above will return a tree with nodes whose data is equal to 'smurf' + def filter(prune: false) + raise InvalidArgument, "No block given!" unless block_given? + + # build the filtered copy + copy = nil + traverse_preorder do |level, n| + if yield(level, n) + p = n.pathname + copy.nil? ? copy = PathTree.new(p, n.data) : copy.add_path(p, n.data) + end + end + + return nil if copy.nil? + + # always return the entry node but return a pruned version if + # the user wishes + entry_node = copy.node(pathname, from_root: true) + (prune ? entry_node.dup : entry_node) + end + + private + + def clean(path) + Pathname.new(path).cleanpath + end + + def leaf_first(left, right) + if left.leaf? != right.leaf? + # always return leaf before non-leaf + return left.leaf? ? -1 : 1 + end + + # for two non-leafs, return lexical order + left.segment <=> right.segment + end + + def get_child(segment_name) + ch = @children.select { |c| c.segment == segment_name.to_s } + ch.length.zero? ? nil : ch[0] + end +end diff --git a/gran/lib/gran/treeconverter.rb b/gran/lib/gran/treeconverter.rb new file mode 100644 index 0000000..0d25400 --- /dev/null +++ b/gran/lib/gran/treeconverter.rb @@ -0,0 +1,333 @@ +require_relative "pathtree" + +module Gran + # Converts all nodes in the supplied src PathTree from adoc to the format + # given by the user. + # + # Requires that all leaf nodes has a 'data' member that can receive an + # 'adoc_source' method that returns a string with the source to be converted. + # + # implements three phases with user hooks: + # pre_build -> build -> post_build + # + # Prebuild:: + # add a pre_builder object that responds to: + # def run(src_tree, dst_tree, converter) + # where + # src_tree:: the node in a PathTree corresponding to the top of the + # src directory + # dst_tree:: the node in a PathTree corresponding to the top of the + # dst directory + # converter:: the specific converter used to convert the adoc source to + # the desired destination format. + + class TreeConverter + attr_reader :dst_tree, :pre_builders, :post_builders, :converter + + class << self + # register all asciidoctor extensions given at instantiation + # + # adoc_ext:: + # { preprocessor: [], ... } + # + # see https://docs.asciidoctor.org/asciidoctor/latest/extensions/register/ + def register_adoc_extensions(adoc_ext) + return if adoc_ext.nil? + + %i[preprocessor tree_processor postprocessor docinfo_processor block + block_macro inline_macro include_processor].each do |e| + next unless adoc_ext.key?(e) + + Array(adoc_ext[e])&.each do |c| + Giblog.logger.debug { "Register #{c.class} as #{e}" } + Asciidoctor::Extensions.register { send(e, c) } + end + end + end + + def unregister_adoc_extenstions + Asciidoctor::Extensions.unregister_all + end + end + + # opts: + # logger: the logger used internally by this instance (default nil) + # adoc_log_level - the log level when logging messages emitted by asciidoctor + # (default Logger::Severity::WARN) + # pre_builders + # post_builders + # adoc_api_opts + # adoc_doc_attribs + # conversion_cb {success: Proc(src,dst,adoc) fail: Proc(src,dst,exc) + def initialize(src_top, dst_top, opts = {}) + # setup logging + @logger = opts.fetch(:logger, Giblog.logger) + @adoc_log_level = opts.fetch(:adoc_log_level, Logger::Severity::WARN) + + # get the top-most node of the source and destination trees + @src_tree = src_top + @dst_tree = PathTree.new(dst_top).node(dst_top, from_root: true) + + # setup build-phase callback objects + @pre_builders = Array(opts.fetch(:pre_builders, [])) + @post_builders = Array(opts.fetch(:post_builders, [])) + @converter = DefaultConverter.new(@logger, opts) + @adoc_ext = opts.fetch(:adoc_extensions, nil) + end + + # abort_on_exc:: if true, an exception lower down the chain will + # abort the conversion and raised to the caller. If false, exceptions + # will be swallowed. In both cases, an 'error' log entry is created. + def run(abort_on_exc: true) + TreeConverter.register_adoc_extensions(@adoc_ext) + pre_build(abort_on_exc: abort_on_exc) + build(abort_on_exc: abort_on_exc) + post_build(abort_on_exc: abort_on_exc) + ensure + TreeConverter.unregister_adoc_extenstions + end + + def pre_build(abort_on_exc: true) + @pre_builders.each do |pb| + pb.on_prebuild(@src_tree, @dst_tree, @converter) + rescue => ex + @logger&.error { ex.message.to_s } + raise ex if abort_on_exc + end + end + + def build(abort_on_exc: true) + @src_tree.traverse_preorder do |level, n| + next unless n.leaf? + + # create the destination node, using the correct suffix depending on conversion backend + rel_path = n.relative_path_from(@src_tree) + Giblog.logger.debug { "Creating dst node: #{rel_path}" } + dst_node = @dst_tree.add_descendants(rel_path) + + # perform the conversion + @converter.convert(n, dst_node, @dst_tree) + rescue => exc + @logger&.error { "#{n.pathname} - #{exc.message}" } + raise exc if abort_on_exc + end + end + + def post_build(abort_on_exc: true) + @post_builders.each do |pb| + pb.on_postbuild(@src_tree, @dst_tree, @converter) + rescue => exc + raise exc if abort_on_exc + @logger&.error { exc.message.to_s } + end + end + + # the default callback will tie a 'SuccessfulConversion' instance + # to the destination node as its data + def self.on_success(src_node, dst_node, dst_tree, doc, adoc_log_str) + dst_node.data = DataDelegator.new(SuccessfulConversion.new( + src_node: src_node, dst_node: dst_node, dst_top: dst_tree, adoc: doc, adoc_stderr: adoc_log_str + )) + end + + # the default callback will tie a 'FailedConversion' instance + # to the destination node as its data + def self.on_failure(src_node, dst_node, dst_tree, ex, adoc_log_str) + Giblog.logger.error { ex.message } + dst_node.data = DataDelegator.new(FailedConversion.new( + src_node: src_node, dst_node: dst_node, dst_top: dst_tree, error_msg: ex.message + )) + end + end + + class DefaultConverter + attr_accessor :adoc_api_opts + # see https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes-reference/ + DEFAULT_ADOC_DOC_ATTRIBS = { + "data-uri" => true, + "hide-uri-scheme" => true, + "xrefstyle" => "short", + "source-highlighter" => "rouge", + "source-linenums-option" => true + } + + # see https://docs.asciidoctor.org/asciidoctor/latest/api/options/ + DEFAULT_ADOC_API_OPTS = { + # backend: "html5", + # base_dir: + # catalog_assets: false, + # converter: + # doctype: "article", + # eruby: + # ignore extention stuff + # header_only: false, + # logger: + # mkdirs: false, + # parse: true, + safe: :unsafe, + sourcemap: true, + # template stuff TBD, + # to_file: + # to_dir: + standalone: true + } + + # opts: + # adoc_log_level + # adoc_api_opts + # adoc_doc_attribs + # conversion_cb { + # success: lambda(src, dst, dst_rel_path, doc, logstr) + # fail: lambda(src,dst,exc) + # } + def initialize(logger, opts) + @logger = logger + @adoc_log_level = opts.fetch(:adoc_log_level, Logger::Severity::WARN) + @conv_cb = opts.fetch(:conversion_cb, { + success: ->(src, dst, dst_rel_path, doc, logstr) { TreeConverter.on_success(src, dst, dst_rel_path, doc, logstr) }, + failure: ->(src, dst, dst_rel_path, ex, logstr) { TreeConverter.on_failure(src, dst, dst_rel_path, ex, logstr) } + }) + + # cache external configuration + @config_opts = opts.dup + end + + # Resolve the document attributes according to the precedence. + # + # According to https://docs.asciidoctor.org/asciidoc/latest/attributes/assignment-precedence/ + # The attribute precedence is: + # 1. An attribute passed to the API or CLI whose value does not end in @ + # 2. An attribute defined in the document + # 3. An attribute passed to the API or CLI whose value or name ends in @ + # 4. The default value of the attribute, if applicable + # + # giblish adds the following rules: + # 1.5 An attribute defined in an attribute provider for a specific source node + # 3.5 The default value set by giblish, if applicable + def resolve_doc_attributes(doc_src, node_attr) + # rule 3.5 + doc_attr = DEFAULT_ADOC_DOC_ATTRIBS.dup + + # sort attribs into soft and hard (rule 1 and 3) + soft_attr = {} + hard_attr = {} + @config_opts.fetch(:adoc_doc_attribs, {}).each do |k, v| + ks = k.to_s.strip + vs = v.to_s.strip + + if ks.end_with?("@") + soft_attr[ks[0..]] = vs + next + end + if vs.end_with?("@") + soft_attr[ks] = vs[0..] + next + end + hard_attr[ks] = vs + end + + # rule 3. + doc_attr.merge!(soft_attr) + + # rule 2 + Giblish.process_header_lines(doc_src.lines) do |line| + a = /^:(.+):(.*)$/.match(line) + next unless a + @logger.debug { "got header attr from doc: #{a[1]} : #{a[2]}" } + doc_attr[a[1].strip] = a[2].strip + end + + @logger.debug { "idprefix before: #{doc_attr["idprefix"]}" } + + # rule 1.5 + doc_attr.merge!(node_attr) + + # rule 1. + doc_attr.merge!(hard_attr) + + @logger.debug { "idprefix after: #{doc_attr["idprefix"]}" } + + # @logger&.debug { "Header attribs: #{doc_attr}" } + doc_attr + end + + # require the following methods to be available from the src node: + # adoc_source + # + # the following methods will be called if supported: + # document_attributes + # api_options + # + # src_node:: the PathTree node containing the info on adoc source and any + # added api_options or doc_attributes + # dst_node:: the PathTree node where conversion info is to be stored + # dst_top:: the PathTree node representing the top dir of the destination + # under which all converted files are written. + def convert(src_node, dst_node, dst_top) + @logger&.info { "Converting #{src_node.pathname} and store result under #{dst_node.parent.pathname}" } + + # merge the common api opts with node specific + api_opts = DEFAULT_ADOC_API_OPTS.dup + api_opts.merge!(@config_opts.fetch(:adoc_api_opts, {})) + api_opts.merge!(src_node.api_options(src_node, dst_node, dst_top)) if src_node.respond_to?(:api_options) + + # use a new logger instance for each conversion + adoc_logger = Giblish::AsciidoctorLogger.new(@logger, @adoc_log_level) + + begin + doc_src = src_node.adoc_source(src_node, dst_node, dst_top) + + node_attr = src_node.respond_to?(:document_attributes) ? + src_node.document_attributes(src_node, dst_node, dst_top) : {} + doc_attr = resolve_doc_attributes(doc_src, node_attr) + # piggy-back our own info on the doc attributes hash so that + # asciidoctor extensions can use this info later on + doc_attr["giblish-info"] = { + src_node: src_node, + dst_node: dst_node, + dst_top: dst_top + } + + # load the source to enable access to doc attributes and properties + # + # NOTE: 'parse' is set to false to prevent preprocessor extensions to be run as part + # of loading the document. We want them to run during the 'convert' call later when + # doc attribs have been amended. + # + # NOTE2: by trial-and-error, it seems that some document attributes must be set when + # calling 'load' and not added after the call and before the 'convert' call to have + # the expected effect (e.g. idprefix). + doc = Asciidoctor.load(doc_src, api_opts.merge( + { + attributes: doc_attr, + parse: false, + logger: adoc_logger + } + )) + + # update the destination node with the correct file suffix. This is dependent + # on the type of conversion performed + dst_node.name = dst_node.name.sub_ext(doc.attributes["outfilesuffix"]) + d = dst_node.pathname + + # make sure the dst dir exists + d.dirname.mkpath + + # do the conversion and write the converted doc to file + output = doc.convert(api_opts) + doc.write(output, d.to_s) + + # give the user the opportunity to eg store the result of the conversion + # as data in the destination node + @conv_cb[:success]&.call(src_node, dst_node, dst_top, doc, adoc_logger.in_mem_storage.string) + true + rescue => ex + @logger&.error { "Conversion failed for #{src_node.pathname}" } + @logger&.error { ex.message } + @logger&.error { ex.backtrace } + @conv_cb[:failure]&.call(src_node, dst_node, dst_top, ex, adoc_logger.in_mem_storage.string) + false + end + end + end +end diff --git a/gran/lib/gran/version.rb b/gran/lib/gran/version.rb new file mode 100644 index 0000000..96670e2 --- /dev/null +++ b/gran/lib/gran/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Gran + VERSION = "0.1.0" +end diff --git a/gran/test/test_gran.rb b/gran/test/test_gran.rb new file mode 100644 index 0000000..d7ca4b1 --- /dev/null +++ b/gran/test/test_gran.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "test_helper" + +class TestGran < Minitest::Test + def test_that_it_has_a_version_number + refute_nil ::Gran::VERSION + end + + # def test_it_does_something_useful + # assert false + # end +end diff --git a/gran/test/test_helper.rb b/gran/test/test_helper.rb new file mode 100644 index 0000000..dfbc834 --- /dev/null +++ b/gran/test/test_helper.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) +require "gran" + +require "minitest/autorun" diff --git a/gran/test/test_pathtree.rb b/gran/test/test_pathtree.rb new file mode 100644 index 0000000..b361c65 --- /dev/null +++ b/gran/test/test_pathtree.rb @@ -0,0 +1,473 @@ +require "fileutils" +require_relative "../lib/gran/pathtree" + +module Gran + class PathTreeTest < Minitest::Test + + def tree_with_slash + t = PathTree.new("/1") + {"/1/2/4" => 124, "/1/2/5" => 125, "/1/2/6" => 126, "/1/3" => 13}.each { |p, d| + t.add_path(p, d) + } + t + end + + def tree_without_slash + t = PathTree.new("1") + {"1/2/4" => 124, "1/2/5" => 125, "1/2/6" => 126, "1/3" => 13}.each { |p, d| + t.add_path(p, d) + } + t + end + + def test_wrong_args_paths + root = PathTree.new("1") + root.add_path("1/2") + + # can not add path with differing root + assert_raises(ArgumentError) { + root.add_path("2") + } + + # can not add existing path + assert_raises(ArgumentError) { + root.add_path("1/2") + } + end + + def test_whitespace_root + t = PathTree.new(" ") + assert_equal(0, t.children.length) + assert_nil(t.parent) + assert_equal(" ", t.segment) + assert_equal(Pathname.new(" "), t.pathname) + c = 0 + t.traverse_preorder { |level, node| c += 1 } + t.traverse_postorder { |level, node| c += 1 } + assert_equal(2, c) + end + + # def test_parent_names + # t = PathTree.new('/hej/hopp') + # assert_equal(["hopp", "hej"], t.parent_names) + # end + def test_root_node + t = PathTree.new("/") + assert_equal(Pathname.new("/"), t.pathname) + assert_equal("/", t.segment) + assert_equal(1, t.count) + + s = PathTree.new("/1") + assert_equal(2, s.count) + end + + def test_name + t = PathTree.new("/1") + {"/1/2/4" => 124, "/1/2/5" => 125, "/1/2/6" => 126, "/1/3" => 13}.each { |p, d| + t.add_path(p, d) + } + + k = t.node("1/2/4") + k.name = "44" + assert(Pathname.new("1/2/44"), k.pathname) + + assert_raises(ArgumentError) { + k.name = "5" + } + + t.name = "11" + assert(Pathname.new("11/2,44"), k.pathname) + end + + def test_pathname + t = PathTree.new("/1") + {"/1/2/4" => 124, "/1/2/5" => 125, "/1/2/6" => 126, "/1/3" => 13}.each { |p, d| + t.add_path(p, d) + } + assert_equal(Pathname.new("/"), t.pathname) + assert_equal(Pathname.new("/1/3"), t.node("1/3").pathname) + assert_equal(Pathname.new("/1/2"), t.node("1/2").pathname) + assert_equal(Pathname.new("/1/2/4"), t.node("1/2/4").pathname) + end + + def test_leaves + t = tree_with_slash + assert_equal( + [Pathname.new("/1/2/4"), Pathname.new("/1/2/5"), + Pathname.new("/1/2/6"), Pathname.new("/1/3")], + t.leave_pathnames + ) + + k = t.node("1/2") + assert_equal( + [Pathname.new("/1/2/4"), Pathname.new("/1/2/5"), + Pathname.new("/1/2/6")], + k.leave_pathnames + ) + end + + def test_dup_with_slash + origin = PathTree.new("/1") + {"/1/2/4" => 124, + "/1/2/5" => 125, + "/1/2/6" => 126, + "/1/3" => 13}.each { |p, d| + origin.add_path(p, d) + } + copy = origin.dup + assert(copy.object_id != origin.object_id) + assert_equal(origin.count, copy.count) + + copy.traverse_preorder do |l, n| + unless n.leaf? + assert_nil(n.data) + next + end + + origin_node = origin.node(n.pathname, from_root: true) + + assert(origin_node.object_id != n.object_id) + assert(origin_node.data.equal?(n.data)) unless origin_node.data.nil? + assert_equal(origin_node.segment, n.segment) + assert_equal(origin_node.data, n.data) + end + end + + def test_dup_without_slash + origin = PathTree.new("1") + {"1/2/4" => 124, + "1/2/5" => 125, + "1/2/6" => 126, + "1/3" => 13}.each { |p, d| + origin.add_path(p, d) + } + copy = origin.dup + assert(copy.object_id != origin.object_id) + + copy.traverse_preorder do |l, n| + unless n.leaf? + assert_nil(n.data) + next + end + + origin_node = origin.node(n.pathname, from_root: true) + + assert(origin_node.object_id != n.object_id) + assert(origin_node.data.equal?(n.data)) unless origin_node.data.nil? + assert_equal(origin_node.segment, n.segment) + assert_equal(origin_node.data, n.data) + end + end + + def test_preorder_ok + root = PathTree.new("1") + {"1/2/4" => 124, + "1/2/5" => 125, + "1/2/6" => 126, + "1/3" => 13}.each { |p, d| + root.add_path(p, d) + } + + order = "" + data, level = [], [] + root.traverse_preorder do |l, node| + level << l + order << node.segment + data << node.data + end + assert_equal("124563", order) + assert_equal([0, 1, 2, 2, 2, 1], level) + assert_equal([nil, nil, 124, 125, 126, 13], data) + end + + def test_postorder_ok + root = PathTree.new("1") + {"1/2/4" => 124, + "1/2/5" => 125, + "1/2/6" => 126, + "1/3" => 13}.each { |p, d| + root.add_path(p, d) + } + + order = "" + data = [] + level = [] + root.traverse_postorder do |l, node| + level << l + order << node.segment + data << node.data + end + assert_equal("456231", order) + assert_equal([2, 2, 2, 1, 1, 0], level) + assert_equal([124, 125, 126, nil, 13, nil], data) + end + + def test_levelorder_ok + root = PathTree.new("1") + {"1/2/4" => 124, + "1/2/5" => 125, + "1/2/6" => 126, + "1/3" => 13}.each { |p, d| + root.add_path(p, d) + } + + order = "" + data = [] + level = [] + root.traverse_levelorder do |l, node| + level << l + order << node.segment + data << node.data + end + assert_equal("123456", order) + assert_equal([0, 1, 1, 2, 2, 2], level) + assert_equal([nil, nil, 13, 124, 125, 126], data) + end + + def test_node + root = PathTree.new("1") + {"1/2/4" => 124, + "1/2/5" => 125, + "1/2/6" => 126, + "1/3" => 13}.each { |p, d| + root.add_path(p, d) + } + + order = "" + data = [] + level = [] + node = root.node("1/2", from_root: true) + assert_equal(node, root.node("2")) + + node.traverse_preorder do |l, node| + level << l + order << node.segment + data << node.data + end + assert_equal("2456", order) + assert_equal([0, 1, 1, 1], level) + assert_equal([nil, 124, 125, 126], data) + + node = root.node("1/4") + assert_nil(node) + end + + def test_append_tree + root = PathTree.new("1") + {"1/2/4" => 124, + "1/2/5" => 125, + "1/2/6" => 126, + "1/3" => 13}.each { |p, d| + root.add_path(p, d) + } + + newtree = PathTree.new("3") + {"3/4" => 34, + "3/5" => 35, + "3/6" => 36, + "3/7/8" => 378}.each { |p, d| + newtree.add_path(p, d) + } + + # append newtree to a leaf of root + n = root.node("2/6") + n.append_tree(newtree) + + order = "" + data = [] + level = [] + root.traverse_preorder do |l, node| + level << l + order << node.segment + data << node.data + end + assert_equal("124563456783", order) + assert_equal([0, 1, 2, 2, 2, 3, 4, 4, 4, 4, 5, 1], level) + assert_equal([nil, nil, 124, 125, 126, nil, 34, 35, 36, nil, 378, 13], data) + end + + def test_append_tree_2 + t = PathTree.new("/1/2") + t.node("1/2").append_tree(PathTree.new("my/new/tree")) + assert_equal(6, t.count) + assert(!t.node("1/2/my/new/tree").nil?) + + t.node("1/").append_tree(PathTree.new("my/new/tree")) + assert_equal(9, t.count) + assert(!t.node("1/my/new/tree").nil?) + end + + def test_trying_append_existing_path + root = PathTree.new("1") + {"1/2/4" => 124, + "1/2/5" => 125, + "1/2/6" => 126, + "1/3" => 13}.each { |p, d| + root.add_path(p, d) + } + + newtree = PathTree.new("2") + {"2/4" => 34, + "2/5" => 35, + "2/6" => 36}.each { |p, d| + newtree.add_path(p, d) + } + + # try to append newtree to root (should fail, overlapping nodes) + assert_raises(ArgumentError) { + root.append_tree(newtree) + } + + # successfully append newtree to a leaf of root + n = root.node("2") + n.append_tree(newtree) + end + + def test_split_stem + root = PathTree.new("/1/2") + root.node("1/2").append_tree(PathTree.new("my/new/tree")) + n = root.node("1/2/my") + + assert_equal( + [Pathname.new("/1/2"), Pathname.new("my")], + root.node("/1/2/my", from_root: true).split_stem + ) + + assert_equal( + [Pathname.new("/1/2/my/new"), Pathname.new("tree")], + root.node("1/2/my/new/tree").split_stem + ) + + d = n.add_descendants("new/branch") + assert_equal(root.node("1/2/my/new/branch"), d) + + assert_equal( + [Pathname.new("/1/2/my/new"), Pathname.new("tree")], + root.node("1/2/my/new/tree").split_stem + ) + + assert_equal( + [Pathname.new("/1/2/my/new"), Pathname.new("branch")], + n.node("/1/2/my/new/branch", from_root: true).split_stem + ) + + root.node("1/2/my").add_descendants("another/branch") + + assert_equal( + [Pathname.new("/1/2/my"), Pathname.new("new/branch")], + n.node("new/branch").split_stem + ) + + assert_equal( + [Pathname.new("/1/2"), Pathname.new("my")], + root.node("1/2/my").split_stem + ) + + assert_equal( + [nil, Pathname.new("/")], + root.node("1/2/my").root.split_stem + ) + end + + def test_copy_to_other_root + src = PathTree.new("src/my/tst/tree") + dst = PathTree.new("dst") + src.children.each { |c| dst.append_tree(c) } + + assert_equal(4, dst.count) + assert(!dst.node("my/tst/tree").nil?) + + src = PathTree.new("/src/leaf") + dst = PathTree.new("dst") + src.node("src").children.each { |c| dst.append_tree(c) } + + assert_equal(2, dst.count) + assert(!dst.node("leaf").nil?) + end + + def test_build_from_fs + # a bit hack-ish but we expect a 'hooks' dir within a dir named '.git' + # since this file is part of a git repo + p = PathTree.build_from_fs("#{__dir__}/../../.git", prune: true) do |pt| + pt.extname == ".sample" + end + expected_dirs = %w[hooks] + found_dirs = [] + p.traverse_levelorder do |l, node| + next unless l == 1 && !node.leaf? + + found_dirs << node.segment + end + assert_equal(expected_dirs, found_dirs) + end + + def test_delegate_to_data + # create a leaf node with a 'String' as data + root = PathTree.new("/my/new/tree", "my data") + n = root.node("my/new/tree") + assert_equal("my data", n.data) + + # call String::split via delegation + assert_equal(["my", "data"], n.split) + + # call String::upcase via delegation + assert_equal("MY DATA", n.upcase) + end + + def test_to_s + root = PathTree.new("1") + {"1/2/4" => 124, + "1/2/5" => 125, + "1/2/6" => 126, + "1/3" => 13}.each { |p, d| + root.add_path(p, d) + } + + value = root.to_s + "\n" + expect = <<~TREE_STR + |-- 1 + |-- 2 + |-- 4 <124> + |-- 5 <125> + |-- 6 <126> + |-- 3 <13> + TREE_STR + + assert_equal(expect, value) + end + + def test_match + root = PathTree.new("1") + {"1/2" => 12, + "1/2/4" => 124, + "1/2/5" => 125, + "1/2/6" => 126, + "1/3" => 13}.each { |p, d| + root.add_path(p, d) + } + + copy = root.match(/2$/) + + assert_equal(2, copy.count) + assert_equal(Pathname.new("1/2"), copy.node("2").pathname) + assert_equal(12, copy.node("2").data) + end + + def test_filter + root = PathTree.new("1") + {"1/2" => 12, + "1/2/4" => 124, + "1/2/5" => 125, + "1/2/6" => 126, + "1/3" => 13}.each { |p, d| + root.add_path(p, d) + } + + copy = root.filter { |l, n| /2$/ =~ n.pathname.to_s } + + assert_equal(2, copy.count) + assert_equal(Pathname.new("1/2"), copy.node("2").pathname) + assert_equal(12, copy.node("2").data) + end + end +end diff --git a/gran/test/test_treeconverter.rb b/gran/test/test_treeconverter.rb new file mode 100644 index 0000000..6852e82 --- /dev/null +++ b/gran/test/test_treeconverter.rb @@ -0,0 +1,193 @@ +require_relative "../lib/gran/treeconverter" +require_relative "../lib/gran/pathtree" + +module Gran + class TreeConverterTest < MiniTest::Test + def tree_from_src_dir(top_dir) + src_tree = PathTree.build_from_fs(top_dir, prune: false) do |pt| + !pt.directory? && pt.extname == ".adoc" + end + src_tree.traverse_preorder do |level, n| + next unless n.leaf? + + n.data = SrcFromFile.new + end + src_tree + end + + def test_generate_html + TmpDocDir.open do |tmp_docs| + # create three adoc files under .../src and .../src/subdir + ["src", "src", "src/subdir"].each { |d| tmp_docs.add_doc_from_str(CreateAdocDocSrc.new, d) } + + # setup the corresponding PathTree + p = Pathname.new(tmp_docs.dir) + fs_root = tree_from_src_dir(p / "src") + + # find the PathTree node pointing to the "src" dir + st = fs_root.node(p / "src", from_root: true) + + assert_equal(3, st.leave_pathnames.count) + + # init a converter that use ".../src" as the top dir, + # and generates html to ".../dst" + tc = TreeConverter.new(st, p / "dst") + tc.run + + # get the node in the dst tree that points to .../dst + dt = tc.dst_tree.node(p / "dst", from_root: true) + + # assert that there now are 3 html files under "dst" + assert_equal(3, dt.leave_pathnames.count) + assert_equal( + st.leave_pathnames.collect { |p| p.sub_ext(".html").relative_path_from(st.pathname) }, + dt.leave_pathnames.collect { |p| p.relative_path_from(dt.pathname) } + ) + end + end + + def test_generate_html_docs_from_str + TmpDocDir.open do |tmp_docs| + p = Pathname.new(tmp_docs.dir) + + # setup a 'virtual' PathTree using strings as content for the nodes + root = PathTree.new(p / "src/metafile_1", SrcFromString.new(CreateAdocDocSrc.new.source)) + root.add_path(p / "src/metafile_2", SrcFromString.new(CreateAdocDocSrc.new.source)) + root.add_path(p / "src/subdir/metafile_3", SrcFromString.new(CreateAdocDocSrc.new.source)) + + st = root.node(p / "src", from_root: true) + + assert_equal(3, st.leave_pathnames.count) + + tc = TreeConverter.new(st, p / "dst") + tc.run + + # get the node in the dst tree that points to .../dst + dt = tc.dst_tree.node(p / "dst", from_root: true) + + # assert that there now are 3 html files under "dst" + assert_equal(3, dt.leave_pathnames.count) + assert_equal( + st.leave_pathnames.collect { |p| p.sub_ext(".html").relative_path_from(st.pathname) }, + dt.leave_pathnames.collect { |p| p.relative_path_from(dt.pathname) } + ) + end + end + + def test_adoc_logging + TmpDocDir.open do |tmp_docs| + # create three adoc files under .../src and .../src/subdir + ["src", "src", "src/subdir"].each { |d| tmp_docs.add_doc_from_str(CreateAdocDocSrc.new, d) } + p = Pathname.new(tmp_docs.dir) + + # write a non-conformant file to src + File.write((p / "src/bad.adoc").to_s, <<~BAD_ADOC + Badly formed doc + + === Out of level + + some text + + BAD_ADOC + ) + # setup the corresponding PathTree + fs_root = tree_from_src_dir(p / "src") + + # find the PathTree node pointing to the "src" dir + st = fs_root.node(p / "src", from_root: true) + + # init a converter that use the standard Giblog logger for its + # internal use but sets the asciidoc log level to only emit warnings + # and above. + # Supply callbacks that are called after each conversion + tc = TreeConverter.new(st, p / "dst", + { + logger: Giblog.logger, + adoc_log_level: Logger::WARN, + conversion_cb: { + success: ->(src, dst, dst_rel_path, doc, logstr) { + return unless dst.segment == "bad.html" + + # we know how the warning 'bad.adoc' should render. + assert_equal(": Line 3 - section title out of sequence: expected level 1, got level 2", + logstr.split("WARNING")[-1].chomp) + } + } + }) + tc.run + end + end + + def test_generate_pdf + TmpDocDir.open do |tmp_docs| + # create three adoc files under .../src and .../src/subdir + ["src", "src", "src/subdir"].each { |d| tmp_docs.add_doc_from_str(CreateAdocDocSrc.new, d) } + + # setup the corresponding PathTree + p = Pathname.new(tmp_docs.dir) + fs_root = tree_from_src_dir(p / "src") + + # find the PathTree node pointing to the "src" dir + st = fs_root.node(p / "src", from_root: true) + + assert_equal(3, st.leave_pathnames.count) + + # init a converter that use ".../src" as the top dir, + # and generates html to ".../dst" + tc = TreeConverter.new(st, p / "dst", + { + adoc_api_opts: { + backend: "pdf" + } + }) + tc.run + + # get the node in the dst tree that points to .../dst + dt = tc.dst_tree.node(p / "dst", from_root: true) + + # assert that there now are 3 html files under "dst" + assert_equal(3, dt.leave_pathnames.count) + assert_equal( + st.leave_pathnames.collect { |p| p.sub_ext(".pdf").relative_path_from(st.pathname) }, + dt.leave_pathnames.collect { |p| p.relative_path_from(dt.pathname) } + ) + end + end + + def test_generate_epub + TmpDocDir.open do |tmp_docs| + # create three adoc files under .../src and .../src/subdir + ["src", "src", "src/subdir"].each { |d| tmp_docs.add_doc_from_str(CreateAdocDocSrc.new, d) } + + # setup the corresponding PathTree + p = Pathname.new(tmp_docs.dir) + fs_root = tree_from_src_dir(p / "src") + + # find the PathTree node pointing to the "src" dir + st = fs_root.node(p / "src", from_root: true) + + assert_equal(3, st.leave_pathnames.count) + + # init a converter that use ".../src" as the top dir, + # and generates html to ".../dst" + tc = TreeConverter.new(st, p / "dst", + { + adoc_api_opts: { + backend: "docbook5" + } + }) + tc.run + + # get the node in the dst tree that points to .../dst + dt = tc.dst_tree.node(p / "dst", from_root: true) + + # assert that there now are 3 html files under "dst" + assert_equal(3, dt.leave_pathnames.count) + assert_equal( + st.leave_pathnames.collect { |p| p.sub_ext(".xml").relative_path_from(st.pathname) }, + dt.leave_pathnames.collect { |p| p.relative_path_from(dt.pathname) } + ) + end + end + end +end