File tree Expand file tree Collapse file tree 3 files changed +74
-0
lines changed
Expand file tree Collapse file tree 3 files changed +74
-0
lines changed Original file line number Diff line number Diff line change @@ -15,6 +15,9 @@ smooth transition to the new APIs.
1515 now treat ` nil ` child slots as empty, preventing binary trees with missing
1616 children from reporting or yielding phantom siblings.
1717
18+ * Added [ Tree::TreeNode#validate_acyclic!] [ validate_acyclic ] and
19+ [ Tree::TreeNode#acyclic?] [ acyclic ] to detect cycles in untrusted trees.
20+
1821## Release 2.2.0 Changes
1922
2023* [ Tree::TreeNode#add] [ add ] now raises ` ArgumentError ` when attempting to add
@@ -166,6 +169,7 @@ smooth transition to the new APIs.
166169
167170[ access ] : rdoc-ref:Tree::TreeNode#[]
168171[ add ] : rdoc-ref:Tree::TreeNode#add
172+ [ acyclic ] : rdoc-ref:Tree::TreeNode#acyclic?
169173[ append ] : rdoc-ref:Tree::TreeNode#<<
170174[ breadth_each ] : rdoc-ref:Tree::TreeNode#breadth_each
171175[ btree_add ] : rdoc-ref:Tree::BinaryTreeNode#add
@@ -192,3 +196,4 @@ smooth transition to the new APIs.
192196[ set_child_at ] : rdoc-ref:Tree::BinaryTreeNode#set_child_at
193197[ siblings ] : rdoc-ref:Tree::TreeNode#siblings
194198[ to_json ] : rdoc-ref:Tree::Utils::JSONConverter#to_json
199+ [ validate_acyclic ] : rdoc-ref:Tree::TreeNode#validate_acyclic!
Original file line number Diff line number Diff line change @@ -199,6 +199,51 @@ def parentage
199199 parentage_array
200200 end
201201
202+ # @!attribute [r] acyclic?
203+ # Returns +true+ if this (sub)tree has no cycles.
204+ #
205+ # @return [Boolean] +true+ if the (sub)tree is acyclic.
206+ def acyclic?
207+ validate_acyclic!
208+ true
209+ rescue ArgumentError
210+ false
211+ end
212+
213+ # Validates that the (sub)tree rooted at this node has no cycles.
214+ #
215+ # @raise [ArgumentError] Raised when a cycle is detected.
216+ # @return [Tree::TreeNode] Returns +self+ when no cycle is found.
217+ def validate_acyclic!
218+ visited = { }
219+ visiting = { }
220+ stack = [ [ self , :enter ] ]
221+
222+ until stack . empty?
223+ node , state = stack . pop
224+ next unless node
225+
226+ if state == :exit
227+ visiting . delete ( node . object_id )
228+ next
229+ end
230+
231+ node_id = node . object_id
232+ raise ArgumentError , 'Cycle detected in tree' if visiting . key? ( node_id )
233+ next if visited . key? ( node_id )
234+
235+ visiting [ node_id ] = true
236+ visited [ node_id ] = true
237+
238+ stack << [ node , :exit ]
239+ node . children . reverse_each do |child |
240+ stack << [ child , :enter ] if child
241+ end
242+ end
243+
244+ self
245+ end
246+
202247 # @!group Node Creation
203248
204249 # Creates a new node with a name and optional content.
Original file line number Diff line number Diff line change @@ -416,5 +416,29 @@ def test_change_parent
416416 assert_equal ( '3' , root2 [ '3' ] [ '4' ] . parent . name )
417417 assert_nil ( root1 [ '2' ] [ '4' ] )
418418 end
419+
420+ def test_validate_acyclic
421+ setup_test_tree
422+
423+ assert_equal ( @root , @root . validate_acyclic! )
424+ assert ( @root . acyclic? , 'Expected the tree to be acyclic' )
425+ end
426+
427+ def test_validate_acyclic_detects_cycle
428+ node_a = Tree ::TreeNode . new ( 'A' )
429+ node_b = Tree ::TreeNode . new ( 'B' )
430+
431+ node_a . instance_variable_set ( :@children , [ node_b ] )
432+ node_a . instance_variable_set ( :@children_hash , { node_b . name => node_b } )
433+ node_b . instance_variable_set ( :@parent , node_a )
434+
435+ node_b . instance_variable_set ( :@children , [ node_a ] )
436+ node_b . instance_variable_set ( :@children_hash , { node_a . name => node_a } )
437+ node_a . instance_variable_set ( :@parent , node_b )
438+
439+ error = assert_raise ( ArgumentError ) { node_a . validate_acyclic! }
440+ assert_match ( /Cycle detected/ , error . message )
441+ assert_equal ( false , node_a . acyclic? )
442+ end
419443 end
420444end
You can’t perform that action at this time.
0 commit comments