diff --git a/packages/relic_core/lib/src/router/path_trie.dart b/packages/relic_core/lib/src/router/path_trie.dart index 5b7d6fb7..693f4f67 100644 --- a/packages/relic_core/lib/src/router/path_trie.dart +++ b/packages/relic_core/lib/src/router/path_trie.dart @@ -554,6 +554,134 @@ final class PathTrie { /// Returns true if the path trie has a single root route ('/'). bool get isSingle => _root.isSingle; + + /// Returns an iterable of all registered path patterns. + /// + /// Paths are returned in a deterministic order: literal segments are + /// sorted alphabetically, with dynamic segments following literals. + /// Only paths with associated values are included. + /// + /// Handles cycles created by self-attachment by tracking visited nodes. + Iterable get paths sync* { + yield* _enumeratePaths(_root, '', <_TrieNode>{}); + } + + Iterable _enumeratePaths( + final _TrieNode node, + final String currentPath, + final Set<_TrieNode> visited, + ) sync* { + if (!visited.add(node)) return; // Cycle detection + + if (node.value != null) { + yield currentPath.isEmpty ? '/' : currentPath; + } + + // Traverse literal children (sorted for deterministic output) + final sortedKeys = node.children.keys.toList()..sort(); + for (final segment in sortedKeys) { + yield* _enumeratePaths( + node.children[segment]!, + '$currentPath/$segment', + visited, + ); + } + + // Traverse dynamic segment + final dynamicSegment = node.dynamicSegment; + if (dynamicSegment != null) { + final segmentStr = switch (dynamicSegment) { + _Parameter(:final name) => ':$name', + _Wildcard() => '*', + _Tail() => '**', + }; + yield* _enumeratePaths( + dynamicSegment.node, + '$currentPath/$segmentStr', + visited, + ); + } + } + + @override + String toString() { + final pathList = paths.toList(); + if (pathList.isEmpty) return 'PathTrie (empty)'; + return 'PathTrie:\n${pathList.map((final p) => ' $p').join('\n')}'; + } + + /// Returns a DOT graph representation of the trie structure. + /// + /// The output can be visualized using Graphviz or similar tools. + /// Nodes with values are shown with a double border (shape=doublecircle). + /// Cycles created by self-attachment are shown as edges to already-visited + /// nodes, making the recursive structure visible. + /// + /// Example usage: + /// ```dart + /// final trie = PathTrie(); + /// trie.add(NormalizedPath('/users/:id'), 1); + /// print(trie.toDot()); + /// // Paste output into https://dreampuf.github.io/GraphvizOnline/ + /// ``` + String toDot() { + final buffer = StringBuffer(); + buffer.writeln('digraph PathTrie {'); + buffer.writeln(' rankdir=TB;'); + buffer.writeln(' node [shape=circle];'); + buffer.writeln(); + + final visited = <_TrieNode, String>{}; + var nodeCounter = 0; + + String getNodeId(_TrieNode node) { + return visited.putIfAbsent(node, () => 'n${nodeCounter++}'); + } + + void traverse(_TrieNode node, String? parentId, String? edgeLabel) { + final nodeId = getNodeId(node); + final isNewNode = + !visited.containsKey(node) || visited[node] == 'n${nodeCounter - 1}'; + + if (isNewNode) { + // Define node with appropriate shape + final shape = node.value != null ? 'doublecircle' : 'circle'; + final label = node.value != null ? '${node.value}' : ''; + buffer.writeln(' $nodeId [shape=$shape, label="$label"];'); + } + + // Add edge from parent + if (parentId != null && edgeLabel != null) { + final style = !isNewNode ? ', style=dashed, color=red' : ''; + buffer.writeln(' $parentId -> $nodeId [label="$edgeLabel"$style];'); + } + + // Don't recurse into already-visited nodes (cycle detected) + if (!isNewNode && parentId != null) return; + + // Traverse literal children (sorted for deterministic output) + final sortedKeys = node.children.keys.toList()..sort(); + for (final segment in sortedKeys) { + traverse(node.children[segment]!, nodeId, segment); + } + + // Traverse dynamic segment + final dynamicSegment = node.dynamicSegment; + if (dynamicSegment != null) { + final segmentStr = switch (dynamicSegment) { + _Parameter(:final name) => ':$name', + _Wildcard() => '*', + _Tail() => '**', + }; + traverse(dynamicSegment.node, nodeId, segmentStr); + } + } + + traverse(_root, null, null); + + buffer.writeln('}'); + return buffer.toString(); + } } extension on NormalizedPath { diff --git a/packages/relic_core/test/router/path_trie_test.dart b/packages/relic_core/test/router/path_trie_test.dart index 44e74b64..9b8a04ca 100644 --- a/packages/relic_core/test/router/path_trie_test.dart +++ b/packages/relic_core/test/router/path_trie_test.dart @@ -722,5 +722,218 @@ void main() { ); }); }); + + group('Path Enumeration', () { + test('Given an empty trie, ' + 'when paths is accessed, ' + 'then returns an empty iterable', () { + expect(trie.paths, isEmpty); + }); + + test('Given an empty trie, ' + 'when toString is called, ' + 'then returns "PathTrie (empty)"', () { + expect(trie.toString(), equals('PathTrie (empty)')); + }); + + test('Given a trie with only a root path, ' + 'when paths is accessed, ' + 'then returns a list containing only "/"', () { + trie.add(NormalizedPath('/'), 1); + expect(trie.paths.toList(), equals(['/'])); + }); + + test('Given a trie with multiple literal paths, ' + 'when paths is accessed, ' + 'then returns paths sorted alphabetically', () { + trie.add(NormalizedPath('/zebra'), 1); + trie.add(NormalizedPath('/apple'), 2); + trie.add(NormalizedPath('/mango'), 3); + + expect(trie.paths.toList(), equals(['/apple', '/mango', '/zebra'])); + }); + + test('Given a trie with parameter segments, ' + 'when paths is accessed, ' + 'then returns paths with :paramName format', () { + trie.add(NormalizedPath('/users/:id'), 1); + trie.add(NormalizedPath('/users/:id/posts/:postId'), 2); + + expect( + trie.paths.toList(), + equals(['/users/:id', '/users/:id/posts/:postId']), + ); + }); + + test('Given a trie with wildcard segments, ' + 'when paths is accessed, ' + 'then returns paths with * format', () { + trie.add(NormalizedPath('/files/*'), 1); + trie.add(NormalizedPath('/files/*/info'), 2); + + expect(trie.paths.toList(), equals(['/files/*', '/files/*/info'])); + }); + + test('Given a trie with tail segments, ' + 'when paths is accessed, ' + 'then returns paths with ** format', () { + trie.add(NormalizedPath('/static/**'), 1); + trie.add(NormalizedPath('/assets/**'), 2); + + expect(trie.paths.toList(), equals(['/assets/**', '/static/**'])); + }); + + test('Given a trie with mixed segment types, ' + 'when paths is accessed, ' + 'then returns paths with literals before dynamic segments', () { + trie.add(NormalizedPath('/api/users/:id'), 1); + trie.add(NormalizedPath('/api/posts'), 2); + trie.add(NormalizedPath('/static/**'), 3); + + expect( + trie.paths.toList(), + equals(['/api/posts', '/api/users/:id', '/static/**']), + ); + }); + + test('Given a trie with intermediate nodes without values, ' + 'when paths is accessed, ' + 'then only returns paths with values', () { + // Only /users/:id/profile has a value, not /users or /users/:id + trie.add(NormalizedPath('/users/:id/profile'), 1); + + expect(trie.paths.toList(), equals(['/users/:id/profile'])); + }); + + test('Given a trie with deep nesting, ' + 'when paths is accessed, ' + 'then returns all nested paths correctly', () { + trie.add(NormalizedPath('/a/b/c/d'), 1); + trie.add(NormalizedPath('/a/b/c'), 2); + trie.add(NormalizedPath('/a/b'), 3); + trie.add(NormalizedPath('/a'), 4); + + expect( + trie.paths.toList(), + equals(['/a', '/a/b', '/a/b/c', '/a/b/c/d']), + ); + }); + + test('Given a trie with toString called, ' + 'when trie has multiple paths, ' + 'then returns formatted string with all paths', () { + trie.add(NormalizedPath('/users'), 1); + trie.add(NormalizedPath('/posts'), 2); + + expect(trie.toString(), equals('PathTrie:\n /posts\n /users')); + }); + + group('Cycle Detection', () { + late PathTrie trieA; + late PathTrie trieB; + + setUp(() { + trieA = PathTrie(); + trieB = PathTrie(); + }); + + test('Given a trie attached to itself, ' + 'when paths is accessed, ' + 'then completes without infinite loop', () { + trieA.add(NormalizedPath('/a'), 1); + trieA.attach(NormalizedPath('/a'), trieA); + + // After self-attachment, trieA._root points to the /a node + // So the root now has the value, and path enumeration sees it as '/' + // This would hang if cycle detection wasn't working + final paths = trieA.paths.toList(); + expect(paths, contains('/')); + }); + + test('Given two tries attached to each other creating a cycle, ' + 'when paths is accessed on either, ' + 'then completes without infinite loop', () { + trieA.add(NormalizedPath('/pathA'), 1); + trieB.add(NormalizedPath('/pathB'), 2); + + // Create mutual attachment + trieA.attach(NormalizedPath('/pathA'), trieB); + + // Now trieB's root points to /pathA in trieA + // Adding to trieB adds to trieA as well + // This creates a potential cycle when iterating + + final pathsA = trieA.paths.toList(); + expect(pathsA, contains('/pathA')); + expect(pathsA, contains('/pathA/pathB')); + }); + }); + + group('DOT Graph Output', () { + test('Given an empty trie, ' + 'when toDot is called, ' + 'then returns valid DOT with single root node', () { + final dot = trie.toDot(); + expect(dot, contains('digraph PathTrie {')); + expect(dot, contains('n0 [shape=circle')); + expect(dot, endsWith('}\n')); + }); + + test('Given a trie with a root value, ' + 'when toDot is called, ' + 'then root node has doublecircle shape', () { + trie.add(NormalizedPath('/'), 42); + final dot = trie.toDot(); + expect(dot, contains('n0 [shape=doublecircle, label="42"]')); + }); + + test('Given a trie with literal paths, ' + 'when toDot is called, ' + 'then shows edges with segment labels', () { + trie.add(NormalizedPath('/users'), 1); + trie.add(NormalizedPath('/posts'), 2); + final dot = trie.toDot(); + expect(dot, contains('-> n1 [label="posts"]')); + expect(dot, contains('-> n2 [label="users"]')); + }); + + test('Given a trie with parameter segments, ' + 'when toDot is called, ' + 'then shows :paramName in edge labels', () { + trie.add(NormalizedPath('/users/:id'), 1); + final dot = trie.toDot(); + expect(dot, contains('[label=":id"]')); + }); + + test('Given a trie with wildcard segments, ' + 'when toDot is called, ' + 'then shows * in edge labels', () { + trie.add(NormalizedPath('/files/*'), 1); + final dot = trie.toDot(); + expect(dot, contains('[label="*"]')); + }); + + test('Given a trie with tail segments, ' + 'when toDot is called, ' + 'then shows ** in edge labels', () { + trie.add(NormalizedPath('/static/**'), 1); + final dot = trie.toDot(); + expect(dot, contains('[label="**"]')); + }); + + test('Given a trie with a cycle from self-attachment, ' + 'when toDot is called, ' + 'then shows cycle as dashed red edge', () { + final trieA = PathTrie(); + trieA.add(NormalizedPath('/a'), 1); + trieA.add(NormalizedPath('/a/b'), 2); + trieA.attach(NormalizedPath('/a/b'), trieA); + + final dot = trieA.toDot(); + // The cycle edge should be dashed and red + expect(dot, contains('style=dashed, color=red')); + }); + }); + }); }); }