gran 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +18 -0
- data/LICENSE.txt +21 -0
- data/README.md +37 -0
- data/Rakefile +16 -0
- data/lib/gran/loggable.rb +25 -0
- data/lib/gran/pathtree.rb +556 -0
- data/lib/gran/tree_transformer.rb +215 -0
- data/lib/gran/version.rb +5 -0
- data/lib/gran.rb +45 -0
- metadata +58 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 565f612153803ad8451b299b172a4880bd9f98149c279773344a5e56043ea3ec
|
4
|
+
data.tar.gz: 027ff3712bde1dae0fa350fb2842efba6a4e120194d29823345629109b7fb9bb
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 813be36fdbb481c4d18ef6c9677865ae15d9c8c17296a841ccbda231ee293c99ac2115b7637900cd55f9244db01b479ded46b405461e0f4d78d7b29e9b7594bd
|
7
|
+
data.tar.gz: 732312e0d45347ecb4bd9893a84f67d50be0a39567607b6176ed4cf337204430bc0a0dd184d7efb4789fdfe8bc49a183ee16a54abdbb4b9f1dfef3e210312557
|
data/CHANGELOG.md
ADDED
data/CODE_OF_CONDUCT.md
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
# Contributor Covenant Code of Conduct
|
2
|
+
|
3
|
+
## Our Pledge
|
4
|
+
|
5
|
+
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.
|
6
|
+
|
7
|
+
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
8
|
+
|
9
|
+
## Our Standards
|
10
|
+
|
11
|
+
Examples of behavior that contributes to a positive environment for our community include:
|
12
|
+
|
13
|
+
* Demonstrating empathy and kindness toward other people
|
14
|
+
* Being respectful of differing opinions, viewpoints, and experiences
|
15
|
+
* Giving and gracefully accepting constructive feedback
|
16
|
+
* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
|
17
|
+
* Focusing on what is best not just for us as individuals, but for the overall community
|
18
|
+
|
19
|
+
Examples of unacceptable behavior include:
|
20
|
+
|
21
|
+
* The use of sexualized language or imagery, and sexual attention or
|
22
|
+
advances of any kind
|
23
|
+
* Trolling, insulting or derogatory comments, and personal or political attacks
|
24
|
+
* Public or private harassment
|
25
|
+
* Publishing others' private information, such as a physical or email
|
26
|
+
address, without their explicit permission
|
27
|
+
* Other conduct which could reasonably be considered inappropriate in a
|
28
|
+
professional setting
|
29
|
+
|
30
|
+
## Enforcement Responsibilities
|
31
|
+
|
32
|
+
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.
|
33
|
+
|
34
|
+
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.
|
35
|
+
|
36
|
+
## Scope
|
37
|
+
|
38
|
+
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.
|
39
|
+
|
40
|
+
## Enforcement
|
41
|
+
|
42
|
+
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.
|
43
|
+
|
44
|
+
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
45
|
+
|
46
|
+
## Enforcement Guidelines
|
47
|
+
|
48
|
+
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
|
49
|
+
|
50
|
+
### 1. Correction
|
51
|
+
|
52
|
+
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
53
|
+
|
54
|
+
**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.
|
55
|
+
|
56
|
+
### 2. Warning
|
57
|
+
|
58
|
+
**Community Impact**: A violation through a single incident or series of actions.
|
59
|
+
|
60
|
+
**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.
|
61
|
+
|
62
|
+
### 3. Temporary Ban
|
63
|
+
|
64
|
+
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
|
65
|
+
|
66
|
+
**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.
|
67
|
+
|
68
|
+
### 4. Permanent Ban
|
69
|
+
|
70
|
+
**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.
|
71
|
+
|
72
|
+
**Consequence**: A permanent ban from any sort of public interaction within the community.
|
73
|
+
|
74
|
+
## Attribution
|
75
|
+
|
76
|
+
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0,
|
77
|
+
available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
78
|
+
|
79
|
+
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
|
80
|
+
|
81
|
+
[homepage]: https://www.contributor-covenant.org
|
82
|
+
|
83
|
+
For answers to common questions about this code of conduct, see the FAQ at
|
84
|
+
https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
|
data/Gemfile
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
source "https://rubygems.org"
|
2
|
+
|
3
|
+
# Use the gemspec for all runtime dependencies and other
|
4
|
+
# metadata on the gem
|
5
|
+
gemspec
|
6
|
+
|
7
|
+
group :development, :test do
|
8
|
+
gem "logging", "~> 2.4"
|
9
|
+
gem "yard", "~> 0.9"
|
10
|
+
gem "ruby-lsp", "~> 0.18"
|
11
|
+
gem "standard", "~> 1.0"
|
12
|
+
gem "rake", "~> 13.0"
|
13
|
+
end
|
14
|
+
|
15
|
+
group :test do
|
16
|
+
gem "minitest", "~> 5.0"
|
17
|
+
gem "minitest-hooks", "~> 1.5"
|
18
|
+
end
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2024 Anders Rillbert
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
# Gran
|
2
|
+
|
3
|
+
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.
|
4
|
+
|
5
|
+
TODO: Delete this and the text above, and describe your gem
|
6
|
+
|
7
|
+
## Installation
|
8
|
+
|
9
|
+
Install the gem and add to the application's Gemfile by executing:
|
10
|
+
|
11
|
+
$ bundle add gran
|
12
|
+
|
13
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
14
|
+
|
15
|
+
$ gem install gran
|
16
|
+
|
17
|
+
## Usage
|
18
|
+
|
19
|
+
TODO: Write usage instructions here
|
20
|
+
|
21
|
+
## Development
|
22
|
+
|
23
|
+
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.
|
24
|
+
|
25
|
+
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).
|
26
|
+
|
27
|
+
## Contributing
|
28
|
+
|
29
|
+
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).
|
30
|
+
|
31
|
+
## License
|
32
|
+
|
33
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
34
|
+
|
35
|
+
## Code of Conduct
|
36
|
+
|
37
|
+
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).
|
data/Rakefile
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "bundler/gem_tasks"
|
4
|
+
require "rake/testtask"
|
5
|
+
|
6
|
+
# Bundler.require(:test, :development)
|
7
|
+
|
8
|
+
Rake::TestTask.new(:test) do |t|
|
9
|
+
t.libs << "test"
|
10
|
+
t.libs << "lib"
|
11
|
+
t.test_files = FileList["test/**/test_*.rb"]
|
12
|
+
end
|
13
|
+
|
14
|
+
require "standard/rake"
|
15
|
+
|
16
|
+
task default: %i[test standard]
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module Gran
|
2
|
+
module Loggable
|
3
|
+
def self.included(base)
|
4
|
+
base.extend(ClassMethods)
|
5
|
+
end
|
6
|
+
|
7
|
+
module ClassMethods
|
8
|
+
def logger
|
9
|
+
@logger ||= Gran.logger
|
10
|
+
end
|
11
|
+
|
12
|
+
def logger=(logger)
|
13
|
+
@logger = logger
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def logger
|
18
|
+
@logger ||= self.class.logger
|
19
|
+
end
|
20
|
+
|
21
|
+
def logger=(logger)
|
22
|
+
@logger = logger
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,556 @@
|
|
1
|
+
require "pathname"
|
2
|
+
require "set"
|
3
|
+
require_relative "loggable"
|
4
|
+
|
5
|
+
module Gran
|
6
|
+
#
|
7
|
+
# Provides a tree structure where each node is the basename of either
|
8
|
+
# a directory or a file. The pathname of a node is the concatenation of
|
9
|
+
# all basenames from the root node to the node in question, given as a
|
10
|
+
# Pathname object.
|
11
|
+
#
|
12
|
+
# Each node must have a unique pathname within the tree it is part of.
|
13
|
+
#
|
14
|
+
# A node can contain an associated 'data' object.
|
15
|
+
#
|
16
|
+
# The following paths:
|
17
|
+
# basedir/file_1
|
18
|
+
# basedir/file_2
|
19
|
+
# basedir/dir1/file_3
|
20
|
+
# basedir/dir1/file_4
|
21
|
+
# basedir/dir2/dir3/file_5
|
22
|
+
#
|
23
|
+
# are thus represented by the following path tree:
|
24
|
+
#
|
25
|
+
# basedir
|
26
|
+
# file_1
|
27
|
+
# file_2
|
28
|
+
# dir1
|
29
|
+
# file_3
|
30
|
+
# file_4
|
31
|
+
# dir2
|
32
|
+
# dir3
|
33
|
+
# file_5
|
34
|
+
#
|
35
|
+
# == Tree info
|
36
|
+
# see https://www.geeksforgeeks.org/tree-traversals-inorder-preorder-and-postorder/
|
37
|
+
#
|
38
|
+
class PathTree
|
39
|
+
include Loggable
|
40
|
+
|
41
|
+
attr_reader :data, :name, :children, :parent, :abs_root
|
42
|
+
attr_writer :parent, :data
|
43
|
+
|
44
|
+
def initialize(path, data = nil, parent = nil)
|
45
|
+
p = clean(path)
|
46
|
+
raise ArgumentError, "Can not instantiate node with path == '.'" if p.to_s == "."
|
47
|
+
raise ArgumentError, "Trying to create a non-root node using an absolute path" if p.absolute? && !parent.nil?
|
48
|
+
|
49
|
+
head = p.descend.first
|
50
|
+
|
51
|
+
@name = head
|
52
|
+
@children = []
|
53
|
+
@data = nil
|
54
|
+
@parent = parent
|
55
|
+
|
56
|
+
tail = p.relative_path_from(head)
|
57
|
+
if tail.to_s == "."
|
58
|
+
@data = data
|
59
|
+
return
|
60
|
+
end
|
61
|
+
|
62
|
+
add_descendants(tail, data)
|
63
|
+
end
|
64
|
+
|
65
|
+
# duplicate this node and all its children but keep the same data references
|
66
|
+
# as the originial nodes.
|
67
|
+
#
|
68
|
+
# parent:: the parent node of the copy, default = nil (the copy
|
69
|
+
# is a root node)
|
70
|
+
# returns:: a copy of this node and all its descendents. The copy will
|
71
|
+
# share any 'data' references with the original.
|
72
|
+
def dup(parent: nil)
|
73
|
+
d = PathTree.new(@name.dup, @data, parent)
|
74
|
+
|
75
|
+
@children.each { |c| d.children << c.dup(parent: d) }
|
76
|
+
d
|
77
|
+
end
|
78
|
+
|
79
|
+
def name=(name)
|
80
|
+
name = Pathname.new(name)
|
81
|
+
|
82
|
+
if !parent.nil? && @parent.children.any? { |c| c.name == name }
|
83
|
+
raise ArgumentError, "Can not rename to #{name}. An existing node already use that name"
|
84
|
+
end
|
85
|
+
|
86
|
+
@name = name
|
87
|
+
end
|
88
|
+
|
89
|
+
# return:: a String with the path segment for this node
|
90
|
+
def segment
|
91
|
+
@name.to_s
|
92
|
+
end
|
93
|
+
|
94
|
+
# return:: a Pathname with the complete path from the root of the
|
95
|
+
# tree where this node is a member to this node (inclusive).
|
96
|
+
def pathname
|
97
|
+
return @name if @parent.nil?
|
98
|
+
|
99
|
+
(@parent.pathname / @name).cleanpath
|
100
|
+
end
|
101
|
+
|
102
|
+
# create a subtree from the given path and add it to this node
|
103
|
+
#
|
104
|
+
# return:: the leaf node for the added subtree
|
105
|
+
def add_descendants(path, data = nil)
|
106
|
+
p = clean(path)
|
107
|
+
raise ArgumentError, "Can not add absolute path as descendant!!" if p.absolute?
|
108
|
+
|
109
|
+
# invoked with 'current' name, ignore
|
110
|
+
return self if p.to_s == "."
|
111
|
+
|
112
|
+
head = p.descend.first
|
113
|
+
tail = p.relative_path_from(head)
|
114
|
+
last_segment = tail.to_s == "."
|
115
|
+
|
116
|
+
ch = get_child(head)
|
117
|
+
if ch.nil?
|
118
|
+
@children << PathTree.new(head, last_segment ? data : nil, self)
|
119
|
+
ch = @children.last
|
120
|
+
end
|
121
|
+
|
122
|
+
last_segment ? @children.last : ch.add_descendants(tail, data)
|
123
|
+
end
|
124
|
+
|
125
|
+
# adds a new path to the root of the tree where this node is a member
|
126
|
+
# and associates the given data to the leaf of that path.
|
127
|
+
def add_path(path, data = nil)
|
128
|
+
p = clean(path)
|
129
|
+
raise ArgumentError, "Trying to add already existing path: #{path}" unless node(p, from_root: true).nil?
|
130
|
+
|
131
|
+
# prune any part of the given path that already exists in this
|
132
|
+
# tree
|
133
|
+
p.ascend do |q|
|
134
|
+
n = node(q, from_root: true)
|
135
|
+
next if n.nil?
|
136
|
+
|
137
|
+
t = PathTree.new(p.relative_path_from(q).to_s, data)
|
138
|
+
n.append_tree(t)
|
139
|
+
return self
|
140
|
+
end
|
141
|
+
|
142
|
+
# no part of the given path existed within the tree
|
143
|
+
raise ArgumentError, "Trying to add path with other root is not supported"
|
144
|
+
end
|
145
|
+
|
146
|
+
# Visits depth-first by root -> left -> right
|
147
|
+
#
|
148
|
+
# level:: the number of hops from the root node
|
149
|
+
# block:: the user supplied block that is executed for every visited node
|
150
|
+
#
|
151
|
+
# the level and node are given as block parameters
|
152
|
+
#
|
153
|
+
# === Returns
|
154
|
+
# A new array containing the values returned by the block
|
155
|
+
#
|
156
|
+
# === Examples
|
157
|
+
# Get an array with name of each node together with the level of the node
|
158
|
+
# traverse_preorder{ |level, n| "#{level} #{n.segment}" }
|
159
|
+
#
|
160
|
+
def traverse_preorder(level = 0, &block)
|
161
|
+
result = [yield(level, self)]
|
162
|
+
@children.each do |c|
|
163
|
+
result.append(*c.traverse_preorder(level + 1, &block))
|
164
|
+
end
|
165
|
+
result
|
166
|
+
end
|
167
|
+
|
168
|
+
# Visits depth-first by left -> right -> root
|
169
|
+
#
|
170
|
+
# level:: the number of hops from the root node
|
171
|
+
# block:: the user supplied block that is executed for every visited node
|
172
|
+
#
|
173
|
+
# the level and node are given as block parameters
|
174
|
+
#
|
175
|
+
# === Returns
|
176
|
+
# A new array containing the values returned by the block
|
177
|
+
#
|
178
|
+
# === Examples
|
179
|
+
#
|
180
|
+
# Get an array of each node together with the level of the node
|
181
|
+
# traverse_postorder{ |level, n| "#{level} #{n.segment}" }
|
182
|
+
def traverse_postorder(level = 0, &block)
|
183
|
+
result = []
|
184
|
+
@children.each do |c|
|
185
|
+
result.concat(c.traverse_postorder(level + 1, &block))
|
186
|
+
end
|
187
|
+
result << yield(level, self)
|
188
|
+
end
|
189
|
+
|
190
|
+
# Visits bredth-first left -> right for each level top-down
|
191
|
+
#
|
192
|
+
# level:: the number of hops from the root node
|
193
|
+
# block:: the user supplied block that is executed for every visited node
|
194
|
+
#
|
195
|
+
# the level and node are given as block parameters
|
196
|
+
#
|
197
|
+
# === Returns
|
198
|
+
# A new array containing the values returned by the block
|
199
|
+
#
|
200
|
+
# === Examples
|
201
|
+
# Get an array with the name of each node together with the level of the node
|
202
|
+
# traverse_levelorder { |level, n| "#{level} #{n.segment}" }
|
203
|
+
def traverse_levelorder(level = 0, &block)
|
204
|
+
result = []
|
205
|
+
# the node of the original call
|
206
|
+
result << yield(level, self) if level == 0
|
207
|
+
|
208
|
+
# this level
|
209
|
+
@children.each do |c|
|
210
|
+
result << yield(level + 1, c)
|
211
|
+
end
|
212
|
+
|
213
|
+
# next level
|
214
|
+
@children.each do |c|
|
215
|
+
result.concat(c.traverse_levelorder(level + 1, &block))
|
216
|
+
end
|
217
|
+
|
218
|
+
result
|
219
|
+
end
|
220
|
+
|
221
|
+
# Sort the nodes on each level in the tree in lexical order but put
|
222
|
+
# leafs before non-leafs.
|
223
|
+
def sort_leaf_first!
|
224
|
+
@children.sort! { |a, b| leaf_first(a, b) }
|
225
|
+
@children.each(&:sort_leaf_first!)
|
226
|
+
self
|
227
|
+
end
|
228
|
+
|
229
|
+
# returns:: the number of nodes in the subtree with this node as
|
230
|
+
# root
|
231
|
+
def count
|
232
|
+
result = 0
|
233
|
+
traverse_preorder do |level, node|
|
234
|
+
result += 1
|
235
|
+
end
|
236
|
+
result
|
237
|
+
end
|
238
|
+
|
239
|
+
# return:: true if the node is a leaf, false otherwise
|
240
|
+
def leaf?
|
241
|
+
@children.length.zero?
|
242
|
+
end
|
243
|
+
|
244
|
+
# return:: an array with Pathnames of each full
|
245
|
+
# path for the leaves in this tree
|
246
|
+
def leave_pathnames(prune: false)
|
247
|
+
paths = []
|
248
|
+
traverse_postorder do |l, n|
|
249
|
+
next unless n.leaf?
|
250
|
+
|
251
|
+
paths << (prune ? n.pathname.relative_path_from(pathname) : n.pathname)
|
252
|
+
end
|
253
|
+
paths
|
254
|
+
end
|
255
|
+
|
256
|
+
# return:: true if this node does not have a parent node
|
257
|
+
def root?
|
258
|
+
@parent.nil?
|
259
|
+
end
|
260
|
+
|
261
|
+
# return:: the root node of the tree where this node is a member
|
262
|
+
def root
|
263
|
+
return self if root?
|
264
|
+
|
265
|
+
@parent.root
|
266
|
+
end
|
267
|
+
|
268
|
+
# Check if a given path exists in the tree as a directory (non-leaf node)
|
269
|
+
#
|
270
|
+
# path:: a String or Pathname with the path to check
|
271
|
+
# from_root:: if true, start the search from the root of the tree
|
272
|
+
#
|
273
|
+
# return:: true if the path exists and is a directory, false otherwise
|
274
|
+
def dir?(path, from_root: true)
|
275
|
+
n = node(path, from_root: from_root)
|
276
|
+
!n.nil? && !n.leaf?
|
277
|
+
end
|
278
|
+
|
279
|
+
# Finds the node corresponding to the given path.
|
280
|
+
#
|
281
|
+
# path:: a String or Pathname with the path to search for
|
282
|
+
# from_root:: if true start the search from the root of the tree where
|
283
|
+
# this node is a member. If false, start the search from this node's
|
284
|
+
# children.
|
285
|
+
#
|
286
|
+
# return:: the node with the given path or nil if the path
|
287
|
+
# does not exist within this pathtree
|
288
|
+
def node(path, from_root: false)
|
289
|
+
p = clean(path)
|
290
|
+
root = nil
|
291
|
+
|
292
|
+
traverse_preorder do |level, node|
|
293
|
+
q = from_root ? node.pathname : node.pathname.relative_path_from(pathname)
|
294
|
+
if q == p
|
295
|
+
root = node
|
296
|
+
break
|
297
|
+
end
|
298
|
+
end
|
299
|
+
root
|
300
|
+
end
|
301
|
+
|
302
|
+
# adds a copy of the given Pathtree as a subtree to this node. the subtree can not
|
303
|
+
# contain nodes that will end up having the same pathname as any existing
|
304
|
+
# node in the target tree. Note that 'data' attributes will not be copied. The copied
|
305
|
+
# Pathtree nodes will thus point to the same data attributes as the original.
|
306
|
+
#
|
307
|
+
# == Example
|
308
|
+
#
|
309
|
+
# 1. Add my/new/tree to /1/2 -> /1/2/my/new/tree
|
310
|
+
# 2. Add /my/new/tree to /1/2 -> ArgumentError - can not add root as subtree
|
311
|
+
# 3. Trying to add 'new/tree' to '/my' node in a tree with '/my/new/tree' raises
|
312
|
+
# ArgumentError since the pathname that would result already exists within the
|
313
|
+
# target tree.
|
314
|
+
def append_tree(root_node)
|
315
|
+
raise ArgumentError, "Trying to append a root node as subtree!" if root_node.pathname.root?
|
316
|
+
|
317
|
+
# make a copy to make sure it is a self-sustaining PathTree
|
318
|
+
c = root_node.dup
|
319
|
+
|
320
|
+
# get all leaf paths prepended with this node's name to check for
|
321
|
+
# previous existance in this tree.
|
322
|
+
p = c.leave_pathnames.collect { |p| Pathname.new(@name) / p }
|
323
|
+
|
324
|
+
# duplicate ourselves to compare paths
|
325
|
+
t = dup
|
326
|
+
|
327
|
+
# check that no path in c would collide with existing paths
|
328
|
+
common = Set.new(t.leave_pathnames) & Set.new(p)
|
329
|
+
unless common.empty?
|
330
|
+
str = common.collect { |p| p.to_s }.join(",")
|
331
|
+
raise ArgumentError, "Can not append tree due to conflicting paths: #{str}"
|
332
|
+
end
|
333
|
+
|
334
|
+
# hook the subtree into this tree
|
335
|
+
@children << c
|
336
|
+
c.parent = self
|
337
|
+
end
|
338
|
+
|
339
|
+
# Splits the node's path into
|
340
|
+
# - a 'stem', the common path to all nodes in this tree that are on the
|
341
|
+
# same level as this node or closer to the root.
|
342
|
+
# - a 'crown', the remaining path when the stem has been removed from this
|
343
|
+
# node's pathname
|
344
|
+
#
|
345
|
+
# === Example
|
346
|
+
# n.split_stem for the following tree:
|
347
|
+
#
|
348
|
+
# base
|
349
|
+
# |- dir
|
350
|
+
# |- leaf_1
|
351
|
+
# |- branch
|
352
|
+
# |- leaf_2
|
353
|
+
#
|
354
|
+
# yields
|
355
|
+
# ["base/dir", "leaf_1"] when n == leaf_1
|
356
|
+
# ["base/dir", "branch/leaf_2"] when n == leaf_2
|
357
|
+
# ["base", "dir"] when n == "dir"
|
358
|
+
# [nil, "base"] when n == "base"
|
359
|
+
#
|
360
|
+
# return:: [stem, crown]
|
361
|
+
def split_stem
|
362
|
+
r = root
|
363
|
+
s = pathname.descend do |stem|
|
364
|
+
n = r.node(stem, from_root: true)
|
365
|
+
break n if n.children.count != 1 || n == self
|
366
|
+
end
|
367
|
+
|
368
|
+
if s == self
|
369
|
+
[root? ? nil : s.parent.pathname, @name]
|
370
|
+
else
|
371
|
+
[s.pathname, pathname.relative_path_from(s.pathname)]
|
372
|
+
end
|
373
|
+
end
|
374
|
+
|
375
|
+
# return:: a Pathname containing the relative path to this node as seen from the
|
376
|
+
# given node
|
377
|
+
def relative_path_from(node)
|
378
|
+
pathname.relative_path_from(node.pathname)
|
379
|
+
end
|
380
|
+
|
381
|
+
# Builds a PathTree with its root as the given file system dir or file
|
382
|
+
#
|
383
|
+
# fs_point:: an absolute or relative path to a file or directory that
|
384
|
+
# already exists in the file system.
|
385
|
+
# prune:: if false, add the entire, absolute, path to the fs_point to
|
386
|
+
# the PathTree. If true, use only the basename of the fs_point as the
|
387
|
+
# root of the PathTree
|
388
|
+
#
|
389
|
+
# You can submit a filter predicate that determine if a specific path
|
390
|
+
# shall be part of the PathTree or not ->(Pathname) { return true/false}
|
391
|
+
#
|
392
|
+
# return:: the node corresponding to the given fs_point in the resulting
|
393
|
+
# pathtree or nil if no nodes matched the given predicate filter
|
394
|
+
#
|
395
|
+
# === Example
|
396
|
+
#
|
397
|
+
# Build a pathtree containing all files under the "mydir" directory that
|
398
|
+
# ends with '.jpg'. The resulting tree will contain the absolute path
|
399
|
+
# to 'mydir' as nodes (eg '/home/gunnar/mydir')
|
400
|
+
#
|
401
|
+
# t = PathTree.build_from_fs("./mydir",true ) { |p| p.extname == ".jpg" }
|
402
|
+
def self.build_from_fs(fs_point, prune: false)
|
403
|
+
top_node = Pathname.new(fs_point).cleanpath
|
404
|
+
raise ArgumentError, "The path '#{fs_point}' does not exist in the file system!" unless top_node.exist?
|
405
|
+
|
406
|
+
t = nil
|
407
|
+
top_node.find do |path|
|
408
|
+
p = Pathname.new(path)
|
409
|
+
|
410
|
+
if (block_given? && yield(p)) || !block_given?
|
411
|
+
t.nil? ? t = PathTree.new(p) : t.add_path(p)
|
412
|
+
end
|
413
|
+
end
|
414
|
+
return nil if t.nil?
|
415
|
+
|
416
|
+
# always return the entry node but prune the parents if
|
417
|
+
# users wishes
|
418
|
+
entry_node = t.node(top_node, from_root: true)
|
419
|
+
(prune ? entry_node.dup : entry_node)
|
420
|
+
end
|
421
|
+
|
422
|
+
# delegate method calls not implemented by PathTree to the associated 'data'
|
423
|
+
# object
|
424
|
+
def method_missing(m, ...)
|
425
|
+
return super if data.nil?
|
426
|
+
|
427
|
+
data.send(m, ...)
|
428
|
+
end
|
429
|
+
|
430
|
+
def respond_to_missing?(method_name, include_private = false)
|
431
|
+
return super if data.nil?
|
432
|
+
|
433
|
+
data.respond_to?(method_name)
|
434
|
+
end
|
435
|
+
|
436
|
+
def to_s
|
437
|
+
traverse_preorder do |level, n|
|
438
|
+
str = " " * 4 * level + "|-- " + n.segment.to_s
|
439
|
+
str += " <#{n.data}>" unless n.data.nil?
|
440
|
+
str
|
441
|
+
end.join("\n")
|
442
|
+
end
|
443
|
+
|
444
|
+
#
|
445
|
+
# Return a list of nodes whose path match the given substring.
|
446
|
+
#
|
447
|
+
# @param [String] sub_str
|
448
|
+
# a string to search for in the pathnames of the nodes
|
449
|
+
# @param [boolean] (false) from_root
|
450
|
+
# True - start search from the root.
|
451
|
+
# False - start search from the current node.
|
452
|
+
#
|
453
|
+
# @return [<Type>] <description>
|
454
|
+
#
|
455
|
+
def find(sub_str, from_root: false)
|
456
|
+
result = []
|
457
|
+
|
458
|
+
traverse_preorder do |level, node|
|
459
|
+
q = from_root ? node.pathname : Pathname(segment) +
|
460
|
+
node.pathname.relative_path_from(pathname)
|
461
|
+
result << node if q.to_s.include?(sub_str)
|
462
|
+
end
|
463
|
+
result
|
464
|
+
end
|
465
|
+
|
466
|
+
# Return a new PathTree with the nodes whith pathname matching the
|
467
|
+
# given regex.
|
468
|
+
#
|
469
|
+
# The copy will point to the same node data as the original.
|
470
|
+
#
|
471
|
+
# regex:: a Regex matching the pathname of the nodes to be included in
|
472
|
+
# the copy
|
473
|
+
# prune:: remove all parents to this node in the returned copy
|
474
|
+
#
|
475
|
+
# === Returns
|
476
|
+
# the entry node in a new PathTree with the nodes with pathnames matching the given regex
|
477
|
+
# or nil if no nodes match
|
478
|
+
def match(regex, prune: false)
|
479
|
+
copy = nil
|
480
|
+
|
481
|
+
traverse_preorder do |level, n|
|
482
|
+
p = n.pathname
|
483
|
+
next unless regex&.match?(p.to_s)
|
484
|
+
|
485
|
+
copy.nil? ? copy = PathTree.new(p, n.data) : copy.add_path(p, n.data)
|
486
|
+
end
|
487
|
+
return nil if copy.nil?
|
488
|
+
|
489
|
+
# always return the entry node but return a pruned version if
|
490
|
+
# the user wishes
|
491
|
+
entry_node = copy.node(pathname, from_root: true)
|
492
|
+
(prune ? entry_node.dup : entry_node)
|
493
|
+
end
|
494
|
+
|
495
|
+
# Return a new PathTree with the nodes matching the given block
|
496
|
+
#
|
497
|
+
# The copy will point to the same node data as the original.
|
498
|
+
#
|
499
|
+
# prune:: prune all parents to this node from the returned copy
|
500
|
+
#
|
501
|
+
# === Block
|
502
|
+
#
|
503
|
+
# The given block will receive the level (from the entry node) and
|
504
|
+
# the node itself for each node.
|
505
|
+
#
|
506
|
+
# === Returns
|
507
|
+
# the entry node to the new Pathtree or nil if no nodes matched the
|
508
|
+
# given block.
|
509
|
+
#
|
510
|
+
# === Example
|
511
|
+
#
|
512
|
+
# copy = original.filter { |l, n| n.data == "smurf" }
|
513
|
+
#
|
514
|
+
# The above will return a tree with nodes whose data is equal to 'smurf'
|
515
|
+
def filter(prune: false)
|
516
|
+
raise InvalidArgument, "No block given!" unless block_given?
|
517
|
+
|
518
|
+
# build the filtered copy
|
519
|
+
copy = nil
|
520
|
+
traverse_preorder do |level, n|
|
521
|
+
if yield(level, n)
|
522
|
+
p = n.pathname
|
523
|
+
copy.nil? ? copy = PathTree.new(p, n.data) : copy.add_path(p, n.data)
|
524
|
+
end
|
525
|
+
end
|
526
|
+
|
527
|
+
return nil if copy.nil?
|
528
|
+
|
529
|
+
# always return the entry node but return a pruned version if
|
530
|
+
# the user wishes
|
531
|
+
entry_node = copy.node(pathname, from_root: true)
|
532
|
+
(prune ? entry_node.dup : entry_node)
|
533
|
+
end
|
534
|
+
|
535
|
+
private
|
536
|
+
|
537
|
+
def clean(path)
|
538
|
+
Pathname.new(path).cleanpath
|
539
|
+
end
|
540
|
+
|
541
|
+
def leaf_first(left, right)
|
542
|
+
if left.leaf? != right.leaf?
|
543
|
+
# always return leaf before non-leaf
|
544
|
+
return left.leaf? ? -1 : 1
|
545
|
+
end
|
546
|
+
|
547
|
+
# for two non-leafs, return lexical order
|
548
|
+
left.segment <=> right.segment
|
549
|
+
end
|
550
|
+
|
551
|
+
def get_child(segment_name)
|
552
|
+
ch = @children.select { |c| c.segment == segment_name.to_s }
|
553
|
+
ch.length.zero? ? nil : ch[0]
|
554
|
+
end
|
555
|
+
end
|
556
|
+
end
|
@@ -0,0 +1,215 @@
|
|
1
|
+
require "pathname"
|
2
|
+
require "fileutils"
|
3
|
+
require_relative "loggable"
|
4
|
+
require_relative "pathtree"
|
5
|
+
|
6
|
+
module Gran
|
7
|
+
#
|
8
|
+
# A framework for transforming source file trees into destination trees
|
9
|
+
# through three distinct phases:
|
10
|
+
#
|
11
|
+
# 1. SCAN - Examine source tree, collect metadata (read-only)
|
12
|
+
# 2. TRANSFORM - Convert source nodes to destination nodes (core work)
|
13
|
+
# 3. FINALIZE - Generate derived outputs, aggregations (post-processing)
|
14
|
+
#
|
15
|
+
# == Usage
|
16
|
+
#
|
17
|
+
# transformer = Gran::TreeTransformer.new(
|
18
|
+
# src: "/path/to/source",
|
19
|
+
# dst: "/path/to/destination",
|
20
|
+
# transformer: MyTransformer.new,
|
21
|
+
# traversal: :preorder # optional, default :preorder
|
22
|
+
# )
|
23
|
+
#
|
24
|
+
# # Register scanners and finalizers
|
25
|
+
# transformer.add_scanner(MyScanner.new)
|
26
|
+
# transformer.add_finalizer(MyFinalizer.new)
|
27
|
+
#
|
28
|
+
# # Run all phases
|
29
|
+
# transformer.run(abort_on_exc: true)
|
30
|
+
#
|
31
|
+
# == Transformer Interface
|
32
|
+
#
|
33
|
+
# Transformers must implement:
|
34
|
+
#
|
35
|
+
# def transform(src_node, dst_tree, context)
|
36
|
+
# # Required: perform transformation, mutate dst_tree
|
37
|
+
# end
|
38
|
+
#
|
39
|
+
# def should_transform?(src_node, context)
|
40
|
+
# # Optional: return true/false to filter nodes
|
41
|
+
# # Default: true (process all nodes)
|
42
|
+
# end
|
43
|
+
#
|
44
|
+
# == Scanner Interface
|
45
|
+
#
|
46
|
+
# Scanners must implement:
|
47
|
+
#
|
48
|
+
# def scan(src_tree, context)
|
49
|
+
# # Examine source tree, collect metadata
|
50
|
+
# # Mutate context to share data with transform/finalize phases
|
51
|
+
# end
|
52
|
+
#
|
53
|
+
# == Finalizer Interface
|
54
|
+
#
|
55
|
+
# Finalizers must implement:
|
56
|
+
#
|
57
|
+
# def finalize(src_tree, dst_tree, transformer, context)
|
58
|
+
# # Generate derived outputs, copy assets, etc.
|
59
|
+
# end
|
60
|
+
#
|
61
|
+
class TreeTransformer
|
62
|
+
include Loggable
|
63
|
+
|
64
|
+
attr_reader :src_tree, :dst_tree, :context, :transformer
|
65
|
+
|
66
|
+
# Create a new TreeTransformer
|
67
|
+
#
|
68
|
+
# @param [Pathname, String, PathTree] src
|
69
|
+
# Source tree - can be a filesystem path or an existing PathTree
|
70
|
+
# @param [Pathname, String] dst
|
71
|
+
# Destination path where transformed output will be created
|
72
|
+
# @param [Object] transformer
|
73
|
+
# Object implementing the transformer interface
|
74
|
+
# @param [Symbol] traversal
|
75
|
+
# Tree traversal order: :preorder (default), :postorder, or :levelorder
|
76
|
+
# @param [Hash] context
|
77
|
+
# Initial context hash for sharing data between phases
|
78
|
+
#
|
79
|
+
def initialize(src:, dst:, transformer:, traversal: :preorder, context: {})
|
80
|
+
@transformer = transformer
|
81
|
+
@traversal = traversal
|
82
|
+
@context = context
|
83
|
+
|
84
|
+
# Build or accept source tree
|
85
|
+
@src_tree = if src.is_a?(PathTree)
|
86
|
+
src
|
87
|
+
else
|
88
|
+
src_path = Pathname.new(src).cleanpath
|
89
|
+
raise ArgumentError, "Source path does not exist: #{src_path}" unless src_path.exist?
|
90
|
+
|
91
|
+
PathTree.build_from_fs(src_path, prune: false)
|
92
|
+
end
|
93
|
+
|
94
|
+
# Create destination tree
|
95
|
+
dst_path = Pathname.new(dst).cleanpath
|
96
|
+
@dst_tree = PathTree.new(dst_path)
|
97
|
+
|
98
|
+
# Store root references in context for convenience
|
99
|
+
@context[:src_root] = @src_tree
|
100
|
+
@context[:dst_root] = @dst_tree
|
101
|
+
|
102
|
+
# Phase handlers
|
103
|
+
@scanners = []
|
104
|
+
@finalizers = []
|
105
|
+
|
106
|
+
# Validate traversal method
|
107
|
+
unless @src_tree.respond_to?(:"traverse_#{@traversal}")
|
108
|
+
raise ArgumentError, "Invalid traversal order: #{@traversal}"
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
# Add a scanner to be run during the scan phase
|
113
|
+
#
|
114
|
+
# @param [Object] scanner
|
115
|
+
# Object implementing the scanner interface (must respond to #scan)
|
116
|
+
#
|
117
|
+
def add_scanner(scanner)
|
118
|
+
raise ArgumentError, "Scanner must respond to #scan" unless scanner.respond_to?(:scan)
|
119
|
+
|
120
|
+
@scanners << scanner
|
121
|
+
end
|
122
|
+
|
123
|
+
# Add a finalizer to be run during the finalize phase
|
124
|
+
#
|
125
|
+
# @param [Object] finalizer
|
126
|
+
# Object implementing the finalizer interface (must respond to #finalize)
|
127
|
+
#
|
128
|
+
def add_finalizer(finalizer)
|
129
|
+
raise ArgumentError, "Finalizer must respond to #finalize" unless finalizer.respond_to?(:finalize)
|
130
|
+
|
131
|
+
@finalizers << finalizer
|
132
|
+
end
|
133
|
+
|
134
|
+
# Run all three transformation phases
|
135
|
+
#
|
136
|
+
# @param [Boolean] abort_on_exc
|
137
|
+
# If true, abort on first exception. If false, log errors and continue.
|
138
|
+
#
|
139
|
+
def run(abort_on_exc: true)
|
140
|
+
logger.info { "Starting tree transformation: #{@src_tree.pathname} -> #{@dst_tree.pathname}" }
|
141
|
+
|
142
|
+
run_scan_phase
|
143
|
+
run_transform_phase(abort_on_exc: abort_on_exc)
|
144
|
+
run_finalize_phase(abort_on_exc: abort_on_exc)
|
145
|
+
|
146
|
+
logger.info { "Tree transformation complete" }
|
147
|
+
end
|
148
|
+
|
149
|
+
private
|
150
|
+
|
151
|
+
# Phase 1: Scan
|
152
|
+
# Examine source tree and collect metadata (read-only)
|
153
|
+
def run_scan_phase
|
154
|
+
return if @scanners.empty?
|
155
|
+
|
156
|
+
logger.debug { "Running scan phase with #{@scanners.length} scanner(s)" }
|
157
|
+
|
158
|
+
@scanners.each do |scanner|
|
159
|
+
logger.debug { "Running scanner: #{scanner.class.name}" }
|
160
|
+
scanner.scan(@src_tree, @context)
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
# Phase 2: Transform
|
165
|
+
# Convert source nodes to destination nodes
|
166
|
+
def run_transform_phase(abort_on_exc:)
|
167
|
+
logger.debug { "Running transform phase (#{@traversal} traversal)" }
|
168
|
+
|
169
|
+
processed = 0
|
170
|
+
errors = 0
|
171
|
+
|
172
|
+
@src_tree.send(:"traverse_#{@traversal}") do |level, src_node|
|
173
|
+
# Check if this node should be transformed
|
174
|
+
should_process = if @transformer.respond_to?(:should_transform?)
|
175
|
+
@transformer.should_transform?(src_node, @context)
|
176
|
+
else
|
177
|
+
true
|
178
|
+
end
|
179
|
+
|
180
|
+
next unless should_process
|
181
|
+
|
182
|
+
# Perform transformation
|
183
|
+
@transformer.transform(src_node, @dst_tree, @context)
|
184
|
+
processed += 1
|
185
|
+
rescue => exc
|
186
|
+
errors += 1
|
187
|
+
logger.error { "Transform failed for #{src_node.pathname}: #{exc.message}" }
|
188
|
+
logger.debug { exc.backtrace.join("\n") }
|
189
|
+
|
190
|
+
raise exc if abort_on_exc
|
191
|
+
end
|
192
|
+
|
193
|
+
logger.info { "Transformed #{processed} node(s)" }
|
194
|
+
logger.warn { "Encountered #{errors} error(s)" } if errors > 0
|
195
|
+
end
|
196
|
+
|
197
|
+
# Phase 3: Finalize
|
198
|
+
# Generate derived outputs and finalize the destination tree
|
199
|
+
def run_finalize_phase(abort_on_exc:)
|
200
|
+
return if @finalizers.empty?
|
201
|
+
|
202
|
+
logger.debug { "Running finalize phase with #{@finalizers.length} finalizer(s)" }
|
203
|
+
|
204
|
+
@finalizers.each do |finalizer|
|
205
|
+
logger.debug { "Running finalizer: #{finalizer.class.name}" }
|
206
|
+
finalizer.finalize(@src_tree, @dst_tree, @transformer, @context)
|
207
|
+
rescue => exc
|
208
|
+
logger.error { "Finalizer failed: #{exc.message}" }
|
209
|
+
logger.debug { exc.backtrace.join("\n") }
|
210
|
+
|
211
|
+
raise exc if abort_on_exc
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
data/lib/gran/version.rb
ADDED
data/lib/gran.rb
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "gran/version"
|
4
|
+
require_relative "gran/loggable"
|
5
|
+
require_relative "gran/pathtree"
|
6
|
+
require_relative "gran/tree_transformer"
|
7
|
+
|
8
|
+
module Gran
|
9
|
+
class Error < StandardError; end
|
10
|
+
|
11
|
+
#
|
12
|
+
# Setup the logger object for the module.
|
13
|
+
#
|
14
|
+
# Users of the module can assign a logger object to the module.
|
15
|
+
# If no logger object is assigned, a default logger object
|
16
|
+
# based on the Ruby Logger class is created.
|
17
|
+
#
|
18
|
+
class << self
|
19
|
+
# @return [logger] set the logger object for the module
|
20
|
+
attr_writer :logger
|
21
|
+
|
22
|
+
#
|
23
|
+
# Used to access the logger for the module.
|
24
|
+
#
|
25
|
+
# @return [logger] the logger object for the module
|
26
|
+
#
|
27
|
+
def logger
|
28
|
+
@logger ||= default_logger
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
#
|
34
|
+
# Implement a default logger for the module.
|
35
|
+
#
|
36
|
+
# @return [logger] the default logger object for the module
|
37
|
+
#
|
38
|
+
def default_logger
|
39
|
+
require "logger"
|
40
|
+
Logger.new($stdout).tap do |log|
|
41
|
+
log.progname = name
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
metadata
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: gran
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Anders Rillbert
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2025-10-17 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description: Provides classes for creatting and transforming trees of PathName nodes
|
14
|
+
email:
|
15
|
+
- anders.rillbert@kutso.se
|
16
|
+
executables: []
|
17
|
+
extensions: []
|
18
|
+
extra_rdoc_files: []
|
19
|
+
files:
|
20
|
+
- CHANGELOG.md
|
21
|
+
- CODE_OF_CONDUCT.md
|
22
|
+
- Gemfile
|
23
|
+
- LICENSE.txt
|
24
|
+
- README.md
|
25
|
+
- Rakefile
|
26
|
+
- lib/gran.rb
|
27
|
+
- lib/gran/loggable.rb
|
28
|
+
- lib/gran/pathtree.rb
|
29
|
+
- lib/gran/tree_transformer.rb
|
30
|
+
- lib/gran/version.rb
|
31
|
+
homepage: https://github.com/rillbert/giblish/tree/main/gran
|
32
|
+
licenses:
|
33
|
+
- MIT
|
34
|
+
metadata:
|
35
|
+
homepage_uri: https://github.com/rillbert/giblish/tree/main/gran
|
36
|
+
bug_tracker_uri: https://github.com/rillbert/giblish/issues
|
37
|
+
source_code_uri: https://github.com/rillbert/giblish
|
38
|
+
allowed_push_host: https://rubygems.org
|
39
|
+
post_install_message:
|
40
|
+
rdoc_options: []
|
41
|
+
require_paths:
|
42
|
+
- lib
|
43
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 3.3.0
|
48
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
49
|
+
requirements:
|
50
|
+
- - ">="
|
51
|
+
- !ruby/object:Gem::Version
|
52
|
+
version: '0'
|
53
|
+
requirements: []
|
54
|
+
rubygems_version: 3.5.22
|
55
|
+
signing_key:
|
56
|
+
specification_version: 4
|
57
|
+
summary: Provides utility classes for working with trees of file paths
|
58
|
+
test_files: []
|