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 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
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2024-09-29
4
+
5
+ - Initial release
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gran
4
+ VERSION = "0.1.0"
5
+ end
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: []