Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions packages/relic_core/lib/src/router/path_trie.dart
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,134 @@ final class PathTrie<T extends Object> {

/// 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<String> get paths sync* {
yield* _enumeratePaths(_root, '', <_TrieNode<T>>{});
}

Iterable<String> _enumeratePaths(
final _TrieNode<T> node,
final String currentPath,
final Set<_TrieNode<T>> 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<T>(:final name) => ':$name',
_Wildcard<T>() => '*',
_Tail<T>() => '**',
};
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<int>();
/// 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<T>, String>{};
var nodeCounter = 0;

String getNodeId(_TrieNode<T> node) {
return visited.putIfAbsent(node, () => 'n${nodeCounter++}');
}

void traverse(_TrieNode<T> 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<T>(:final name) => ':$name',
_Wildcard<T>() => '*',
_Tail<T>() => '**',
};
traverse(dynamicSegment.node, nodeId, segmentStr);
}
}

traverse(_root, null, null);

buffer.writeln('}');
return buffer.toString();
}
}

extension on NormalizedPath {
Expand Down
213 changes: 213 additions & 0 deletions packages/relic_core/test/router/path_trie_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<int> trieA;
late PathTrie<int> trieB;

setUp(() {
trieA = PathTrie<int>();
trieB = PathTrie<int>();
});

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<int>();
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'));
});
});
});
});
}