From 9e9fabaf9d09f0d306cbc5566687edc2fa04ef8a Mon Sep 17 00:00:00 2001 From: Glenford Williams Date: Fri, 14 Feb 2025 18:48:12 -0500 Subject: [PATCH] Layout tag support (#20) - dot notation for array filters - better null checks in binary op - layout title filter support - layout tag updated to use the template analyzer --- .idea/libraries/Dart_Packages.xml | 138 ++-- CHANGELOG.md | 22 +- example/layout.dart | 137 ++++ lib/src/analyzer/block_info.dart | 218 ++++++ lib/src/analyzer/resolver.dart | 380 ++++++++++ lib/src/analyzer/static_analyzer.dart | 373 ++++++++++ lib/src/analyzer/template_analysis.dart | 57 ++ lib/src/analyzer/template_analyzer.dart | 372 ++++++++++ lib/src/analyzer/template_structure.dart | 323 +++++++++ lib/src/ast.dart | 9 + lib/src/context.dart | 34 +- lib/src/evaluator.dart | 669 +----------------- lib/src/evaluator/buffer.dart | 29 + lib/src/evaluator/evaluate.dart | 55 ++ lib/src/evaluator/evaluator.dart | 497 +++++++++++++ lib/src/evaluator/layout.dart | 4 + lib/src/evaluator/visit.dart | 218 ++++++ lib/src/evaluator/visit_async.dart | 3 + lib/src/filter_registry.dart | 19 +- lib/src/filters/array.dart | 9 +- lib/src/tag_registry.dart | 6 + lib/src/tags/block.dart | 40 ++ lib/src/tags/layout.dart | 136 ++++ lib/src/tags/super_tag.dart | 27 + lib/src/tags/tags.dart | 3 + lib/src/template.dart | 3 +- lib/src/util.dart | 126 ++++ pubspec.lock | 12 +- pubspec.yaml | 4 +- .../deeply_nested_super_call_test.dart | 88 +++ .../analyzer/layout_analyzer_test.dart | 69 ++ .../analyzer/multilevel_layout_test.dart | 80 +++ .../analyzer/analyzer/nested_blocks_test.dart | 64 ++ test/analyzer/analyzer/super_call_test.dart | 67 ++ test/analyzer/resolver/ast_matcher.dart | 67 ++ .../resolver/complete_layout_merge_test.dart | 173 +++++ .../deeply_nested_super_merge_test.dart | 89 +++ .../multi_level_inheritance_merge_test.dart | 62 ++ test/analyzer/resolver/nested_merge_test.dart | 71 ++ .../simple_inheritance_merge_test.dart | 53 ++ .../resolver/simple_layout_merge_test.dart | 54 ++ .../resolver/super_tag_merge_test.dart | 57 ++ test/evaluator_test.dart | 85 +++ test/layout_test2.dart | 130 ++++ test/shared_test_root.dart | 23 + test/tags/dot_notation.dart | 92 +++ test/tags/layout_test.dart | 256 +++++++ 47 files changed, 4751 insertions(+), 752 deletions(-) create mode 100644 example/layout.dart create mode 100644 lib/src/analyzer/block_info.dart create mode 100644 lib/src/analyzer/resolver.dart create mode 100644 lib/src/analyzer/static_analyzer.dart create mode 100644 lib/src/analyzer/template_analysis.dart create mode 100644 lib/src/analyzer/template_analyzer.dart create mode 100644 lib/src/analyzer/template_structure.dart create mode 100644 lib/src/evaluator/buffer.dart create mode 100644 lib/src/evaluator/evaluate.dart create mode 100644 lib/src/evaluator/evaluator.dart create mode 100644 lib/src/evaluator/layout.dart create mode 100644 lib/src/evaluator/visit.dart create mode 100644 lib/src/evaluator/visit_async.dart create mode 100644 lib/src/tags/block.dart create mode 100644 lib/src/tags/layout.dart create mode 100644 lib/src/tags/super_tag.dart create mode 100644 test/analyzer/analyzer/deeply_nested_super_call_test.dart create mode 100644 test/analyzer/analyzer/layout_analyzer_test.dart create mode 100644 test/analyzer/analyzer/multilevel_layout_test.dart create mode 100644 test/analyzer/analyzer/nested_blocks_test.dart create mode 100644 test/analyzer/analyzer/super_call_test.dart create mode 100644 test/analyzer/resolver/ast_matcher.dart create mode 100644 test/analyzer/resolver/complete_layout_merge_test.dart create mode 100644 test/analyzer/resolver/deeply_nested_super_merge_test.dart create mode 100644 test/analyzer/resolver/multi_level_inheritance_merge_test.dart create mode 100644 test/analyzer/resolver/nested_merge_test.dart create mode 100644 test/analyzer/resolver/simple_inheritance_merge_test.dart create mode 100644 test/analyzer/resolver/simple_layout_merge_test.dart create mode 100644 test/analyzer/resolver/super_tag_merge_test.dart create mode 100644 test/layout_test2.dart create mode 100644 test/shared_test_root.dart create mode 100644 test/tags/dot_notation.dart create mode 100644 test/tags/layout_test.dart diff --git a/.idea/libraries/Dart_Packages.xml b/.idea/libraries/Dart_Packages.xml index 2ce9dc6..c756134 100644 --- a/.idea/libraries/Dart_Packages.xml +++ b/.idea/libraries/Dart_Packages.xml @@ -5,28 +5,21 @@ - - - - - - - - - @@ -44,6 +37,13 @@ + + + + + + @@ -72,17 +72,17 @@ - + - - + - @@ -93,6 +93,13 @@ + + + + + + @@ -100,6 +107,27 @@ + + + + + + + + + + + + + + + + + + @@ -114,6 +142,13 @@ + + + + + + @@ -131,21 +166,14 @@ - - - - - - - - @@ -159,7 +187,7 @@ - @@ -187,14 +215,14 @@ - - @@ -261,13 +289,6 @@ - - - - - - @@ -299,35 +320,35 @@ - - - - + - - + - @@ -383,33 +404,38 @@ - - - + + + + - - + + + + + + + - - - + + - + - - + + @@ -419,16 +445,15 @@ - - - - + + + + - @@ -436,7 +461,6 @@ - diff --git a/CHANGELOG.md b/CHANGELOG.md index 519a979..d7e679f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,18 @@ -## 1.0.0-dev.1 +## 1.0.0-dev.2 +- **Template Enhancements:** + - Added layout tag support with title filter. + - Implemented template analyzer and resolver. + +- **Analyzer Improvements:** + - Initial support for a static analyzer. + - Extensive testing and improvements in static analysis. + - Enhanced resolver and analyzer integration. -### Async Support -- async evaluation support for all builtin tags -- Add async filesystem support for template loading -- Add async render function -### Other Changes -- Decouple parsers for better maintainability -- Improve template resolution and rendering -- Separate evaluation logic for sync and async operations +- **Filter Enhancements:** + - Enabled dot notation for array filters. +## 1.0.0-dev.1 +- layout tag support ## 0.8.2 - Make sure we register all the missing string filters diff --git a/example/layout.dart b/example/layout.dart new file mode 100644 index 0000000..5508c7b --- /dev/null +++ b/example/layout.dart @@ -0,0 +1,137 @@ +import 'package:liquify/liquify.dart'; + +void main() async { + // Create our file system with templates + final fs = MapRoot({ + // Base layout with common structure + 'layouts/base.liquid': ''' + + + + {% block title %}Default Title{% endblock %} + {% block meta %}{% endblock %} + + {% block styles %}{% endblock %} + + +
+ {% block header %} + + {% endblock %} +
+ +
+ {% block content %} + Default content + {% endblock %} +
+ +
+ {% block footer %} +

© {{ year }} My Website

+ {% endblock %} +
+ + + {% block scripts %}{% endblock %} + +''', + + // Blog post layout that extends base + 'layouts/post.liquid': ''' +{% layout "layouts/base.liquid", title: post_title, year: year %} + +{% block meta %} + + +{% endblock %} + +{% block styles %} + +{% endblock %} + +{% block content %} +
+

{{ post_title }}

+ +
+ {{ post.content }} +
+ {% if post.tags.size > 0 %} +
+ Tags: + {% for tag in post.tags %} + {{ tag }} + {% endfor %} +
+ {% endif %} +
+{% endblock %} + +{% block scripts %} + +{% endblock %}''', + + // Actual blog post using post layout + 'posts/hello-world.liquid': ''' +{% assign post_title = "Hello, World!" %} +{% layout "layouts/post.liquid", post_title: post_title, year: year %} +{%- block header -%} +

HEADER CONTENT

+{%- endblock -%} +{% block footer %} + {{ block.parent }} + +{% endblock %}''' + }); + + // Sample post data + final context = { + 'year': 2024, + 'post': { + 'title': 'Hello, World!', + 'author': 'John Doe', + 'date': '2024-02-09', + 'excerpt': 'An introduction to our blog', + 'content': ''' +Welcome to our new blog! This is our first post exploring the features +of the Liquid template engine. We'll be covering: + +- Template inheritance +- Layout blocks +- Custom filters +- And much more! + +Stay tuned for more content coming soon!''', + 'tags': ['welcome', 'introduction', 'liquid'], + } + }; + + print('\nRendering blog post with layout inheritance:'); + print('----------------------------------------\n'); + + // Render the blog post + final template = + Template.fromFile('posts/hello-world.liquid', fs, data: context); + print(await template.renderAsync()); + + // Demonstrate dynamic layout names + print('\nDemo of dynamic layout names:'); + print('----------------------------------------\n'); + + final dynamicTemplate = Template.parse( + '{% layout "layouts/{{ layout_type }}.liquid", title: "Dynamic Title" %}', + data: { + 'layout_type': 'post', + }, + root: fs); + print(await dynamicTemplate.renderAsync()); +} diff --git a/lib/src/analyzer/block_info.dart b/lib/src/analyzer/block_info.dart new file mode 100644 index 0000000..df946c4 --- /dev/null +++ b/lib/src/analyzer/block_info.dart @@ -0,0 +1,218 @@ +import 'package:liquify/parser.dart'; + +/// Represents information about a block in a Liquid template. +/// +/// A block is a section of a template that can be overridden by child templates. +/// BlockInfo tracks the block's content, inheritance relationships, and metadata +/// about how it's used in the template hierarchy. +/// +/// Example of a block in a Liquid template: +/// ```liquid +/// {% block header %} +///
Default content
+/// {% endblock %} +/// ``` +/// +/// The BlockInfo for this would contain: +/// * name: "header" +/// * content: The AST nodes for the header content +/// * isOverride: Whether this block overrides a parent template's block +/// * nestedBlocks: Any blocks defined within this block +class BlockInfo { + /// The name of the block, which can be a simple name or a dot-notation path + /// for nested blocks (e.g., "header.navigation"). + final String name; + + /// The source template path where this block is defined. + final String source; + + /// The AST nodes that make up the block's content. + /// + /// This includes everything between the opening and closing block tags. + final List? content; + + /// Whether this block overrides a block from a parent template. + /// + /// This is true in cases like: + /// * Direct override of a parent's block + /// * Override of a nested block from an ancestor + /// * Block defined in a child template that extends a parent + final bool isOverride; + + /// The parent block if this is a nested block or an override. + /// + /// For example, if this is a "navigation" block nested inside a "header" + /// block, [parent] would point to the "header" BlockInfo. + final BlockInfo? parent; + + /// Map of blocks that are defined within this block. + /// + /// The keys are the simple names of the nested blocks, and the values + /// are the BlockInfo objects for those blocks. + final Map nestedBlocks; + + /// Whether this block contains a super() call to include parent content. + /// + /// When true, this block uses the `{{ super() }}` syntax to include + /// the content from the parent template's version of this block. + final bool hasSuperCall; + + /// Creates a new BlockInfo instance. + /// + /// All parameters except [content] are required: + /// * [name] - The block's name or dot-notation path + /// * [source] - The template path where this block is defined + /// * [content] - The block's AST nodes (optional) + /// * [isOverride] - Whether this overrides a parent block + /// * [parent] - The parent block for nested blocks + /// * [nestedBlocks] - Map of blocks defined within this one + /// * [hasSuperCall] - Whether this block uses super() + const BlockInfo({ + required this.name, + required this.source, + this.content, + required this.isOverride, + this.parent, + required this.nestedBlocks, + required this.hasSuperCall, + }); + + /// Creates a copy of this BlockInfo with optionally modified properties. + /// + /// This is useful when you need to create a variation of an existing + /// BlockInfo while keeping most properties the same. + /// + /// Example: + /// ```dart + /// final overriddenBlock = originalBlock.copyWith(isOverride: true); + /// ``` + BlockInfo copyWith({ + String? name, + String? source, + List? content, + bool? isOverride, + BlockInfo? parent, + Map? nestedBlocks, + bool? hasSuperCall, + }) { + return BlockInfo( + name: name ?? this.name, + source: source ?? this.source, + content: content ?? this.content, + isOverride: isOverride ?? this.isOverride, + parent: parent ?? this.parent, + nestedBlocks: nestedBlocks ?? this.nestedBlocks, + hasSuperCall: hasSuperCall ?? this.hasSuperCall, + ); + } + + @override + String toString() { + return 'BlockInfo(name: $name, source: $source, isOverride: $isOverride, hasSuperCall: $hasSuperCall)'; + } + + /// Converts this block information to a JSON-compatible map structure. + /// + /// This is useful for: + /// * Serializing block information + /// * Debugging block structures + /// * Generating reports + /// + /// Returns a map containing: + /// * name: The block's name + /// * source: The template path where this block is defined + /// * isOverride: Whether this block overrides a parent block + /// * hasSuperCall: Whether this block uses super() + /// * hasParent: Whether this block has a parent block + /// * parentSource: The source template of the parent block (if any) + /// * nestedBlocks: Map of nested blocks within this block + Map toJson() { + return { + 'name': name, + 'source': source, + 'isOverride': isOverride, + 'hasSuperCall': hasSuperCall, + 'hasParent': parent != null, + 'parentSource': parent?.source, + 'nestedBlocks': + nestedBlocks.map((key, value) => MapEntry(key, value.toJson())), + }; + } + + /// Creates a deep copy of this block with a new source template. + /// + /// This method creates a completely new instance of BlockInfo and all its + /// nested blocks, with the new source template path. This is useful when + /// copying blocks between templates. + /// + /// Parameters: + /// * [newSource] - The new template path to use as the source + /// + /// Returns a new [BlockInfo] instance with all nested blocks copied. + /// + /// Example: + /// ```dart + /// final copiedBlock = originalBlock.deepCopy('new_template.liquid'); + /// ``` + BlockInfo deepCopy(String newSource) { + final nestedBlocksCopy = {}; + for (final nested in nestedBlocks.entries) { + nestedBlocksCopy[nested.key] = nested.value.deepCopy(newSource); + } + return BlockInfo( + name: name, + source: newSource, + content: content, + isOverride: false, + parent: this, + nestedBlocks: nestedBlocksCopy, + hasSuperCall: hasSuperCall, + ); + } + + /// Creates a new BlockInfo with an additional nested block. + /// + /// Since BlockInfo is immutable, this method returns a new instance + /// with the additional nested block added to the nestedBlocks map. + /// + /// Parameters: + /// * [name] - The name of the nested block to add + /// * [block] - The BlockInfo instance for the nested block + /// + /// Returns a new [BlockInfo] instance with the additional nested block. + /// + /// Example: + /// ```dart + /// final newBlock = block.withNestedBlock('navigation', navBlock); + /// ``` + BlockInfo withNestedBlock(String name, BlockInfo block) { + final newNestedBlocks = Map.from(nestedBlocks); + newNestedBlocks[name] = block; + return copyWith(nestedBlocks: newNestedBlocks); + } + + /// Finds a nested block by its dot-notation path. + /// + /// This method traverses the nested block hierarchy using a dot-separated + /// path to find a specific nested block. + /// + /// Parameters: + /// * [path] - The dot-notation path to the nested block + /// + /// Returns the [BlockInfo] for the found block, or null if not found. + /// + /// Example: + /// ```dart + /// final navBlock = headerBlock.findNestedBlock('navigation.menu'); + /// ``` + BlockInfo? findNestedBlock(String path) { + final parts = path.split('.'); + var current = this; + for (var part in parts) { + final block = current.nestedBlocks[part]; + if (block == null) return null; + current = block; + } + return current; + } +} diff --git a/lib/src/analyzer/resolver.dart b/lib/src/analyzer/resolver.dart new file mode 100644 index 0000000..622a7ea --- /dev/null +++ b/lib/src/analyzer/resolver.dart @@ -0,0 +1,380 @@ +import 'package:liquify/parser.dart'; +import 'package:liquify/src/analyzer/block_info.dart'; +import 'package:liquify/src/analyzer/template_structure.dart'; +import 'package:liquify/src/util.dart'; + +final resolverLogger = Logger('Resolver'); + +/// Recursive helper to merge a single AST node. +/// - If the node is a block tag, attempt to replace it with the resolved content. +/// - Otherwise, process its children (body and content) recursively. +List _mergeNode(ASTNode node, TemplateStructure structure) { + if (node is Tag && node.name == 'block') { + final blockName = _getBlockName(node); + resolverLogger + .info("[_mergeNode] Processing block tag with name: $blockName"); + + if (blockName == null) { + resolverLogger + .info("[_mergeNode] Block name is null, returning original node"); + return [node]; + } + + // First try to find a direct override for this block + BlockInfo? block = structure.resolvedBlocks[blockName]; + resolverLogger.info( + "[_mergeNode] Direct block lookup for '$blockName': ${block != null ? 'found' : 'not found'}"); + + // If no direct override found, look for nested block override + if (block == null) { + final nestedBlockName = structure.resolvedBlocks.keys + .firstWhere((key) => key.endsWith('.$blockName'), orElse: () => ''); + if (nestedBlockName.isNotEmpty) { + block = structure.resolvedBlocks[nestedBlockName]; + resolverLogger + .info("[_mergeNode] Found nested block override: $nestedBlockName"); + } + } + + // If we found a block (either direct or nested) + if (block != null && block.content != null) { + resolverLogger.info( + "[_mergeNode] Found block override: isOverride=${block.isOverride}, hasSuperCall=${block.hasSuperCall}"); + + // If this is an override with super call, process it + if (block.hasSuperCall) { + resolverLogger + .info("[_mergeNode] Processing super call for block: $blockName"); + return _processSuperCall(node, block, structure); + } + + // If this is an override, use its content + if (block.isOverride) { + resolverLogger + .info("[_mergeNode] Using override content for block: $blockName"); + return block.content!; + } + } + + resolverLogger.info( + "[_mergeNode] No override found or not an override, processing block body"); + return node.body.expand((n) => _mergeNode(n, structure)).toList(); + } + + // For non-block nodes, recursively process children + if (node is Tag) { + resolverLogger.info("[_mergeNode] Processing non-block tag: ${node.name}"); + final newContent = + node.content.expand((n) => _mergeNode(n, structure)).toList(); + final newBody = node.body.expand((n) => _mergeNode(n, structure)).toList(); + return [Tag(node.name, newContent, body: newBody)]; + } + + return [node]; +} + +String? _getBlockName(Tag blockTag) { + final name = blockTag.content.firstWhere( + (n) => n is Identifier, + orElse: () => TextNode(''), + ); + if (name is! Identifier) return null; + return name.name; +} + +List _processSuperCall( + Tag node, BlockInfo block, TemplateStructure structure) { + if (structure.parent == null) { + return []; + } + + // Get the block name without any parent prefixes + final blockName = block.name.split('.').last; + + // Try to find the parent's version of this block + BlockInfo? parentBlock = structure.parent!.resolvedBlocks[blockName]; + + // If not found directly, look for it as a nested block + if (parentBlock == null) { + final nestedBlockName = structure.parent!.resolvedBlocks.keys + .firstWhere((key) => key.endsWith('.$blockName'), orElse: () => ''); + if (nestedBlockName.isNotEmpty) { + parentBlock = structure.parent!.resolvedBlocks[nestedBlockName]; + } + } + + if (parentBlock == null || parentBlock.content == null) { + return []; + } + + // Process the parent's content recursively + return parentBlock.content! + .expand((n) => _mergeNode(n, structure.parent!)) + .toList(); +} + +List buildCompleteMergedAst(TemplateStructure structure, + {Map? overrides}) { + final chain = structure.inheritanceChain; + List baseNodes = chain.first.nodes; + Map currentOverrides = { + ...overrides ?? {}, + }; + + resolverLogger.startScope('Building complete merged AST'); + + // Process templates from most specific to least specific + for (int i = chain.length - 1; i >= 0; i--) { + final current = chain[i]; + resolverLogger.startScope('Processing template: ${current.templatePath}'); + resolverLogger + .info('Available blocks: (${current.resolvedBlocks.keys.join(', ')})'); + + // Collect overrides from this level + for (var entry in current.resolvedBlocks.entries) { + if (entry.value.isOverride && !currentOverrides.containsKey(entry.key)) { + resolverLogger.info( + 'Adding override for block: ${entry.key} from ${current.templatePath}'); + currentOverrides[entry.key] = entry.value; + } + } + resolverLogger.endScope(); + } + + // Now process the base nodes with all collected overrides + resolverLogger.startScope('Processing base nodes'); + resolverLogger + .info('Available overrides: ${currentOverrides.keys.join(', ')}'); + baseNodes = + _processNodesWithOverrides(baseNodes, chain.last, currentOverrides); + resolverLogger.endScope(); + + resolverLogger.endScope('AST building completed'); + return _collapseNodes(baseNodes); +} + +List _processNodesWithOverrides(List nodes, + TemplateStructure structure, Map overrides) { + List result = []; + + for (var node in nodes) { + if (node is Tag && node.name == 'block') { + String? blockName = _getBlockName(node); + resolverLogger.startScope('Processing block: $blockName'); + + if (blockName != null) { + // Check for both direct and nested block names + BlockInfo? override = _findOverride(blockName, overrides); + + if (override != null && override.content != null) { + resolverLogger.startScope('Processing override content'); + resolverLogger.info('Source: ${override.source}'); + resolverLogger.info('Is override: ${override.isOverride}'); + resolverLogger.info('Has super call: ${override.hasSuperCall}'); + + // Process override content + List processedContent = []; + for (var contentNode in override.content!) { + if (contentNode is Tag && + contentNode.name == 'super' && + override.parent != null) { + // For super() calls, process parent's content + for (var parentNode in override.parent!.content ?? []) { + if (parentNode is Tag && parentNode.name == 'nav') { + // For nav tags in parent content, extract only the text + processedContent.addAll(_extractTextContent(parentNode.body)); + } else { + processedContent.add(parentNode); + } + } + } else { + // For other nodes, process them recursively + processedContent.addAll(_processNodesWithOverrides( + [contentNode], structure, overrides)); + } + } + result.addAll(processedContent); + resolverLogger.endScope('Override processing completed'); + } else { + resolverLogger.info('No override found, processing block body'); + // No override found - process the block's body recursively + result.addAll(node.body + .expand( + (n) => _processNodesWithOverrides([n], structure, overrides)) + .toList()); + } + } + resolverLogger.endScope(); + } else if (node is Tag) { + resolverLogger.startScope('Processing non-block tag: ${node.name}'); + // Process other tags recursively + final processedContent = node.content + .expand((n) => _processNodesWithOverrides([n], structure, overrides)) + .toList(); + final processedBody = node.body + .expand((n) => _processNodesWithOverrides([n], structure, overrides)) + .toList(); + result.add(Tag(node.name, processedContent, body: processedBody)); + resolverLogger.endScope(); + } else { + result.add(node); + } + } + + return result; +} + +/// Helper function to recursively extract text content from nodes +List _extractTextContent(List nodes) { + List result = []; + for (var node in nodes) { + if (node is Tag) { + result.addAll(_extractTextContent(node.body)); + } else if (node is TextNode) { + result.add(node); + } + } + return result; +} + +BlockInfo? _findOverride(String blockName, Map overrides) { + resolverLogger.startScope('Looking for override: $blockName'); + + // Try direct match first + var override = overrides[blockName]; + if (override != null) { + resolverLogger.endScope('Found direct override'); + return override; + } + + // Try nested notation + for (var key in overrides.keys) { + if (key.endsWith('.$blockName')) { + resolverLogger.endScope('Found nested override as $key'); + return overrides[key]; + } + } + + resolverLogger.endScope('No override found'); + return null; +} + +List _collapseNodes(List nodes) { + List result = []; + TextNode? currentText; + + for (var node in nodes) { + if (node is TextNode) { + if (currentText == null) { + currentText = node; + } else { + currentText = TextNode(currentText.text + node.text); + } + } else { + if (currentText != null) { + result.add(currentText); + currentText = null; + } + result.add(node); + } + } + + if (currentText != null) { + result.add(currentText); + } + + return result; +} + +List _applyOverride(ASTNode node, TemplateStructure structure, + Map overrides) { + if (node is Tag && node.name == 'block') { + // Extract block name + String? blockName; + for (var child in node.content) { + if (child is Identifier) { + blockName = child.name; + break; + } + } + + if (blockName != null) { + // Check both direct and nested block names + BlockInfo? override = overrides[blockName]; + if (override == null) { + // Look for nested block notation + for (var key in overrides.keys) { + if (key.endsWith('.$blockName')) { + override = overrides[key]; + break; + } + } + } + + if (override != null && override.isOverride) { + if (override.content != null) { + return override.content!.expand((child) { + if (child is Tag && child.name == 'super') { + // Handle super() calls by using parent content + return node.body.expand((parentChild) => + _applyOverride(parentChild, structure, overrides)); + } + return _applyOverride(child, structure, overrides); + }).toList(); + } + } + } + + // If no override found or not marked as override, use original content + return node.body + .expand((child) => _applyOverride(child, structure, overrides)) + .toList(); + } else if (node is Tag) { + // For other tags, process content and body + final newContent = node.content + .expand((child) => _applyOverride(child, structure, overrides)) + .toList(); + final newBody = node.body + .expand((child) => _applyOverride(child, structure, overrides)) + .toList(); + return [Tag(node.name, newContent, body: newBody)]; + } else if (node is TextNode) { + // Preserve TextNodes as-is + return [node]; + } else { + // Handle other node types + return [node]; + } +} + +List resolveSuperCalls( + List? overrideContent, + List? parentContent, + TemplateStructure structure, + Map overrides) { + if (overrideContent == null) return []; + List result = []; + + for (var node in overrideContent) { + if (node is Tag && node.name == 'super') { + // On super() call, inject parent content + if (parentContent != null) { + result.addAll(parentContent.expand((child) => _applyOverride( + child, structure.parent!, structure.parent!.resolvedBlocks))); + } + } else if (node is TextNode) { + // Preserve text nodes + result.add(node); + } else if (node is Tag) { + // Process other tags recursively + final newContent = + resolveSuperCalls(node.content, parentContent, structure, overrides); + final newBody = + resolveSuperCalls(node.body, parentContent, structure, overrides); + result.add(Tag(node.name, newContent, body: newBody)); + } else { + result.add(node); + } + } + return result; +} diff --git a/lib/src/analyzer/static_analyzer.dart b/lib/src/analyzer/static_analyzer.dart new file mode 100644 index 0000000..b97f092 --- /dev/null +++ b/lib/src/analyzer/static_analyzer.dart @@ -0,0 +1,373 @@ +import 'package:liquify/parser.dart'; +import 'package:liquify/src/util.dart'; +import 'template_analyzer.dart'; + +/// Represents a variable reference in a template +class VariableReference { + final String name; + final String templatePath; + final int lineNumber; + final bool isAssignment; + final bool isRead; + + VariableReference({ + required this.name, + required this.templatePath, + required this.lineNumber, + required this.isAssignment, + required this.isRead, + }); + + @override + String toString() => + '$name (${isAssignment ? "assigned" : "read"} at $templatePath:$lineNumber)'; +} + +/// Represents a filter usage in a template +class FilterUsage { + final String name; + final String templatePath; + final int lineNumber; + final List arguments; + + FilterUsage({ + required this.name, + required this.templatePath, + required this.lineNumber, + required this.arguments, + }); + + @override + String toString() => + '$name(${arguments.join(", ")}) at $templatePath:$lineNumber'; +} + +/// Result of static analysis for a template +class StaticAnalysisResult { + final String templatePath; + final Set declaredVariables; + final Set usedVariables; + final Set undefinedVariables; + final List variableReferences; + final List filterUsages; + final Map> variableDependencies; + final List layoutDependencies; + + StaticAnalysisResult({ + required this.templatePath, + required this.declaredVariables, + required this.usedVariables, + required this.undefinedVariables, + required this.variableReferences, + required this.filterUsages, + required this.variableDependencies, + required this.layoutDependencies, + }); + + @override + String toString() { + final buffer = StringBuffer(); + buffer.writeln('Static Analysis for $templatePath:'); + buffer.writeln('Declared variables: ${declaredVariables.join(", ")}'); + buffer.writeln('Used variables: ${usedVariables.join(", ")}'); + if (undefinedVariables.isNotEmpty) { + buffer.writeln( + 'Potentially undefined variables: ${undefinedVariables.join(", ")}'); + } + if (layoutDependencies.isNotEmpty) { + buffer.writeln('Layout dependencies: ${layoutDependencies.join(" -> ")}'); + } + if (filterUsages.isNotEmpty) { + buffer.writeln('Filter usages:'); + for (final filter in filterUsages) { + buffer.writeln(' $filter'); + } + } + if (variableDependencies.isNotEmpty) { + buffer.writeln('Variable dependencies:'); + variableDependencies.forEach((variable, deps) { + buffer.writeln(' $variable depends on: ${deps.join(", ")}'); + }); + } + return buffer.toString(); + } +} + +/// Static analyzer for Liquid templates +class LiquidStaticAnalyzer { + final TemplateAnalyzer _templateAnalyzer; + final Logger _logger = Logger('StaticAnalyzer'); + + LiquidStaticAnalyzer(this._templateAnalyzer); + + /// Analyzes a template and its dependencies for variable usage and other static properties + Future analyzeTemplate(String templatePath) async { + _logger.info('Starting static analysis of $templatePath'); + + // First, use template analyzer to resolve layouts and includes + final templateAnalysis = + _templateAnalyzer.analyzeTemplate(templatePath).last; + final structure = templateAnalysis.structures[templatePath]; + + if (structure == null) { + throw Exception('Template structure not found for $templatePath'); + } + + final declaredVariables = {}; + final usedVariables = {}; + final variableReferences = []; + final filterUsages = []; + final variableDependencies = >{}; + final layoutDependencies = []; + + // Track layout dependencies + var current = structure; + while (current.parent != null) { + layoutDependencies.add(current.parent!.templatePath); + current = current.parent!; + } + + // Analyze the AST + for (final node in structure.nodes) { + _analyzeASTNode( + node, + templatePath, + declaredVariables, + usedVariables, + variableReferences, + filterUsages, + variableDependencies, + ); + } + + // Calculate undefined variables (used but not declared) + final undefinedVariables = usedVariables.difference(declaredVariables); + + return StaticAnalysisResult( + templatePath: templatePath, + declaredVariables: declaredVariables, + usedVariables: usedVariables, + undefinedVariables: undefinedVariables, + variableReferences: variableReferences, + filterUsages: filterUsages, + variableDependencies: variableDependencies, + layoutDependencies: layoutDependencies.reversed.toList(), + ); + } + + void _analyzeASTNode( + ASTNode node, + String templatePath, + Set declaredVariables, + Set usedVariables, + List variableReferences, + List filterUsages, + Map> variableDependencies, + ) { + if (node is Variable) { + _analyzeVariableNode( + node, + templatePath, + declaredVariables, + usedVariables, + variableReferences, + filterUsages, + variableDependencies, + ); + } else if (node is FilteredExpression) { + _analyzeFilteredExpression( + node, + templatePath, + declaredVariables, + usedVariables, + variableReferences, + filterUsages, + variableDependencies, + ); + } else if (node is Tag) { + _analyzeTagNode( + node, + templatePath, + declaredVariables, + usedVariables, + variableReferences, + filterUsages, + variableDependencies, + ); + + // Process tag body recursively + for (final bodyNode in node.body) { + _analyzeASTNode( + bodyNode, + templatePath, + declaredVariables, + usedVariables, + variableReferences, + filterUsages, + variableDependencies, + ); + } + } + + // Process any child nodes + if (node is Document) { + for (final child in node.children) { + _analyzeASTNode( + child, + templatePath, + declaredVariables, + usedVariables, + variableReferences, + filterUsages, + variableDependencies, + ); + } + } + } + + void _analyzeVariableNode( + Variable variable, + String templatePath, + Set declaredVariables, + Set usedVariables, + List variableReferences, + List filterUsages, + Map> variableDependencies, + ) { + usedVariables.add(variable.name); + + variableReferences.add(VariableReference( + name: variable.name, + templatePath: templatePath, + lineNumber: 0, + isAssignment: false, + isRead: true, + )); + + // Process the expression for dependencies + if (variable.expression is Variable) { + final deps = variableDependencies[variable.name] ?? []; + deps.add((variable.expression as Variable).name); + variableDependencies[variable.name] = deps; + usedVariables.add((variable.expression as Variable).name); + } + } + + void _analyzeFilteredExpression( + FilteredExpression expr, + String templatePath, + Set declaredVariables, + Set usedVariables, + List variableReferences, + List filterUsages, + Map> variableDependencies, + ) { + // First analyze the base expression + _analyzeASTNode( + expr.expression, + templatePath, + declaredVariables, + usedVariables, + variableReferences, + filterUsages, + variableDependencies, + ); + + // Then analyze each filter + for (final filter in expr.filters) { + filterUsages.add(FilterUsage( + name: filter.name.name, + templatePath: templatePath, + lineNumber: 0, + arguments: filter.arguments.map((arg) => arg.toString()).toList(), + )); + + // Track variable dependencies through filter arguments + for (final arg in filter.arguments) { + if (arg is Variable) { + if (expr.expression is Variable) { + final baseVarName = (expr.expression as Variable).name; + final deps = variableDependencies[baseVarName] ?? []; + deps.add(arg.name); + variableDependencies[baseVarName] = deps; + } + usedVariables.add(arg.name); + } + } + } + } + + void _analyzeTagNode( + Tag tag, + String templatePath, + Set declaredVariables, + Set usedVariables, + List variableReferences, + List filterUsages, + Map> variableDependencies, + ) { + switch (tag.name) { + case 'assign': + final assignContent = + tag.content.firstWhere((n) => n is Identifier) as Identifier; + final varName = assignContent.name; + declaredVariables.add(varName); + variableReferences.add(VariableReference( + name: varName, + templatePath: templatePath, + lineNumber: 0, + isAssignment: true, + isRead: false, + )); + + // Process the assigned value for dependencies + for (final node in tag.content) { + if (node is Variable) { + final deps = variableDependencies[varName] ?? []; + deps.add(node.name); + variableDependencies[varName] = deps; + usedVariables.add(node.name); + } + } + break; + + case 'capture': + final captureContent = + tag.content.firstWhere((n) => n is Identifier) as Identifier; + final varName = captureContent.name; + declaredVariables.add(varName); + variableReferences.add(VariableReference( + name: varName, + templatePath: templatePath, + lineNumber: 0, + isAssignment: true, + isRead: false, + )); + break; + + case 'for': + final loopVar = (tag.content[0] as Identifier).name; + final collection = (tag.content[2] as Variable).name; + + declaredVariables.add(loopVar); + usedVariables.add(collection); + + variableReferences.add(VariableReference( + name: loopVar, + templatePath: templatePath, + lineNumber: 0, + isAssignment: true, + isRead: false, + )); + + variableReferences.add(VariableReference( + name: collection, + templatePath: templatePath, + lineNumber: 0, + isAssignment: false, + isRead: true, + )); + break; + } + } +} diff --git a/lib/src/analyzer/template_analysis.dart b/lib/src/analyzer/template_analysis.dart new file mode 100644 index 0000000..7f67cb8 --- /dev/null +++ b/lib/src/analyzer/template_analysis.dart @@ -0,0 +1,57 @@ +import 'template_structure.dart'; + +/// Represents the results of analyzing a Liquid template and its inheritance chain. +/// +/// TemplateAnalysis collects information about: +/// * The structures of all templates in the inheritance chain +/// * Any warnings or errors encountered during analysis +/// * The relationships between templates +/// +/// This class is typically used as the return value from template analysis +/// operations and provides access to the complete analysis results. +class TemplateAnalysis { + /// Map of template paths to their analyzed structures. + /// + /// The keys are the paths to the templates (relative to the root), + /// and the values are the [TemplateStructure] objects containing + /// the analysis results for each template. + /// + /// This includes both the main template being analyzed and any + /// parent templates it extends or includes. + final Map structures; + + /// List of warnings generated during template analysis. + /// + /// Warnings might include: + /// * Missing template files + /// * Invalid block structures + /// * Inheritance issues + /// * Other non-fatal problems encountered during analysis + final List warnings; + + /// Creates a new template analysis result. + /// + /// Initializes empty maps and lists for collecting analysis results. + /// The analysis will be populated as the template and its inheritance + /// chain are processed. + TemplateAnalysis() + : structures = {}, + warnings = []; + + /// Converts the analysis results to a JSON-compatible map. + /// + /// This is useful for: + /// * Debugging template analysis + /// * Serializing analysis results + /// * Generating reports + /// + /// Returns a map containing: + /// * warnings: List of warning messages + /// * structures: Map of template paths to their structures + Map toJson() { + return { + 'warnings': warnings, + 'structures': structures, + }; + } +} diff --git a/lib/src/analyzer/template_analyzer.dart b/lib/src/analyzer/template_analyzer.dart new file mode 100644 index 0000000..eb25f4a --- /dev/null +++ b/lib/src/analyzer/template_analyzer.dart @@ -0,0 +1,372 @@ +import 'package:liquify/parser.dart'; +import 'package:liquify/src/fs.dart'; +import 'package:liquify/src/util.dart'; + +import 'block_info.dart'; +import 'template_analysis.dart'; +import 'template_structure.dart'; + +/// A template analyzer that processes Liquid templates to understand their structure, +/// block hierarchy, and inheritance relationships. +/// +/// The analyzer is responsible for: +/// * Parsing and analyzing Liquid templates +/// * Tracking template inheritance through `layout` and `extends` tags +/// * Managing block overrides and nested block relationships +/// * Building a complete picture of the template structure +/// +/// Example usage: +/// ```dart +/// final analyzer = TemplateAnalyzer(root); +/// final analysis = analyzer.analyzeTemplate('path/to/template.liquid').last; +/// final structure = analysis.structures['path/to/template.liquid']; +/// ``` +class TemplateAnalyzer { + /// The root directory context for resolving template paths. + /// + /// When provided, the analyzer uses this to locate and read template files. + /// If null, the analyzer can only work with explicitly provided template content. + final Root? root; + + /// Logger instance for debugging and tracing template analysis. + final Logger logger = Logger('TemplateAnalyzer'); + + /// Creates a new template analyzer with an optional root directory context. + /// + /// The [root] parameter provides the context for resolving template paths and + /// reading template content. If not provided, the analyzer can only work with + /// explicitly provided template content through [initialNodes]. + TemplateAnalyzer([this.root]); + + /// Analyzes a template and yields analysis results as they become available. + /// + /// This method processes a template and its inheritance chain, building a complete + /// picture of the template's structure including blocks, overrides, and layout relationships. + /// + /// Parameters: + /// * [templatePath] - The path to the template file, relative to the root + /// * [initialNodes] - Optional pre-parsed AST nodes if the template content is already available + /// * [parentStructure] - Optional parent structure if this template's parent has already been analyzed + /// + /// Returns an [Iterable] of [TemplateAnalysis] objects, which contain: + /// * The analyzed structures for this template and its parents + /// * Any warnings or errors encountered during analysis + /// + /// Example: + /// ```dart + /// final analysis = await analyzer.analyzeTemplate('child.liquid').last; + /// final childStructure = analysis.structures['child.liquid']; + /// ``` + Iterable analyzeTemplate( + String templatePath, { + List? initialNodes, + TemplateStructure? parentStructure, + }) sync* { + final analysis = TemplateAnalysis(); + if (root == null && initialNodes == null) { + analysis.warnings + .add('No root directory set and no initial nodes provided'); + yield analysis; + return; + } + try { + final nodes = + initialNodes ?? parseInput(root!.resolve(templatePath).content); + + for (final structure in _analyzeStructure(templatePath, nodes, analysis, + providedParent: parentStructure)) { + if (structure != null) { + analysis.structures[templatePath] = structure; + } + yield analysis; + } + } catch (e) { + analysis.warnings.add('Failed to analyze template: $e'); + yield analysis; + } + } + + /// Internal method that performs the actual template structure analysis. + /// + /// This method handles: + /// * Processing layout/extends tags to build the inheritance chain + /// * Analyzing and categorizing blocks within the template + /// * Managing block overrides and nested block relationships + /// * Building the complete template structure + /// + /// The analysis happens in two passes: + /// 1. First pass processes layout/extends tags and builds parent structures + /// 2. Second pass processes blocks with full knowledge of the inheritance chain + /// + /// Parameters: + /// * [templatePath] - The path to the template being analyzed + /// * [nodes] - The AST nodes of the template + /// * [analysis] - The current analysis state + /// * [providedParent] - Optional pre-analyzed parent structure + /// + /// Returns an [Iterable] of [TemplateStructure] objects representing the + /// analyzed template structure. + Iterable _analyzeStructure( + String templatePath, + List nodes, + TemplateAnalysis analysis, { + TemplateStructure? providedParent, + }) sync* { + TemplateStructure? parentStructure = providedParent; + final localBlocks = {}; + var structureBody = []; + bool layoutFound = false; + + // First pass: Process layout/extends tags and build parent structure + for (final node in nodes) { + if (!layoutFound && + node is Tag && + (node.name == 'layout' || node.name == 'extends')) { + if (node.content.isNotEmpty && node.content.first is Literal) { + final parentPath = (node.content.first as Literal).value.toString(); + logger.info('[Analyzer] Found parent template: $parentPath'); + final parentSource = root!.resolve(parentPath); + final parentNodes = parseInput(parentSource.content); + + // Process parent structure completely before continuing + var lastParentStructure = parentStructure; + for (final structure + in _analyzeStructure(parentPath, parentNodes, analysis)) { + lastParentStructure = structure; + if (lastParentStructure != null) { + analysis.structures[parentPath] = lastParentStructure; + } + } + parentStructure = lastParentStructure; + + // Ensure parent blocks are fully processed + if (parentStructure != null) { + logger.info( + '[Analyzer] Parent blocks: ${parentStructure.blocks.keys.join(', ')}'); + } + } + structureBody = List.from(node.body); + layoutFound = true; + } else if (!layoutFound) { + structureBody.add(node); + } + } + if (!layoutFound) { + structureBody = nodes; + } + + /// Internal helper function that processes block nodes recursively. + /// + /// This function: + /// * Extracts block names and content + /// * Handles nested blocks and their relationships + /// * Manages block overrides and inheritance + /// * Processes super() calls within blocks + /// + /// Parameters: + /// * [nodes] - The AST nodes to process + /// * [parentBlock] - Optional parent block for nested blocks + void processBlockNodes(List nodes, {BlockInfo? parentBlock}) { + for (final node in nodes) { + if (node is Tag && node.name == 'block') { + String? simpleName; + for (final c in node.content) { + if (c is Identifier) { + simpleName = c.name; + break; + } + if (c is Literal) { + final text = c.value.toString().trim(); + if (text.isNotEmpty) { + simpleName = text; + break; + } + } + } + if (simpleName != null) { + String finalName = simpleName; + BlockInfo? inheritedParent; + + if (parentBlock != null) { + finalName = '${parentBlock.name}.$simpleName'; + } + + // Check for block in parent structure + bool isOverride = false; + if (parentStructure != null) { + // If this block exists in any ancestor, it's an override + TemplateStructure? ancestor = parentStructure; + while (!isOverride && ancestor != null) { + // First check if any parent's block key ends with '.$simpleName' + bool found = false; + for (final key in ancestor.blocks.keys) { + if (key.endsWith('.$simpleName')) { + finalName = key; + inheritedParent = ancestor.blocks[key]; + isOverride = true; + found = true; + break; + } + } + + if (!found) { + // Then check if parent's blocks directly contain the simple name + if (ancestor.blocks.containsKey(simpleName)) { + inheritedParent = ancestor.blocks[simpleName]; + finalName = simpleName; + isOverride = true; + } else { + // Finally check each parent's topBlock nestedBlocks + for (final topBlock in ancestor.blocks.values) { + if (topBlock.nestedBlocks.containsKey(simpleName)) { + finalName = '${topBlock.name}.$simpleName'; + inheritedParent = topBlock.nestedBlocks[simpleName]; + isOverride = true; + found = true; + break; + } + } + } + } + + ancestor = ancestor.parent; + } + } + + // Check if this block has any nested blocks that are overridden + bool hasOverriddenSubBlocks = false; + if (parentStructure != null) { + hasOverriddenSubBlocks = parentStructure.blocks.keys.any((key) => + key.startsWith('$finalName.') && + parentStructure?.blocks[key]?.isOverride == true); + } + + bool foundSuper = false; + void checkForSuper(List nodes) { + for (final n in nodes) { + if (n is Tag && n.name == "super") { + foundSuper = true; + break; + } + if (n is Tag) { + checkForSuper(n.body); + checkForSuper(n.content); + } + } + } + + checkForSuper(node.body); + + final newBlock = BlockInfo( + name: finalName, + source: templatePath, + content: node.body, + isOverride: isOverride || + hasOverriddenSubBlocks || + parentBlock != null || + parentStructure != null, + parent: inheritedParent ?? parentBlock, + nestedBlocks: {}, + hasSuperCall: foundSuper, + ); + + if (parentBlock != null) { + parentBlock.nestedBlocks[simpleName] = newBlock; + } else { + localBlocks[finalName] = newBlock; + } + + logger.info( + '[Analyzer] Created block: $finalName (override: ${newBlock.isOverride})'); + processBlockNodes(node.body, parentBlock: newBlock); + } + } else if (node is Tag) { + processBlockNodes(node.body, parentBlock: parentBlock); + processBlockNodes(node.content, parentBlock: parentBlock); + } + } + } + + processBlockNodes(structureBody, parentBlock: null); + + final structure = TemplateStructure( + templatePath: templatePath, + nodes: structureBody.cast(), + blocks: localBlocks, + parent: parentStructure, + ); + + logger.info('[Analyzer] Completed structure for $templatePath'); + logger.info('[Analyzer] Blocks: ${localBlocks.keys.join(', ')}'); + yield structure; + } + + /// Builds a hierarchical tree representation of the template's block structure. + /// + /// This method takes a template path and returns a nested map structure that + /// represents the template's blocks and their relationships. The tree preserves + /// the dot notation hierarchy of nested blocks. + /// + /// Parameters: + /// * [templatePath] - The path to the template to analyze + /// + /// Returns a [Map] where: + /// * Keys are block names + /// * Values are maps containing block information including: + /// * source: The template where the block is defined + /// * isOverride: Whether the block overrides a parent block + /// * hasSuperCall: Whether the block calls super() + /// * children: Nested blocks within this block + /// + /// Example: + /// ```dart + /// final tree = analyzer.buildResolvedTree('template.liquid'); + /// print(tree['header']['children']['navigation']); + /// ``` + Map buildResolvedTree(String templatePath) { + final analysis = analyzeTemplate(templatePath).last; + final structure = analysis.structures[templatePath]; + if (structure == null) { + throw Exception('Template structure not found for $templatePath'); + } + final nestedTree = _nestByDotNotation(structure.blocks); + logger.info( + "[Analyzer][buildResolvedTree] Final nested tree produced: $nestedTree"); + return nestedTree; + } + + /// Internal helper that converts a flat map of blocks with dot notation keys + /// into a nested tree structure. + /// + /// This method takes block names like "header.navigation.menu" and creates + /// a nested structure: header -> navigation -> menu. + /// + /// Parameters: + /// * [flatMap] - The flat map of blocks using dot notation keys + /// + /// Returns a nested [Map] structure preserving the block hierarchy. + Map _nestByDotNotation(Map flatMap) { + final Map root = {}; + flatMap.forEach((dottedKey, blockInfo) { + final parts = dottedKey.split('.'); + Map current = root; + for (int i = 0; i < parts.length; i++) { + final segment = parts[i]; + current.putIfAbsent(segment, () => {}); + if (i == parts.length - 1) { + current[segment] = { + 'source': blockInfo.source, + 'isOverride': blockInfo.isOverride, + 'hasSuperCall': blockInfo.hasSuperCall, + 'children': {}, + }; + } else { + current[segment].putIfAbsent('children', () => {}); + current = + Map.from(current[segment]['children'] as Map); + } + } + }); + return root; + } +} diff --git a/lib/src/analyzer/template_structure.dart b/lib/src/analyzer/template_structure.dart new file mode 100644 index 0000000..57b6655 --- /dev/null +++ b/lib/src/analyzer/template_structure.dart @@ -0,0 +1,323 @@ +import 'package:liquify/parser.dart'; +import 'package:logging/logging.dart'; + +import 'block_info.dart'; + +// Create a logger for this file. +final Logger logger = Logger('TemplateStructure'); + +initLogger() { + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) { + print('${record.level.name}: ${record.time}: ${record.message}'); + }); +} + +/// Represents the structure of a Liquid template, including its blocks, +/// inheritance relationships, and content. +/// +/// TemplateStructure is a key component in template analysis that: +/// * Maintains the hierarchy of template inheritance +/// * Tracks blocks and their relationships +/// * Provides methods to query and manipulate the template structure +/// +/// The structure is built during template analysis and can be used to: +/// * Resolve block overrides +/// * Handle super() calls +/// * Generate the final merged template +class TemplateStructure { + /// The path to this template file. + final String templatePath; + + /// The AST nodes that make up this template's content. + final List nodes; + + /// Map of blocks defined in this template. + /// + /// The keys are block names (which may use dot notation for nested blocks) + /// and the values are [BlockInfo] objects containing the block details. + final Map blocks; + + /// The parent template structure if this template extends another. + /// + /// This is set when the template uses the `layout` or `extends` tag + /// to inherit from another template. + final TemplateStructure? parent; + + /// Creates a new template structure. + /// + /// Parameters: + /// * [templatePath] - The path to this template file + /// * [nodes] - The AST nodes of the template + /// * [blocks] - Map of blocks defined in this template + /// * [parent] - The parent template structure if this extends another + const TemplateStructure({ + required this.templatePath, + required this.nodes, + required this.blocks, + this.parent, + }); + + /// Returns true if this template extends another template + bool get hasParent => parent != null; + + /// Returns the root template in the inheritance chain + TemplateStructure get root { + var current = this; + while (current.parent != null) { + current = current.parent!; + } + return current; + } + + /// Returns true if this template has any blocks + bool get hasBlocks => blocks.isNotEmpty; + + /// Returns the inheritance chain from root to leaf + List get inheritanceChain { + final chain = []; + var current = this; + while (current.parent != null) { + chain.add(current); + current = current.parent!; + } + chain.add(current); // Add the root template + return chain.reversed.toList(); // Return from root to leaf + } + + /// Returns the names of all blocks in this template + Set get blockNames => blocks.keys.toSet(); + + /// Returns true if this template has a block with the given name + bool hasBlock(String name) => blocks.containsKey(name); + + /// Gets the block info for a given block name + BlockInfo? getBlock(String name) => blocks[name]; + + /// Adds a block to this template's structure + void addBlock(String name, BlockInfo info) { + blocks[name] = info; + } + + @override + String toString() { + final buffer = StringBuffer(); + buffer.writeln('Template: $templatePath'); + if (hasParent) { + buffer.writeln('Parent: ${parent!.templatePath}'); + } + buffer.writeln('Blocks:'); + blocks.forEach((name, info) { + buffer.writeln(' $name: $info'); + }); + return buffer.toString(); + } + + /// Returns a map of all blocks in this template and its parents. + /// + /// This method provides a complete view of all blocks available in the + /// template hierarchy, with child template blocks taking precedence + /// over parent template blocks. + /// + /// The returned map uses dot notation for nested blocks and includes + /// override information. + /// + /// Example: + /// ```dart + /// final allBlocks = structure.resolvedBlocks; + /// print(allBlocks['header']?.source); // Template where header is defined + /// ``` + Map get resolvedBlocks { + Map local = {}; + var ancestorChain = []; + var current = parent; + while (current != null) { + ancestorChain.add(current); + current = current.parent; + } + for (var ancestor in ancestorChain.reversed) { + final flattened = flatten(ancestor.blocks); + local.addAll(flattened); + } + local.addAll(flatten(blocks)); + + return local; + } + + /// Converts this template structure to a JSON-compatible map. + /// + /// This is useful for: + /// * Debugging template structures + /// * Serializing template information + /// * Generating documentation + /// + /// Returns a map containing: + /// * name: The template path + /// * parent: The parent template path (if any) + /// * blocks: Map of blocks in this template + /// * variables: Map of variables used in this template + /// * dependencies: List of template dependencies + /// * allBlockNames: List of all block names in this template + Map toJson() { + return { + 'name': templatePath, + 'parent': parent?.templatePath, + 'blocks': blocks.map((key, value) => MapEntry(key, value.toJson())), + 'variables': {}, + 'dependencies': [], + 'allBlockNames': allBlockNames.toList(), + }; + } + + /// Returns a flattened map of all blocks in this template. + /// + /// This method takes a map of blocks (potentially with nested blocks) + /// and flattens it into a single map where nested block names use + /// dot notation (e.g., "header.navigation"). + /// + /// Parameters: + /// * [blocks] - The blocks to flatten + /// * [prefix] - Optional prefix for nested block names + /// + /// Returns a map where keys are the full block paths and values + /// are the corresponding [BlockInfo] objects. + /// + /// Example: + /// ```dart + /// final flatBlocks = structure.flatten(blocks); + /// print(flatBlocks['header.navigation']); // Prints nested block info + /// ``` + Map flatten(Map blocks, + {String prefix = ''}) { + final map = {}; + blocks.forEach((key, blockInfo) { + final fullName = prefix.isEmpty ? key : '$prefix.$key'; + + // Check if any ancestor template has defined this block + bool isOverride = false; + var ancestor = parent; + while (ancestor != null && !isOverride) { + // Check direct blocks + if (ancestor.blocks.containsKey(fullName) || + ancestor.blocks.containsKey(key)) { + isOverride = true; + } else { + // Check nested blocks in each top-level block + for (final block in ancestor.blocks.values) { + if (block.nestedBlocks.containsKey(key)) { + isOverride = true; + break; + } + } + } + ancestor = ancestor.parent; + } + + final effectiveBlock = + isOverride ? blockInfo.copyWith(isOverride: true) : blockInfo; + map[fullName] = effectiveBlock; + + // Also flatten any nested blocks + if (blockInfo.nestedBlocks.isNotEmpty) { + map.addAll(flatten(blockInfo.nestedBlocks, prefix: fullName)); + } + }); + return map; + } + + /// Returns a set of all block names in the inheritance chain. + /// + /// This includes block names from: + /// * This template + /// * All parent templates + /// * All nested blocks in any template + /// + /// Example: + /// ```dart + /// final names = structure.allBlockNames; + /// print(names.contains('header.navigation')); // Check if block exists + /// ``` + Set get allBlockNames { + final names = {}; + final inheritanceChain = []; + + TemplateStructure? current = this; + while (null != current) { + inheritanceChain.add(current); + current = current.parent; + } + void addBlockNames(Map blocks) { + for (final block in blocks.values) { + names.add(block.name); + names.addAll(block.nestedBlocks.keys); + } + } + + for (final template in inheritanceChain) { + addBlockNames(template.blocks); + } + return names; + } + + /// Finds a block by its fully qualified name. + /// + /// This method can find both top-level and nested blocks using + /// dot notation (e.g., "header.navigation"). + /// + /// Parameters: + /// * [name] - The fully qualified block name to find + /// + /// Returns the [BlockInfo] for the found block, or null if not found. + /// + /// Example: + /// ```dart + /// final navBlock = structure.findBlock('header.navigation'); + /// if (navBlock != null) { + /// print('Navigation defined in: ${navBlock.source}'); + /// } + /// ``` + BlockInfo? findBlock(String name) { + var parts = name.split('.'); + var container = blocks; + BlockInfo? result; + for (var part in parts) { + result = container[part]; + if (result == null) { + return null; + } + container = result.nestedBlocks; + } + return result; + } + + /// Checks if this template or any of its parents has a block. + /// + /// This method searches for a block through the entire template + /// inheritance chain, including nested blocks. + /// + /// Parameters: + /// * [blockName] - The name of the block to find + /// + /// Returns true if the block is found anywhere in the inheritance chain. + /// + /// Example: + /// ```dart + /// if (structure.hasBlockInChain('navigation')) { + /// print('Navigation block exists in template hierarchy'); + /// } + /// ``` + bool hasBlockInChain(String blockName) { + // Check if this template has the block + if (blocks.containsKey(blockName)) { + return true; + } + // Check if any block in this template has the given block as a nested block + for (final block in blocks.values) { + if (block.nestedBlocks.containsKey(blockName)) { + return true; + } + } + // Check parent templates + return parent?.hasBlockInChain(blockName) ?? false; + } +} diff --git a/lib/src/ast.dart b/lib/src/ast.dart index d616989..d52ac8c 100644 --- a/lib/src/ast.dart +++ b/lib/src/ast.dart @@ -86,6 +86,11 @@ class Tag extends ASTNode { @override Future acceptAsync(ASTVisitor visitor) => visitor.visitTagAsync(this); + + @override + String toString() { + return 'Tag(name: $name, content: $content, body: $body, filters: $filters)'; + } } class GroupedExpression extends ASTNode { @@ -258,6 +263,10 @@ class TextNode extends ASTNode { @override Future acceptAsync(ASTVisitor visitor) => visitor.visitTextNodeAsync(this); + @override + String toString() { + return text; + } @override Map toJson() => { diff --git a/lib/src/context.dart b/lib/src/context.dart index dc32bfe..310ee9c 100644 --- a/lib/src/context.dart +++ b/lib/src/context.dart @@ -1,4 +1,5 @@ import 'package:liquify/src/fs.dart'; + import 'filter_registry.dart'; /// Represents the execution environment for a code context. @@ -6,8 +7,28 @@ import 'filter_registry.dart'; /// The `Environment` class manages the variable stack and filters used within a /// code context. It provides methods for pushing and popping scopes, as well as /// accessing and modifying variables within the current scope. +enum BlockMode { + output, // Normal template rendering + store // Store blocks for layout +} + class Environment { final List> _variableStack; + final Map _registers; + + void setRegister(String key, dynamic value) { + _registers[key] = value; + } + + void removeRegister(String s) { + _registers.remove(s); + } + + get registers => _registers; + + dynamic getRegister(String key) { + return _registers[key]; + } /// Constructs a new `Environment` instance with the provided initial data. /// @@ -17,11 +38,12 @@ class Environment { /// /// Parameters: /// - `data`: An optional map of initial variables and their values. Defaults to an empty map. - Environment([Map data = const {}]) : _variableStack = [data]; + Environment( + [Map data = const {}, Map? register]) + : _variableStack = [data], + _registers = register ?? {}; - Environment._clone( - this._variableStack, - ); + Environment._clone(this._variableStack, this._registers); /// Creates a new [Environment] instance that is a deep copy of the current instance. /// @@ -38,7 +60,9 @@ class Environment { .toList(); // Shallow copy the filters (assuming filters are immutable) // final clonedFilters = Map.from(_filters); - return Environment._clone(clonedVariableStack /*, clonedFilters*/); + final cloned = Environment._clone(clonedVariableStack, _registers); + cloned._root = _root; + return cloned; } Root? _root; diff --git a/lib/src/evaluator.dart b/lib/src/evaluator.dart index af18a6d..7842ffc 100644 --- a/lib/src/evaluator.dart +++ b/lib/src/evaluator.dart @@ -1,668 +1 @@ -import 'package:liquify/parser.dart' show parseInput; -import 'package:liquify/src/context.dart'; -import 'package:liquify/src/drop.dart'; -import 'package:liquify/src/tag_registry.dart'; -import 'package:liquify/src/util.dart'; - -import 'ast.dart'; -import 'buffer.dart'; -import 'visitor.dart'; - -/// Evaluates Liquid templates by traversing and executing AST nodes. -/// -/// The evaluator maintains a [context] for variable storage and a [buffer] for -/// accumulating output during template rendering. It implements the visitor pattern -/// through [ASTVisitor] to evaluate different types of AST nodes. -/// -/// Example: -/// ```dart -/// final evaluator = Evaluator(Environment()); -/// final nodes = parseInput('Hello {{ name }}!'); -/// evaluator.context.setVariable('name', 'World'); -/// final result = evaluator.evaluateNodes(nodes); -/// print(result); // Prints: Hello World! -/// ``` -class Evaluator implements ASTVisitor { - final Environment context; - Buffer buffer = Buffer(); - final Map nodeMap = {}; - - Evaluator(this.context); - - /// Creates a new `Evaluator` instance with the provided `Environment` context and `Buffer`. - /// The `Buffer` is used to accumulate the output of the template evaluation. - Evaluator.withBuffer(this.context, this.buffer); - - /// Creates a new `Evaluator` instance with a cloned `Environment` context and the same `Buffer`. - /// This allows creating a nested `Evaluator` instance with its own context, while sharing the same output buffer. - Evaluator createInnerEvaluator() { - final innerContext = context.clone(); - return Evaluator.withBuffer(innerContext, buffer); - } - - /// Creates a nested evaluator with a cloned context and the given [buffer]. - /// - /// Useful for evaluating nested templates that need their own variable scope - /// while sharing the same output buffer. - /// - /// ```dart - /// final inner = evaluator.createInnerEvaluatorWithBuffer(Buffer()); - /// inner.context.setVariable('x', 123); // Won't affect parent context - /// ``` - Evaluator createInnerEvaluatorWithBuffer(Buffer buffer) { - final innerContext = context.clone(); - return Evaluator.withBuffer(innerContext, buffer); - } - - /// Resolves and parses the Liquid template with the given name. - /// - /// The template is resolved relative to the root directory set in the `Environment` context. - /// If no root directory is set, an exception is thrown. - /// - /// The content of the resolved template file is parsed into a list of `ASTNode` instances. - /// - /// @param templateName The name of the Liquid template to resolve and parse. - /// @return A list of `ASTNode` instances representing the parsed template. - /// @throws Exception if no root directory is set for template resolution. - List resolveAndParseTemplate(String templateName) { - final root = context.getRoot(); - if (root == null) { - throw Exception('No root directory set for template resolution'); - } - - final source = root.resolve(templateName); - return parseInput(source.content); - } - - /// Resolves and parses a template asynchronously. - /// - /// Use this instead of [resolveAndParseTemplate] when loading templates from - /// remote sources or slow storage. Throws if no root directory is set. - Future> resolveAndParseTemplateAsync( - String templateName) async { - final root = context.getRoot(); - if (root == null) { - throw Exception('No root directory set for template resolution'); - } - final source = await root.resolveAsync(templateName); - return parseInput(source.content); - } - - /// Evaluates the provided AST node by calling its `accept` method with this `Evaluator` instance. - /// - /// This method is used to evaluate individual AST nodes during the template evaluation process. - /// It delegates the evaluation of the node to the node's own `accept` method, passing in the current `Evaluator` instance. - /// - /// @param node The AST node to evaluate. - /// @return The result of evaluating the AST node. - dynamic evaluate(ASTNode node) { - return node.accept(this); - } - - /// Evaluates the provided AST node asynchronously by calling its `acceptAsync` method with this `Evaluator` instance. - /// - /// This method is used to evaluate individual AST nodes during the template evaluation process asynchronously. - /// It delegates the evaluation of the node to the node's own `acceptAsync` method, passing in the current `Evaluator` instance. - /// - /// @param node The AST node to evaluate. - /// @return The result of evaluating the AST node. - Future evaluateAsync(ASTNode node) { - return node.acceptAsync(this); - } - - /// Evaluates a list of AST nodes and writes the results to the buffer. - /// - /// This method iterates through the provided list of AST nodes and evaluates each one. - /// For `Assignment` nodes, the method simply continues to the next node. - /// For `Tag` nodes, the method calls the `accept` method on the node, allowing the node to handle its own evaluation. - /// For all other node types, the method writes the result of evaluating the node to the buffer. - /// - /// After evaluating all the nodes, the method returns the contents of the buffer as a string. - /// - /// @param nodes The list of AST nodes to evaluate. - /// @return The contents of the buffer as a string, representing the evaluated nodes. - dynamic evaluateNodes(List nodes) { - for (final node in nodes) { - if (node is Assignment) continue; - if (node is Tag) { - node.accept(this); - } else { - buffer.write(node.accept(this)); - } - } - return buffer.toString(); - } - - /// Evaluates a list of AST nodes asynchronously and writes the results to the buffer. - /// - /// This method iterates through the provided list of AST nodes and evaluates each one asynchronously. - /// For `Assignment` nodes, the method simply continues to the next node. - /// For `Tag` nodes, the method calls the `acceptAsync` method on the node, allowing the node to handle its own evaluation. - /// For all other node types, the method writes the result of evaluating the node to the buffer. - /// - /// After evaluating all the nodes, the method returns the contents of the buffer as a string. - /// - /// @param nodes The list of AST nodes to evaluate. - /// @return The contents of the buffer as a string, representing the evaluated nodes. - Future evaluateNodesAsync(List nodes) async { - for (final node in nodes) { - if (node is Assignment) continue; - if (node is Tag) { - await node.acceptAsync(this); - } else { - buffer.write(await node.acceptAsync(this)); - } - } - return buffer.toString(); - } - - @override - - /// Returns the raw value stored in the [Literal] node. - /// - /// Values can be strings, numbers, booleans, null, etc. - dynamic visitLiteral(Literal node) { - return node.value; - } - - @override - dynamic visitIdentifier(Identifier node) { - final value = context.getVariable(node.name); - return value; - } - - @override - dynamic visitBinaryOperation(BinaryOperation node) { - final left = node.left.accept(this); - final right = node.right.accept(this); - dynamic result; - switch (node.operator) { - case '+': - result = left + right; - break; - case '-': - result = left - right; - break; - case '*': - result = left * right; - break; - case '/': - result = left / right; - break; - case '==': - result = left == right; - break; - case '!=': - result = left != right; - break; - case '<': - result = left < right; - break; - case '>': - result = left > right; - break; - case '<=': - result = left <= right; - break; - case '>=': - result = left >= right; - break; - case 'and': - result = isTruthy(left) && isTruthy(right); - break; - case 'or': - result = isTruthy(left) || isTruthy(right); - break; - case '..': - result = List.generate(right - left + 1, (index) => left + index); - break; - case 'in': - if (right is! Iterable) { - throw Exception('Right side of "in" operator must be iterable.'); - } - result = right.contains(left); - break; - default: - throw UnsupportedError('Unsupported operator: ${node.operator}'); - } - return result; - } - - @override - dynamic visitUnaryOperation(UnaryOperation node) { - final expr = node.expression.accept(this); - dynamic result; - switch (node.operator) { - case 'not': - case '!': - result = !expr; - break; - default: - throw UnsupportedError('Unsupported operator: ${node.operator}'); - } - return result; - } - - @override - dynamic visitGroupedExpression(GroupedExpression node) { - return node.expression.accept(this); - } - - @override - dynamic visitAssignment(Assignment node) { - final value = node.value.accept(this); - if (node.variable is Identifier) { - context.setVariable((node.variable as Identifier).name, value); - } else if (node.variable is MemberAccess) { - final memberAccess = node.variable as MemberAccess; - - final objName = (memberAccess.object as Identifier).name; - - if (context.getVariable(objName) == null) { - context.setVariable(objName, {}); - } - - var objectVal = context(objName); - - for (var i = 0; i < memberAccess.members.length; i++) { - final name = (memberAccess.members[i] as Identifier).name; - if (i == memberAccess.members.length - 1) { - objectVal[name] = value; - } else { - if (!(objectVal as Map).containsKey(memberAccess.members[i])) { - objectVal[name] = {}; - } - objectVal = objectVal[name]; - } - } - } - } - - @override - dynamic visitDocument(Document node) { - return evaluateNodes(node.children); - } - - @override - dynamic visitFilter(Filter node) { - final filterFunction = context.getFilter(node.name.name); - if (filterFunction == null) { - throw Exception('Undefined filter: ${node.name.name}'); - } - final args = []; - final namedArgs = {}; - - for (final arg in node.arguments) { - if (arg is NamedArgument) { - namedArgs[arg.identifier.name] = arg.value.accept(this); - } else { - args.add(arg.accept(this)); - } - } - - return (value) => filterFunction(value, args, namedArgs); - } - - @override - dynamic visitFilterExpression(FilteredExpression node) { - dynamic value; - - if (node.expression is Assignment) { - (node.expression as Assignment).value.accept(this); - - if ((node.expression as Assignment).value is Literal) { - value = ((node.expression as Assignment).value as Literal).value; - } else { - value = context.getVariable( - ((node.expression as Assignment).value as Identifier).name); - } - } else { - value = node.expression.accept(this); - } - - for (final filter in node.filters) { - final filterFunction = filter.accept(this); - value = filterFunction(value); - } - return value; - } - - @override - dynamic visitMemberAccess(MemberAccess node) { - var object = node.object; - final objName = (object as Identifier).name; - - var objectVal = context.getVariable(objName); - - if (objectVal == null) return null; - - //members can either be a Identifier or an ArrayAccess - for (final member in node.members) { - final keyName = member is Identifier - ? member.name - : ((member as ArrayAccess).array as Identifier).name; - final isArray = member is ArrayAccess; - - if (isArray) { - final key = (member.array as Identifier).name; - final index = (member.key as Literal).value; - objectVal = objectVal[key]; - - if (objectVal == null) return; - if (objectVal is List) { - if (index >= 0 && index < objectVal.length) { - objectVal = objectVal[index]; - } else { - return null; - } - } else { - objectVal = objectVal[index]; - } - } else if (objectVal is Drop) { - if (member is Identifier) { - objectVal = objectVal(Symbol(keyName)); - } - } else if (objectVal is Map) { - if (!objectVal.containsKey(keyName)) { - return null; - } - objectVal = objectVal[keyName]; - } else if (objectVal == null) { - return null; - } - } - return objectVal; - } - - @override - dynamic visitNamedArgument(NamedArgument node) { - return MapEntry(node.identifier.name, node.value.accept(this)); - } - - @override - dynamic visitTag(Tag node) { - final tag = TagRegistry.createTag(node.name, node.content, node.filters); - tag?.preprocess(this); - tag?.body = node.body; - tag?.evaluate(this, buffer); - } - - @override - dynamic visitTextNode(TextNode node) { - return node.text; - } - - @override - dynamic visitVariable(Variable node) { - final value = node.expression.accept(this); - return value; - } - - @override - visitArrayAccess(ArrayAccess arrayAccess) { - final array = arrayAccess.array.accept(this); - final key = arrayAccess.key.accept(this); - if (array is List) { - final index = key is int ? key : int.parse(key); - if (index >= 0 && index < array.length) { - return array[index]; - } - } else if (array is Map && array.containsKey(key)) { - return array[key]; - } - return null; - } - - @override - Future visitArrayAccessAsync(ArrayAccess arrayAccess) async { - final array = await arrayAccess.array.acceptAsync(this); - final key = await arrayAccess.key.acceptAsync(this); - if (array is List) { - final index = key is int ? key : int.parse(key); - if (index >= 0 && index < array.length) { - return array[index]; - } - } else if (array is Map && array.containsKey(key)) { - return array[key]; - } - return null; - } - - @override - Future visitAssignmentAsync(Assignment node) async { - final value = await node.value.acceptAsync(this); - if (node.variable is Identifier) { - context.setVariable((node.variable as Identifier).name, value); - } else if (node.variable is MemberAccess) { - final memberAccess = node.variable as MemberAccess; - - final objName = (memberAccess.object as Identifier).name; - - if (context.getVariable(objName) == null) { - context.setVariable(objName, {}); - } - - var objectVal = context(objName); - - for (var i = 0; i < memberAccess.members.length; i++) { - final name = (memberAccess.members[i] as Identifier).name; - if (i == memberAccess.members.length - 1) { - objectVal[name] = value; - } else { - if (!(objectVal as Map).containsKey(memberAccess.members[i])) { - objectVal[name] = {}; - } - objectVal = objectVal[name]; - } - } - } - } - - @override - Future visitBinaryOperationAsync(BinaryOperation node) async { - final left = await node.left.acceptAsync(this); - final right = await node.right.acceptAsync(this); - dynamic result; - switch (node.operator) { - case '+': - result = left + right; - break; - case '-': - result = left - right; - break; - case '*': - result = left * right; - break; - case '/': - result = left / right; - break; - case '==': - result = left == right; - break; - case '!=': - result = left != right; - break; - case '<': - result = left < right; - break; - case '>': - result = left > right; - break; - case '<=': - result = left <= right; - break; - case '>=': - result = left >= right; - break; - case 'and': - result = isTruthy(left) && isTruthy(right); - break; - case 'or': - result = isTruthy(left) || isTruthy(right); - break; - case '..': - result = List.generate(right - left + 1, (index) => left + index); - break; - case 'in': - if (right is! Iterable) { - throw Exception('Right side of "in" operator must be iterable.'); - } - result = right.contains(left); - break; - default: - throw UnsupportedError('Unsupported operator: ${node.operator}'); - } - return result; - } - - @override - Future visitDocumentAsync(Document node) async { - return await evaluateNodesAsync(node.children); - } - - @override - Future visitFilterAsync(Filter node) async { - final filterFunction = context.getFilter(node.name.name); - if (filterFunction == null) { - throw Exception('Undefined filter: ${node.name.name}'); - } - final args = []; - final namedArgs = {}; - - for (final arg in node.arguments) { - if (arg is NamedArgument) { - namedArgs[arg.identifier.name] = await arg.value.acceptAsync(this); - } else { - args.add(await arg.acceptAsync(this)); - } - } - - return (value) => filterFunction(value, args, namedArgs); - } - - @override - Future visitFilterExpressionAsync(FilteredExpression node) async { - dynamic value; - - if (node.expression is Assignment) { - await (node.expression as Assignment).value.acceptAsync(this); - - if ((node.expression as Assignment).value is Literal) { - value = ((node.expression as Assignment).value as Literal).value; - } else { - value = context.getVariable( - ((node.expression as Assignment).value as Identifier).name); - } - } else { - value = await node.expression.acceptAsync(this); - } - - for (final filter in node.filters) { - final filterFunction = await filter.acceptAsync(this); - value = filterFunction(value); - } - return value; - } - - @override - Future visitGroupedExpressionAsync(GroupedExpression node) async { - return await node.expression.acceptAsync(this); - } - - @override - Future visitIdentifierAsync(Identifier node) async { - final value = context.getVariable(node.name); - return value; - } - - @override - Future visitLiteralAsync(Literal node) async { - return node.value; - } - - @override - Future visitMemberAccessAsync(MemberAccess node) async { - var object = node.object; - final objName = (object as Identifier).name; - - var objectVal = context.getVariable(objName); - - if (objectVal == null) return null; - - //members can either be a Identifier or an ArrayAccess - for (final member in node.members) { - final keyName = member is Identifier - ? member.name - : ((member as ArrayAccess).array as Identifier).name; - final isArray = member is ArrayAccess; - - if (isArray) { - final key = (member.array as Identifier).name; - final index = (member.key as Literal).value; - objectVal = objectVal[key]; - - if (objectVal == null) return; - if (objectVal is List) { - if (index >= 0 && index < objectVal.length) { - objectVal = objectVal[index]; - } else { - return null; - } - } else { - objectVal = objectVal[index]; - } - } else if (objectVal is Drop) { - if (member is Identifier) { - objectVal = objectVal(Symbol(keyName)); - } - } else if (objectVal is Map) { - if (!objectVal.containsKey(keyName)) { - return null; - } - objectVal = objectVal[keyName]; - } else if (objectVal == null) { - return null; - } - } - return objectVal; - } - - @override - Future visitNamedArgumentAsync(NamedArgument node) async { - return MapEntry(node.identifier.name, await node.value.acceptAsync(this)); - } - - @override - Future visitTagAsync(Tag node) async { - final tag = TagRegistry.createTag(node.name, node.content, node.filters); - tag?.preprocess(this); - tag?.body = node.body; - await tag?.evaluateAsync(this, buffer); - } - - @override - Future visitTextNodeAsync(TextNode node) async { - return node.text; - } - - @override - Future visitUnaryOperationAsync(UnaryOperation node) async { - final expr = await node.expression.acceptAsync(this); - dynamic result; - switch (node.operator) { - case 'not': - case '!': - result = !expr; - break; - default: - throw UnsupportedError('Unsupported operator: ${node.operator}'); - } - return result; - } - - @override - Future visitVariableAsync(Variable node) async { - final value = await node.expression.acceptAsync(this); - return value; - } -} +export 'evaluator/evaluator.dart'; diff --git a/lib/src/evaluator/buffer.dart b/lib/src/evaluator/buffer.dart new file mode 100644 index 0000000..2c1fe6b --- /dev/null +++ b/lib/src/evaluator/buffer.dart @@ -0,0 +1,29 @@ +part of 'evaluator.dart'; + +extension BufferHandling on Evaluator { + Buffer get currentBuffer => + _blockBuffers.isEmpty ? buffer : _blockBuffers.last; + + void startBlockCapture() { + pushBuffer(); + } + + String endBlockCapture() { + return popBuffer(); + } + + bool isCapturingBlock() { + return _blockBuffers.isNotEmpty; + } + + void pushBuffer() { + _blockBuffers.add(Buffer()); + } + + String popBuffer() { + if (_blockBuffers.isEmpty) { + throw Exception('No block buffer to pop'); + } + return _blockBuffers.removeLast().toString(); + } +} diff --git a/lib/src/evaluator/evaluate.dart b/lib/src/evaluator/evaluate.dart new file mode 100644 index 0000000..654c514 --- /dev/null +++ b/lib/src/evaluator/evaluate.dart @@ -0,0 +1,55 @@ +part of 'evaluator.dart'; + +extension Evaluation on Evaluator { + dynamic evaluate(ASTNode node) { + return node.accept(this); + } + + Future evaluateAsync(ASTNode node) { + return node.acceptAsync(this); + } + + List resolveAndParseTemplate(String templateName) { + final root = context.getRoot(); + if (root == null) { + throw Exception('No root directory set for template resolution'); + } + + final source = root.resolve(templateName); + return parseInput(source.content); + } + + Future> resolveAndParseTemplateAsync( + String templateName) async { + final root = context.getRoot(); + if (root == null) { + throw Exception('No root directory set for template resolution'); + } + final source = await root.resolveAsync(templateName); + return parseInput(source.content); + } + + String evaluateNodes(List nodes) { + for (final node in nodes) { + if (node is Assignment) continue; + if (node is Tag) { + node.accept(this); + } else { + currentBuffer.write(node.accept(this)); + } + } + return buffer.toString(); + } + + Future evaluateNodesAsync(List nodes) async { + for (final node in nodes) { + if (node is Assignment) continue; + if (node is Tag) { + await node.acceptAsync(this); + } else { + currentBuffer.write(await node.acceptAsync(this)); + } + } + return buffer.toString(); + } +} diff --git a/lib/src/evaluator/evaluator.dart b/lib/src/evaluator/evaluator.dart new file mode 100644 index 0000000..1b40011 --- /dev/null +++ b/lib/src/evaluator/evaluator.dart @@ -0,0 +1,497 @@ +import 'package:liquify/parser.dart' show parseInput, Tag; +import 'package:liquify/src/context.dart'; +import 'package:liquify/src/drop.dart'; +import 'package:liquify/src/filter_registry.dart'; +import 'package:liquify/src/tag_registry.dart'; +import 'package:liquify/src/util.dart'; + +import '../ast.dart'; +import '../buffer.dart'; +import '../visitor.dart'; + +part 'buffer.dart'; +part 'evaluate.dart'; +part 'visit.dart'; +part 'visit_async.dart'; + +/// Evaluates Liquid templates by traversing and executing AST nodes. +/// +/// The evaluator maintains a [context] for variable storage and a [buffer] for +/// accumulating output during template rendering. It implements the visitor pattern +/// through [ASTVisitor] to evaluate different types of AST nodes. +class Evaluator implements ASTVisitor { + final Environment context; + Buffer buffer = Buffer(); + final List _blockBuffers = []; + final Map nodeMap = {}; + + Evaluator(this.context); + + /// Creates a new `Evaluator` instance with the provided `Environment` context and `Buffer`. + Evaluator.withBuffer(this.context, this.buffer); + + /// Creates a new `Evaluator` instance with a cloned `Environment` context and the same `Buffer`. + Evaluator createInnerEvaluator() { + final innerContext = context.clone(); + return Evaluator.withBuffer(innerContext, buffer); + } + + tmpResult(List nodes) { + final innerContext = context.clone(); + return Evaluator.withBuffer(innerContext, Buffer()).evaluateNodes(nodes); + } + + tmpResultAsync(List nodes) async { + final innerContext = context.clone(); + return await Evaluator.withBuffer(innerContext, Buffer()) + .evaluateNodesAsync(nodes); + } + + /// Creates a nested evaluator with a cloned context and the given [buffer]. + Evaluator createInnerEvaluatorWithBuffer(Buffer buffer) { + final innerContext = context.clone(); + return Evaluator.withBuffer(innerContext, buffer); + } + + @override + dynamic visitLiteral(Literal node) { + return node.value; + } + + @override + dynamic visitIdentifier(Identifier node) { + final value = context.getVariable(node.name); + return value; + } + + dynamic _binaryOp(left, operator, right) { + switch (operator) { + case '+': + return (left ?? 0) + (right ?? 0); + case '-': + return (left ?? 0) - (right ?? 0); + case '*': + return (left ?? 1) * (right ?? 1); + case '/': + return (left ?? 1) / (right ?? 1); + case '==': + return left == right; + case '!=': + return left != right; + case '<': + return (left ?? 0) < (right ?? 0); + case '>': + return (left ?? 0) > (right ?? 0); + case '<=': + return (left ?? 0) <= (right ?? 0); + case '>=': + return (left ?? 0) >= (right ?? 0); + case 'and': + return isTruthy(left) && isTruthy(right); + case 'or': + return isTruthy(left) || isTruthy(right); + case '..': + return List.generate( + (right ?? 0) - (left ?? 0) + 1, (index) => (left ?? 0) + index); + case 'in': + if (right is! Iterable) { + throw Exception('Right side of "in" operator must be iterable.'); + } + return right.contains(left); + default: + throw UnsupportedError('Unsupported operator: $operator'); + } + } + + @override + dynamic visitBinaryOperation(BinaryOperation node) { + final left = node.left.accept(this); + final right = node.right.accept(this); + + return _binaryOp(left, node.operator, right); + } + + @override + dynamic visitUnaryOperation(UnaryOperation node) { + final expr = node.expression.accept(this); + switch (node.operator) { + case 'not': + case '!': + return !expr; + default: + throw UnsupportedError('Unsupported operator: ${node.operator}'); + } + } + + @override + dynamic visitGroupedExpression(GroupedExpression node) { + return node.expression.accept(this); + } + + @override + dynamic visitAssignment(Assignment node) { + final value = node.value.accept(this); + if (node.variable is Identifier) { + context.setVariable((node.variable as Identifier).name, value); + } else if (node.variable is MemberAccess) { + final memberAccess = node.variable as MemberAccess; + final objName = (memberAccess.object as Identifier).name; + + if (context.getVariable(objName) == null) { + context.setVariable(objName, {}); + } + + var objectVal = context(objName); + for (var i = 0; i < memberAccess.members.length; i++) { + final name = (memberAccess.members[i] as Identifier).name; + if (i == memberAccess.members.length - 1) { + objectVal[name] = value; + } else { + if (!(objectVal as Map).containsKey(memberAccess.members[i])) { + objectVal[name] = {}; + } + objectVal = objectVal[name]; + } + } + } + } + + @override + dynamic visitDocument(Document node) { + return evaluateNodes(node.children); + } + + @override + dynamic visitFilter(Filter node) { + final filterFunction = context.getFilter(node.name.name); + if (filterFunction == null) { + throw Exception('Undefined filter: ${node.name.name}'); + } + + final args = []; + final namedArgs = {}; + + for (final arg in node.arguments) { + if (arg is NamedArgument) { + namedArgs[arg.identifier.name] = arg.value.accept(this); + } else { + args.add(arg.accept(this)); + } + } + + return (value) => filterFunction(value, args, namedArgs); + } + + @override + dynamic visitFilterExpression(FilteredExpression node) { + dynamic value; + if (node.expression is Assignment) { + (node.expression as Assignment).value.accept(this); + if ((node.expression as Assignment).value is Literal) { + value = ((node.expression as Assignment).value as Literal).value; + } else { + value = context.getVariable( + ((node.expression as Assignment).value as Identifier).name); + } + } else { + value = node.expression.accept(this); + } + + for (final filter in node.filters) { + final filterFunction = filter.accept(this); + value = filterFunction(value); + } + return value; + } + + @override + dynamic visitMemberAccess(MemberAccess node) { + var object = node.object; + final objName = (object as Identifier).name; + var objectVal = context.getVariable(objName); + + if (objectVal == null) return null; + + for (final member in node.members) { + final keyName = member is Identifier + ? member.name + : ((member as ArrayAccess).array as Identifier).name; + final isArray = member is ArrayAccess; + + if (isArray) { + final key = (member.array as Identifier).name; + final index = (member.key as Literal).value; + objectVal = objectVal[key]; + + if (objectVal == null) return; + if (objectVal is List) { + if (index >= 0 && index < objectVal.length) { + objectVal = objectVal[index]; + } else { + return null; + } + } else { + objectVal = objectVal[index]; + } + } else if (objectVal is Drop) { + if (member is Identifier) { + objectVal = objectVal(Symbol(keyName)); + } + } else if (objectVal is List && member is Identifier) { + // Check if it's a registered dot notation filter + if (FilterRegistry.isDotNotationFilter(keyName)) { + final filterFunction = FilterRegistry.getFilter(keyName); + if (filterFunction != null) { + objectVal = filterFunction(objectVal, [], {}); + } + } + } else if (objectVal is Map) { + if (!objectVal.containsKey(keyName)) { + return null; + } + objectVal = objectVal[keyName]; + } else if (objectVal == null) { + return null; + } + } + return objectVal; + } + + @override + dynamic visitNamedArgument(NamedArgument node) { + return MapEntry(node.identifier.name, node.value.accept(this)); + } + + @override + dynamic visitTag(Tag node) { + final tag = TagRegistry.createTag(node.name, node.content, node.filters); + tag?.preprocess(this); + tag?.body = node.body; + tag?.evaluate(this, buffer); + } + + @override + dynamic visitTextNode(TextNode node) { + return node.text; + } + + @override + dynamic visitVariable(Variable node) { + return node.expression.accept(this); + } + + @override + visitArrayAccess(ArrayAccess arrayAccess) { + final array = arrayAccess.array.accept(this); + final key = arrayAccess.key.accept(this); + if (array is List) { + final index = key is int ? key : int.parse(key); + if (index >= 0 && index < array.length) { + return array[index]; + } + } else if (array is Map && array.containsKey(key)) { + return array[key]; + } + return null; + } + + @override + Future visitArrayAccessAsync(ArrayAccess arrayAccess) async { + final array = await arrayAccess.array.acceptAsync(this); + final key = await arrayAccess.key.acceptAsync(this); + if (array is List) { + final index = key is int ? key : int.parse(key); + if (index >= 0 && index < array.length) { + return array[index]; + } + } else if (array is Map && array.containsKey(key)) { + return array[key]; + } + return null; + } + + @override + Future visitAssignmentAsync(Assignment node) async { + final value = await node.value.acceptAsync(this); + if (node.variable is Identifier) { + context.setVariable((node.variable as Identifier).name, value); + } else if (node.variable is MemberAccess) { + final memberAccess = node.variable as MemberAccess; + final objName = (memberAccess.object as Identifier).name; + + if (context.getVariable(objName) == null) { + context.setVariable(objName, {}); + } + + var objectVal = context(objName); + for (var i = 0; i < memberAccess.members.length; i++) { + final name = (memberAccess.members[i] as Identifier).name; + if (i == memberAccess.members.length - 1) { + objectVal[name] = value; + } else { + if (!(objectVal as Map).containsKey(memberAccess.members[i])) { + objectVal[name] = {}; + } + objectVal = objectVal[name]; + } + } + } + } + + @override + Future visitBinaryOperationAsync(BinaryOperation node) async { + final left = await node.left.acceptAsync(this); + final right = await node.right.acceptAsync(this); + return _binaryOp(left, node.operator, right); + } + + @override + Future visitDocumentAsync(Document node) async { + return await evaluateNodesAsync(node.children); + } + + @override + Future visitFilterAsync(Filter node) async { + final filterFunction = context.getFilter(node.name.name); + if (filterFunction == null) { + throw Exception('Undefined filter: ${node.name.name}'); + } + final args = []; + final namedArgs = {}; + + for (final arg in node.arguments) { + if (arg is NamedArgument) { + namedArgs[arg.identifier.name] = await arg.value.acceptAsync(this); + } else { + args.add(await arg.acceptAsync(this)); + } + } + + return (value) => filterFunction(value, args, namedArgs); + } + + @override + Future visitFilterExpressionAsync(FilteredExpression node) async { + dynamic value; + if (node.expression is Assignment) { + await (node.expression as Assignment).value.acceptAsync(this); + if ((node.expression as Assignment).value is Literal) { + value = ((node.expression as Assignment).value as Literal).value; + } else { + value = context.getVariable( + ((node.expression as Assignment).value as Identifier).name); + } + } else { + value = await node.expression.acceptAsync(this); + } + + for (final filter in node.filters) { + final filterFunction = await filter.acceptAsync(this); + value = filterFunction(value); + } + return value; + } + + @override + Future visitGroupedExpressionAsync(GroupedExpression node) async { + return await node.expression.acceptAsync(this); + } + + @override + Future visitIdentifierAsync(Identifier node) async { + return context.getVariable(node.name); + } + + @override + Future visitLiteralAsync(Literal node) async { + return node.value; + } + + @override + Future visitMemberAccessAsync(MemberAccess node) async { + var object = node.object; + final objName = (object as Identifier).name; + var objectVal = context.getVariable(objName); + + if (objectVal == null) return null; + + for (final member in node.members) { + final keyName = member is Identifier + ? member.name + : ((member as ArrayAccess).array as Identifier).name; + final isArray = member is ArrayAccess; + + if (isArray) { + final key = (member.array as Identifier).name; + final index = (member.key as Literal).value; + objectVal = objectVal[key]; + + if (objectVal == null) return; + if (objectVal is List) { + if (index >= 0 && index < objectVal.length) { + objectVal = objectVal[index]; + } else { + return null; + } + } else { + objectVal = objectVal[index]; + } + } else if (objectVal is Drop) { + if (member is Identifier) { + objectVal = objectVal(Symbol(keyName)); + } + } else if (objectVal is List && member is Identifier) { + // Check if it's a registered dot notation filter + if (FilterRegistry.isDotNotationFilter(keyName)) { + final filterFunction = FilterRegistry.getFilter(keyName); + if (filterFunction != null) { + objectVal = await filterFunction(objectVal, [], {}); + } + } + } else if (objectVal is Map) { + if (!objectVal.containsKey(keyName)) { + return null; + } + objectVal = objectVal[keyName]; + } else if (objectVal == null) { + return null; + } + } + return objectVal; + } + + @override + Future visitNamedArgumentAsync(NamedArgument node) async { + return MapEntry(node.identifier.name, await node.value.acceptAsync(this)); + } + + @override + Future visitTagAsync(Tag node) async { + final tag = TagRegistry.createTag(node.name, node.content, node.filters); + tag?.preprocess(this); + tag?.body = node.body; + await tag?.evaluateAsync(this, buffer); + } + + @override + Future visitTextNodeAsync(TextNode node) async { + return node.text; + } + + @override + Future visitUnaryOperationAsync(UnaryOperation node) async { + final expr = await node.expression.acceptAsync(this); + switch (node.operator) { + case 'not': + case '!': + return !expr; + default: + throw UnsupportedError('Unsupported operator: ${node.operator}'); + } + } + + @override + Future visitVariableAsync(Variable node) async { + return node.expression.acceptAsync(this); + } +} diff --git a/lib/src/evaluator/layout.dart b/lib/src/evaluator/layout.dart new file mode 100644 index 0000000..dc3bd77 --- /dev/null +++ b/lib/src/evaluator/layout.dart @@ -0,0 +1,4 @@ +// These are no longer needed as we build the hierarchy at once +// _LayoutInfo? _extractLayoutInfo(List nodes) { ... } +// Future<_LayoutInfo?> _extractLayoutInfoAsync(List nodes) async { ... } +// } diff --git a/lib/src/evaluator/visit.dart b/lib/src/evaluator/visit.dart new file mode 100644 index 0000000..a294a5c --- /dev/null +++ b/lib/src/evaluator/visit.dart @@ -0,0 +1,218 @@ +part of 'evaluator.dart'; + +// extension SyncVisits on Evaluator { +// @override +// dynamic visitLiteral(Literal node) { +// return node.value; +// } +// +// @override +// dynamic visitIdentifier(Identifier node) { +// final value = context.getVariable(node.name); +// return value; +// } +// +// @override +// dynamic visitBinaryOperation(BinaryOperation node) { +// final left = node.left.accept(this); +// final right = node.right.accept(this); +// switch (node.operator) { +// case '+': return left + right; +// case '-': return left - right; +// case '*': return left * right; +// case '/': return left / right; +// case '==': return left == right; +// case '!=': return left != right; +// case '<': return left < right; +// case '>': return left > right; +// case '<=': return left <= right; +// case '>=': return left >= right; +// case 'and': return isTruthy(left) && isTruthy(right); +// case 'or': return isTruthy(left) || isTruthy(right); +// case '..': return List.generate(right - left + 1, (index) => left + index); +// case 'in': +// if (right is! Iterable) { +// throw Exception('Right side of "in" operator must be iterable.'); +// } +// return right.contains(left); +// default: +// throw UnsupportedError('Unsupported operator: ${node.operator}'); +// } +// } +// +// @override +// dynamic visitUnaryOperation(UnaryOperation node) { +// final expr = node.expression.accept(this); +// switch (node.operator) { +// case 'not': +// case '!': +// return !expr; +// default: +// throw UnsupportedError('Unsupported operator: ${node.operator}'); +// } +// } +// +// @override +// dynamic visitGroupedExpression(GroupedExpression node) { +// return node.expression.accept(this); +// } +// +// @override +// dynamic visitAssignment(Assignment node) { +// final value = node.value.accept(this); +// if (node.variable is Identifier) { +// context.setVariable((node.variable as Identifier).name, value); +// } else if (node.variable is MemberAccess) { +// final memberAccess = node.variable as MemberAccess; +// final objName = (memberAccess.object as Identifier).name; +// +// if (context.getVariable(objName) == null) { +// context.setVariable(objName, {}); +// } +// +// var objectVal = context(objName); +// for (var i = 0; i < memberAccess.members.length; i++) { +// final name = (memberAccess.members[i] as Identifier).name; +// if (i == memberAccess.members.length - 1) { +// objectVal[name] = value; +// } else { +// if (!(objectVal as Map).containsKey(memberAccess.members[i])) { +// objectVal[name] = {}; +// } +// objectVal = objectVal[name]; +// } +// } +// } +// } +// +// @override +// dynamic visitDocument(Document node) { +// return evaluateNodes(node.children); +// } +// +// @override +// dynamic visitFilter(Filter node) { +// final filterFunction = context.getFilter(node.name.name); +// if (filterFunction == null) { +// throw Exception('Undefined filter: ${node.name.name}'); +// } +// +// final args = []; +// final namedArgs = {}; +// +// for (final arg in node.arguments) { +// if (arg is NamedArgument) { +// namedArgs[arg.identifier.name] = arg.value.accept(this); +// } else { +// args.add(arg.accept(this)); +// } +// } +// +// return (value) => filterFunction(value, args, namedArgs); +// } +// +// @override +// dynamic visitFilterExpression(FilteredExpression node) { +// dynamic value; +// if (node.expression is Assignment) { +// (node.expression as Assignment).value.accept(this); +// if ((node.expression as Assignment).value is Literal) { +// value = ((node.expression as Assignment).value as Literal).value; +// } else { +// value = context.getVariable( +// ((node.expression as Assignment).value as Identifier).name); +// } +// } else { +// value = node.expression.accept(this); +// } +// +// for (final filter in node.filters) { +// final filterFunction = filter.accept(this); +// value = filterFunction(value); +// } +// return value; +// } +// +// @override +// dynamic visitMemberAccess(MemberAccess node) { +// var object = node.object; +// final objName = (object as Identifier).name; +// var objectVal = context.getVariable(objName); +// +// if (objectVal == null) return null; +// +// for (final member in node.members) { +// final keyName = member is Identifier +// ? member.name +// : ((member as ArrayAccess).array as Identifier).name; +// final isArray = member is ArrayAccess; +// +// if (isArray) { +// final key = (member.array as Identifier).name; +// final index = (member.key as Literal).value; +// objectVal = objectVal[key]; +// +// if (objectVal == null) return; +// if (objectVal is List) { +// if (index >= 0 && index < objectVal.length) { +// objectVal = objectVal[index]; +// } else { +// return null; +// } +// } else { +// objectVal = objectVal[index]; +// } +// } else if (objectVal is Drop) { +// if (member is Identifier) { +// objectVal = objectVal(Symbol(keyName)); +// } +// } else if (objectVal is Map) { +// if (!objectVal.containsKey(keyName)) { +// return null; +// } +// objectVal = objectVal[keyName]; +// } else if (objectVal == null) { +// return null; +// } +// } +// return objectVal; +// } +// +// @override +// dynamic visitNamedArgument(NamedArgument node) { +// return MapEntry(node.identifier.name, node.value.accept(this)); +// } +// +// @override +// dynamic visitTag(Tag node) { +// final tag = TagRegistry.createTag(node.name, node.content, node.filters); +// tag?.preprocess(this); +// tag?.body = node.body; +// tag?.evaluate(this, buffer); +// } +// +// @override +// dynamic visitTextNode(TextNode node) { +// return node.text; +// } +// +// @override +// dynamic visitVariable(Variable node) { +// return node.expression.accept(this); +// } +// +// @override +// visitArrayAccess(ArrayAccess arrayAccess) { +// final array = arrayAccess.array.accept(this); +// final key = arrayAccess.key.accept(this); +// if (array is List) { +// final index = key is int ? key : int.parse(key); +// if (index >= 0 && index < array.length) { +// return array[index]; +// } +// } else if (array is Map && array.containsKey(key)) { +// return array[key]; +// } +// return null; +// } +// } diff --git a/lib/src/evaluator/visit_async.dart b/lib/src/evaluator/visit_async.dart new file mode 100644 index 0000000..d6c74f5 --- /dev/null +++ b/lib/src/evaluator/visit_async.dart @@ -0,0 +1,3 @@ +part of 'evaluator.dart'; + +extension AsyncVisits on Evaluator {} diff --git a/lib/src/filter_registry.dart b/lib/src/filter_registry.dart index f1c12ab..e1bc4a5 100644 --- a/lib/src/filter_registry.dart +++ b/lib/src/filter_registry.dart @@ -27,8 +27,25 @@ class FilterRegistry { /// /// [name] The name of the filter to be registered. /// [function] The filter function to be associated with the given name. - static void register(String name, FilterFunction function) { + /// [dotNotation] If true, the filter can be used with dot notation. + static void register(String name, FilterFunction function, + {bool dotNotation = false}) { _filters[name] = function; + if (dotNotation) { + _dotNotationFilters.add(name); + } + } + + /// List of filters that can be used with dot notation. + static final Set _dotNotationFilters = {}; + + /// Checks if a filter can be used with dot notation. + /// + /// [name] The name of the filter to check. + /// + /// Returns true if the filter can be used with dot notation, false otherwise. + static bool isDotNotationFilter(String name) { + return _dotNotationFilters.contains(name); } /// Retrieves a filter function by its name. diff --git a/lib/src/filters/array.dart b/lib/src/filters/array.dart index a1b0ac8..02c30c8 100644 --- a/lib/src/filters/array.dart +++ b/lib/src/filters/array.dart @@ -1,5 +1,6 @@ import 'dart:math' as math; import 'package:liquify/src/filters/module.dart'; +import 'package:liquify/src/filter_registry.dart'; /// Converts the input value to uppercase. /// @@ -182,14 +183,16 @@ class ArrayModule extends Module { filters['lower'] = lower; filters['length'] = length; filters['join'] = join; - filters['first'] = first; - filters['last'] = last; filters['reverse'] = reverse; - filters['size'] = size; filters['sort'] = sort; filters['map'] = map; filters['where'] = where; filters['uniq'] = uniq; filters['slice'] = slice; + + // Register dot notation support for array methods + FilterRegistry.register('first', first, dotNotation: true); + FilterRegistry.register('last', last, dotNotation: true); + FilterRegistry.register('size', size, dotNotation: true); } } diff --git a/lib/src/tag_registry.dart b/lib/src/tag_registry.dart index d63466d..805bd29 100644 --- a/lib/src/tag_registry.dart +++ b/lib/src/tag_registry.dart @@ -47,6 +47,12 @@ class TagRegistry { /// Registers all built-in Liquid tags. void registerBuiltInTags() { + TagRegistry.register( + 'layout', (content, filters) => tags.LayoutTag(content, filters)); + TagRegistry.register( + 'super', (content, filters) => tags.SuperTag(content, filters)); + TagRegistry.register( + 'block', (content, filters) => tags.BlockTag(content, filters)); TagRegistry.register( 'echo', (content, filters) => tags.EchoTag(content, filters)); TagRegistry.register( diff --git a/lib/src/tags/block.dart b/lib/src/tags/block.dart new file mode 100644 index 0000000..6d1badc --- /dev/null +++ b/lib/src/tags/block.dart @@ -0,0 +1,40 @@ +import 'package:liquify/parser.dart'; + +/// A tag that defines a block in a template. Used with layout inheritance. +/// The block content is now handled by the analyzer and resolver. +class BlockTag extends AbstractTag with CustomTagParser { + late String name; + + BlockTag(super.content, super.filters); + + @override + void preprocess(Evaluator evaluator) { + if (content.isEmpty || content.first is! Identifier) { + throw Exception('BlockTag requires a name as first argument'); + } + name = (content.first as Identifier).name; + } + + @override + dynamic evaluateContent(Evaluator evaluator) { + // Block content is now handled by the analyzer and resolver + // This tag is only used for parsing and AST construction + return ''; + } + + @override + Parser parser() { + return ((tagStart() & + string('block').trim() & + ref0(identifier).trim() & + tagEnd()) & + ref0(element) + .starLazy(tagStart() & string('endblock').trim() & tagEnd()) & + (tagStart() & string('endblock').trim() & tagEnd())) + .map((values) { + final tag = + Tag('block', [values[2] as ASTNode], body: values[4].cast()); + return tag; + }); + } +} diff --git a/lib/src/tags/layout.dart b/lib/src/tags/layout.dart new file mode 100644 index 0000000..a9ecf08 --- /dev/null +++ b/lib/src/tags/layout.dart @@ -0,0 +1,136 @@ +import 'dart:math' show Random; + +import 'package:liquify/parser.dart'; +import 'package:liquify/src/analyzer/block_info.dart'; +import 'package:liquify/src/analyzer/resolver.dart'; +import 'package:liquify/src/util.dart'; + +import '../analyzer/template_analyzer.dart'; + +class LayoutTag extends AbstractTag with CustomTagParser, AsyncTag { + final Logger _logger = Logger('LayoutTag'); + late String layoutName; + + LayoutTag(super.content, super.filters); + + @override + dynamic evaluateWithContext(Evaluator evaluator, Buffer buffer) { + _logger.info('Starting layout evaluation'); + if (content.isEmpty) { + throw Exception('LayoutTag requires a name as first argument'); + } + if (content.first is Literal) { + layoutName = (content.first as Literal).value as String; + } else if (content.first is Identifier) { + layoutName = (content.first as Identifier).name; + } + + layoutName = (evaluator.evaluate(content.first)); + layoutName = evaluator.tmpResult(parseInput(layoutName)); + + return buildLayout(evaluator, layoutName.trim()); + } + + @override + Future evaluateWithContextAsync( + Evaluator evaluator, Buffer buffer) async { + _logger.info('Starting layout evaluation'); + if (content.first is Literal) { + layoutName = (content.first as Literal).value as String; + } else if (content.first is Identifier) { + layoutName = (content.first as Identifier).name; + } + + layoutName = await evaluator.tmpResultAsync(parseInput(layoutName)); + return buildLayout(evaluator, layoutName.trim()); + } + + @override + Parser parser() { + return ((tagStart() & + string('layout').trim() & + ref0(identifier).or(ref0(stringLiteral)).trim() & + ref0(namedArgument) + .star() + .starSeparated(char(',') | whitespace()) + .trim() & + tagEnd()) & + ref0(element).star()) + .map((values) { + final arguments = [values[2] as ASTNode]; + final elements = values[3].elements as List; + for (var i = 0; i < elements.length; i++) { + if (elements[i] is List) { + final list = elements[i] as List; + for (var j = 0; j < list.length; j++) { + final arg = list[j] as NamedArgument; + arguments.add(arg); + } + continue; + } + } + + final tag = Tag('layout', arguments, body: values[5].cast()); + return tag; + }); + } + + buildLayout(Evaluator evaluator, String layoutName) { + final layoutEvaluator = + evaluator.createInnerEvaluatorWithBuffer(evaluator.buffer); + layoutEvaluator.context.setRoot(evaluator.context.getRoot()); + layoutEvaluator.context.merge(evaluator.context.all()); + + // Process variables from layout tag arguments + for (final arg in content.whereType()) { + final value = evaluator.evaluate(arg.value); + _logger.info('Setting variable ${arg.identifier.name} = $value'); + layoutEvaluator.context.setVariable(arg.identifier.name, value); + } + // Create a template analyzer with the root from the context + final analyzer = TemplateAnalyzer(evaluator.context.getRoot()); + + // First analyze the layout template + final analysis = analyzer.analyzeTemplate(layoutName).last; + final layoutStructure = analysis.structures[layoutName]; + + if (layoutStructure == null) { + throw Exception('Failed to analyze layout template: $layoutName'); + } + + final blocks = + body.whereType().where((tag) => tag.name == 'block').map((tag) { + String source = "templ${Random().nextInt(1000)}.liquid"; + String name = ''; + if (tag.content.isNotEmpty && tag.content.first is Identifier) { + name = (tag.content.first as Identifier).name; + } else if (tag.content.isNotEmpty && tag.content.first is Literal) { + name = (tag.content.first as Literal).value as String; + } + return BlockInfo( + name: name, + source: source, + content: tag.body, + isOverride: true, + nestedBlocks: {}, + hasSuperCall: false); + }).fold>({}, (map, block) { + map[block.name] = block; + return map; + }); + + final mergedAst = + buildCompleteMergedAst(layoutStructure, overrides: blocks); + + // Evaluate the merged AST + _logger.info('Evaluating merged AST'); + for (final node in mergedAst) { + final result = layoutEvaluator.evaluate(node); + if (result != null) { + evaluator.buffer.write(result); + } + } + + _logger.info('Layout evaluation complete'); + } +} diff --git a/lib/src/tags/super_tag.dart b/lib/src/tags/super_tag.dart new file mode 100644 index 0000000..f9d6d53 --- /dev/null +++ b/lib/src/tags/super_tag.dart @@ -0,0 +1,27 @@ +import 'package:liquify/parser.dart'; + +/// A tag that represents a call to the parent block's content. +/// The super content is now handled by the analyzer and resolver. +class SuperTag extends AbstractTag with CustomTagParser { + SuperTag(super.content, super.filters); + + @override + dynamic evaluateContent(Evaluator evaluator) { + // Super content is now handled by the analyzer and resolver + // This tag is only used for parsing and AST construction + return ''; + } + + @override + Parser parser() { + // This matches syntax: {{ super() }} + return (varStart() & + string('super').trim() & + char('(').trim() & + char(')').trim() & + varEnd()) + .map((_) { + return Tag('super', []); + }); + } +} diff --git a/lib/src/tags/tags.dart b/lib/src/tags/tags.dart index 8b34a34..f2de7d6 100644 --- a/lib/src/tags/tags.dart +++ b/lib/src/tags/tags.dart @@ -15,3 +15,6 @@ export 'raw.dart' show RawTag; export 'render.dart' show RenderTag; export 'increment.dart' show IncrementTag; export 'decrement.dart' show DecrementTag; +export 'block.dart' show BlockTag; +export 'layout.dart' show LayoutTag; +export 'super_tag.dart' show SuperTag; diff --git a/lib/src/template.dart b/lib/src/template.dart index b07d3fb..757c9f4 100644 --- a/lib/src/template.dart +++ b/lib/src/template.dart @@ -1,6 +1,5 @@ import 'package:liquify/parser.dart' as parser; -import 'package:liquify/src/context.dart'; -import 'package:liquify/src/evaluator.dart'; +import 'package:liquify/parser.dart'; import 'package:liquify/src/fs.dart'; class Template { diff --git a/lib/src/util.dart b/lib/src/util.dart index 88eea0f..1256860 100644 --- a/lib/src/util.dart +++ b/lib/src/util.dart @@ -17,6 +17,19 @@ void printAST(ASTNode node, int indent) { printAST(child, indent + 1); } } else if (node is Tag) { + if (node.name == "layout") { + print('$indentStr Name: ${node.name}'); + print('$indentStr Content:'); + for (final child in node.content) { + printAST(child, indent + 2); + } + print('$indentStr Body:'); + for (final child in node.body) { + printAST(child, indent + 2); + } + return; + } + print('$indentStr Name: ${node.name}'); print('$indentStr Content:'); for (final child in node.content) { @@ -93,3 +106,116 @@ bool isTruthy(dynamic data) { data.type != LiteralType.boolean && data.value != false); } + +/// Recursively converts an [ASTNode] to a JSON-compatible map. +dynamic astToJson(ASTNode node) { + if (node is TextNode) { + return { + 'type': 'TextNode', + 'text': node.text, + }; + } else if (node is Tag) { + return { + 'type': 'Tag', + 'name': node.name, + 'content': node.content.map((child) => astToJson(child)).toList(), + 'body': node.body.map((child) => astToJson(child)).toList(), + }; + } else { + // Fallback: return the runtime type. + return {'type': node.runtimeType.toString()}; + } +} + +/// Converts a list of AST nodes to a JSON-compatible list. +List mergedAstToJson(List ast) => + ast.map((node) => astToJson(node)).toList(); + +class Logger { + final String context; + static final Map _enabledContexts = { + 'Analyzer': true, + 'Resolver': true, + }; + static final Map _indentLevels = {}; + static const String _indentChar = "│ "; + static const String _branchChar = "├─"; + static const String _lastBranchChar = "└─"; + + Logger(this.context); + + String _getIndentation([bool isLast = false]) { + final level = _indentLevels[context] ?? 0; + if (level == 0) return ''; + + final mainIndent = List.filled(level - 1, _indentChar).join(''); + final branchIndicator = isLast ? _lastBranchChar : _branchChar; + return '$mainIndent$branchIndicator '; + } + + void startScope(String message) { + if (_enabledContexts[context] ?? false) { + _indentLevels[context] = (_indentLevels[context] ?? 0) + 1; + if (_indentLevels[context] == 1) { + print('[$context]'); + } + print(_getIndentation() + message); + } + } + + void endScope([String? message]) { + if (_enabledContexts[context] ?? false) { + if (message != null) { + print(_getIndentation(true) + message); + } + _indentLevels[context] = (_indentLevels[context] ?? 1) - 1; + } + } + + void info(String message) { + if (_enabledContexts[context] ?? false) { + if (_indentLevels[context] == 0) { + print('[$context]'); + } + print(_getIndentation(true) + message); + } + } + + void warn(String message) { + if (_enabledContexts[context] ?? false) { + if (_indentLevels[context] == 0) { + print('[$context]'); + } + print('${_getIndentation(true)}WARN: $message'); + } + } + + void error(String message) { + if (_enabledContexts[context] ?? false) { + if (_indentLevels[context] == 0) { + print('[$context]'); + } + print('${_getIndentation(true)}ERROR: $message'); + } + } + + static void enableContext(String context) { + _enabledContexts[context] = true; + _indentLevels[context] = 0; + } + + static void disableContext(String context) { + _enabledContexts[context] = false; + _indentLevels.remove(context); + } + + static void enableAllContexts() { + _enabledContexts.updateAll((_, __) => true); + _indentLevels.clear(); + } + + static void disableAllContexts() { + _enabledContexts.updateAll((_, __) => false); + _indentLevels.clear(); + } +} diff --git a/pubspec.lock b/pubspec.lock index d558450..f0e296f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -18,13 +18,13 @@ packages: source: hosted version: "7.3.0" args: - dependency: transitive + dependency: "direct main" description: name: args - sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.6.0" async: dependency: transitive description: @@ -194,13 +194,13 @@ packages: source: hosted version: "5.1.1" logging: - dependency: transitive + dependency: "direct main" description: name: logging - sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" matcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3eacc2a..580fc23 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: liquify description: > A powerful and extensible Liquid template engine for Dart. Supports full Liquid syntax, custom tags and filters, and high-performance parsing and rendering. -version: 1.0.0-dev.1 +version: 1.0.0-dev.2 repository: https://github.com/kingwill101/liquify topics: @@ -23,6 +23,8 @@ dependencies: gato: ^0.0.5+1 file: ^7.0.1 path: ^1.9.1 + args: ^2.6.0 + logging: ^1.3.0 dev_dependencies: lints: ^5.1.1 diff --git a/test/analyzer/analyzer/deeply_nested_super_call_test.dart b/test/analyzer/analyzer/deeply_nested_super_call_test.dart new file mode 100644 index 0000000..d2a50de --- /dev/null +++ b/test/analyzer/analyzer/deeply_nested_super_call_test.dart @@ -0,0 +1,88 @@ +// File: test/analyzer/deeply_nested_super_call_test.dart +import 'package:liquify/parser.dart'; +import 'package:liquify/src/analyzer/template_analyzer.dart'; +import 'package:liquify/src/util.dart'; +import 'package:test/test.dart'; + +import '../../shared_test_root.dart'; + +void main() { + group('Deeply Nested Super Call', () { + late TestRoot root; + late TemplateAnalyzer analyzer; + + setUp(() { + // Configure logging - enable only analyzer logs + Logger.disableAllContexts(); + Logger.enableContext('Analyzer'); + root = TestRoot(); + analyzer = TemplateAnalyzer(root); + + // Grandparent template: defines "header" with a nested "navigation" block. + root.addFile('grandparent.liquid', ''' + {% block header %} +
+ {% block navigation %} +
    +
  • Grandparent Nav
  • +
+ {% endblock %} +
+ {% endblock %} + '''); + + // Parent template: extends grandparent and overrides the nested "navigation" block. + root.addFile('parent.liquid', ''' + {% layout 'grandparent.liquid' %} + {% block navigation %} +
    +
  • Parent Nav
  • +
+ {% endblock %} + '''); + + // Child template: extends parent and overrides the nested "navigation" block calling super(). + root.addFile('child.liquid', ''' + {% layout 'parent.liquid' %} + {% block navigation %} +
    +
  • Child Nav Before
  • + {{ super() }} +
  • Child Nav After
  • +
+ {% endblock %} + '''); + }); + + test('merges deeply nested super calls', () async { + final analysis = analyzer.analyzeTemplate('child.liquid').last; + final childStructure = analysis.structures['child.liquid']!; + final resolvedBlocks = childStructure.resolvedBlocks; + + // Since "navigation" is originally nested in "header", the final key for the overridden block is "header.navigation". + final navBlock = resolvedBlocks['header.navigation']; + expect(navBlock, isNotNull, + reason: 'header.navigation block should be present.'); + expect(navBlock!.source, equals('child.liquid'), + reason: 'Child override should be used for the nested block.'); + expect(navBlock.isOverride, isTrue, + reason: 'The nested block override must be marked as an override.'); + expect(navBlock.hasSuperCall, isTrue, + reason: 'The nested block should detect a super() call.'); + + // Verify that the nested block's parent is set and comes from parent.liquid. + expect(navBlock.parent, isNotNull, + reason: 'The deeply nested override should have a parent block.'); + expect(navBlock.parent!.source, equals('parent.liquid'), + reason: + 'The parent block for the nested override should be from parent.liquid.'); + + // Additionally, check that at least one node in the block's content is a Tag named "super". + bool foundSuper = + (navBlock.content ?? []).any((n) => n is Tag && n.name == 'super'); + expect(foundSuper, isTrue, + reason: + 'The deeply nested block content should include a super() call tag.'); + }); + }); +} diff --git a/test/analyzer/analyzer/layout_analyzer_test.dart b/test/analyzer/analyzer/layout_analyzer_test.dart new file mode 100644 index 0000000..4e0f247 --- /dev/null +++ b/test/analyzer/analyzer/layout_analyzer_test.dart @@ -0,0 +1,69 @@ +import 'package:liquify/src/analyzer/template_analyzer.dart'; +import 'package:liquify/src/util.dart'; +import 'package:test/test.dart'; + +import '../../shared_test_root.dart'; + +void main() { + group('Layout Analyzer', () { + late TestRoot root; + late TemplateAnalyzer analyzer; + + setUp(() { + // Configure logging - enable only analyzer logs + Logger.disableAllContexts(); + Logger.enableContext('Analyzer'); + root = TestRoot(); + analyzer = TemplateAnalyzer(root); + + // Base template with simple blocks. + root.addFile('base.liquid', ''' + + + + {% block head %} + Base Title + {% endblock %} + + + {% block content %}{% endblock %} + + + '''); + + // Child template that extends the base. + root.addFile('child.liquid', ''' + {% layout 'base.liquid' %} + + {% block head %} + Child Title + {% endblock %} + + {% block content %} +

Child content

+ {% endblock %} + '''); + }); + + test('analyzes layout inheritance', () async { + final analysis = analyzer.analyzeTemplate('child.liquid').last; + expect(analysis.structures.containsKey('child.liquid'), isTrue); + + final childStructure = analysis.structures['child.liquid']!; + final resolvedBlocks = childStructure.resolvedBlocks; + + // Check for expected block keys. + expect(resolvedBlocks.keys, containsAll(['head', 'content'])); + + // The child overrides the head block from the base. + final headBlock = resolvedBlocks['head']; + expect(headBlock, isNotNull); + expect(headBlock!.isOverride, isTrue); + + // The content block is defined only in the child. + final contentBlock = resolvedBlocks['content']; + expect(contentBlock, isNotNull); + expect(contentBlock!.isOverride, isTrue); + }); + }); +} diff --git a/test/analyzer/analyzer/multilevel_layout_test.dart b/test/analyzer/analyzer/multilevel_layout_test.dart new file mode 100644 index 0000000..359c513 --- /dev/null +++ b/test/analyzer/analyzer/multilevel_layout_test.dart @@ -0,0 +1,80 @@ +// File: test/analyzer/multilevel_layout_test.dart +import 'package:liquify/src/analyzer/template_analyzer.dart'; +import 'package:liquify/src/util.dart'; +import 'package:test/test.dart'; + +import '../../shared_test_root.dart'; + +void main() { + group('Multi-Level Inheritance', () { + late TestRoot root; + late TemplateAnalyzer analyzer; + + setUp(() { + // Configure logging - enable only analyzer logs + Logger.disableAllContexts(); + Logger.enableContext('Analyzer'); + root = TestRoot(); + analyzer = TemplateAnalyzer(root); + + // Grandparent template with base blocks. + root.addFile('grandparent.liquid', ''' + {% block header %} +

Grandparent Header

+ {% endblock %} + {% block content %} +

Grandparent Content

+ {% endblock %} + '''); + + // Parent template that extends grandparent. + root.addFile('parent.liquid', ''' + {% layout 'grandparent.liquid' %} + {% block header %} +

Parent Header

+ {% endblock %} + {% block content %} +

Parent Content

+ {% endblock %} + '''); + + // Child template that extends parent. + root.addFile('child.liquid', ''' + {% layout 'parent.liquid' %} + {% block header %} +

Child Header

+ {% endblock %} + {% block footer %} +
Child Footer
+ {% endblock %} + '''); + }); + + test('analyzes multi-level inheritance', () async { + final analysis = analyzer.analyzeTemplate('child.liquid').last; + expect(analysis.structures.containsKey('child.liquid'), isTrue); + + final childStructure = analysis.structures['child.liquid']!; + final resolvedBlocks = childStructure.resolvedBlocks; + + // Child header should override parent's (and inherit parent's parent). + expect(resolvedBlocks['header'], isNotNull); + expect(resolvedBlocks['header']!.source, equals('child.liquid')); + expect(resolvedBlocks['header']!.isOverride, isTrue); + + // Parent content should be inherited because the child didn't override it. + expect(resolvedBlocks['content'], isNotNull); + // Depending on design, if the parent's block remains and is not overridden, + // its source may still be 'parent.liquid'. In our current design, however, + // a redefinition in the parent is marked as override in the child structure. + // Adjust the expectation based on your intended behavior. + expect(resolvedBlocks['content']!.source, equals('parent.liquid')); + expect(resolvedBlocks['content']!.isOverride, isTrue); + + // The footer is defined only in the child. + expect(resolvedBlocks['footer'], isNotNull); + expect(resolvedBlocks['footer']!.source, equals('child.liquid')); + expect(resolvedBlocks['footer']!.isOverride, isTrue); + }); + }); +} diff --git a/test/analyzer/analyzer/nested_blocks_test.dart b/test/analyzer/analyzer/nested_blocks_test.dart new file mode 100644 index 0000000..388a16c --- /dev/null +++ b/test/analyzer/analyzer/nested_blocks_test.dart @@ -0,0 +1,64 @@ +import 'package:liquify/src/analyzer/template_analyzer.dart'; +import 'package:liquify/src/util.dart'; +import 'package:test/test.dart'; + +import '../../shared_test_root.dart'; + +void main() { + group('Nested Blocks Analysis', () { + late TestRoot root; + late TemplateAnalyzer analyzer; + + setUp(() { + // Configure logging - enable only analyzer logs + Logger.disableAllContexts(); + Logger.enableContext('Analyzer'); + root = TestRoot(); + analyzer = TemplateAnalyzer(root); + + // Base template with a nested block. + root.addFile('base.liquid', ''' + {% block header %} +
+ {% block navigation %} +
  • Base Navigation
+ {% endblock %} +
+ {% endblock %} + '''); + + // Child template extends the base and overrides the nested "navigation" block. + root.addFile('child.liquid', ''' + {% layout 'base.liquid' %} + + {% block navigation %} +
  • Child Navigation
+ {% endblock %} + '''); + }); + + test('analyzes nested blocks', () async { + final analysis = analyzer.analyzeTemplate('child.liquid').last; + expect(analysis.structures.containsKey('child.liquid'), isTrue); + + final childStructure = analysis.structures['child.liquid']!; + final resolvedBlocks = childStructure.resolvedBlocks; + + // Expect the childStructure to contain the "header" block and a nested block "header.navigation". + expect(resolvedBlocks.keys, containsAll(['header', 'header.navigation'])); + + // The header block should originate from the base template. + final headerBlock = resolvedBlocks['header']; + expect(headerBlock, isNotNull); + expect(headerBlock!.source, equals('base.liquid')); + expect(headerBlock.nestedBlocks, isNotEmpty); + expect(headerBlock.nestedBlocks, contains('navigation')); + + // The nested "navigation" block (as "header.navigation") should be overridden by the child. + final navigationBlock = resolvedBlocks['header.navigation']; + expect(navigationBlock, isNotNull); + expect(navigationBlock!.source, equals('child.liquid')); + expect(navigationBlock.isOverride, isTrue); + }); + }); +} diff --git a/test/analyzer/analyzer/super_call_test.dart b/test/analyzer/analyzer/super_call_test.dart new file mode 100644 index 0000000..1c1c861 --- /dev/null +++ b/test/analyzer/analyzer/super_call_test.dart @@ -0,0 +1,67 @@ +import 'package:liquify/parser.dart'; +import 'package:liquify/src/analyzer/template_analyzer.dart'; +import 'package:liquify/src/util.dart'; +import 'package:test/test.dart'; + +import '../../shared_test_root.dart'; + +void main() { + group('Super Call Analysis', () { + late TestRoot root; + late TemplateAnalyzer analyzer; + + setUp(() { + // Configure logging - enable only analyzer logs + Logger.disableAllContexts(); + Logger.enableContext('Analyzer'); + root = TestRoot(); + analyzer = TemplateAnalyzer(root); + + // Base template defines a block with some content. + root.addFile('base.liquid', ''' + {% block content %} +

Base Content

+ {% endblock %} + '''); + + // Child template overrides it and calls super(). + root.addFile('child.liquid', ''' + {% layout 'base.liquid' %} + {% block content %} +
Child Content Before
+ {{ super() }} +
Child Content After
+ {% endblock %} + '''); + }); + + test('merges parent block content with super()', () async { + final analysis = analyzer.analyzeTemplate('child.liquid').last; + final childStructure = analysis.structures['child.liquid']!; + final resolvedBlocks = childStructure.resolvedBlocks; + + // For example, we expect the child template's "content" block + // (possibly flattened as just "content" if no nesting is required) + // to merge the child's and parent's content. + final contentBlock = resolvedBlocks['content']; + expect(contentBlock, isNotNull); + expect(contentBlock!.source, equals('child.liquid')); + + // Verify that the block was detected as an override and contains a super() call. + expect(contentBlock.hasSuperCall, isTrue); + expect(contentBlock.isOverride, isTrue); + expect(contentBlock.parent, isNotNull); + expect(contentBlock.parent!.source, equals('base.liquid')); + + // Additionally, check that at least one node in the block's content is a Tag named "super". + bool foundSuper = (contentBlock.content ?? []) + .any((n) => n is Tag && n.name == 'super'); + expect(foundSuper, isTrue, + reason: 'The block content should include a super() call tag.'); + + // This test will eventually verify that the rendered output is: + // "
Child Content Before

Base Content

Child Content After
" + // Once your evaluator supports super(). + }); + }); +} diff --git a/test/analyzer/resolver/ast_matcher.dart b/test/analyzer/resolver/ast_matcher.dart new file mode 100644 index 0000000..06a09c2 --- /dev/null +++ b/test/analyzer/resolver/ast_matcher.dart @@ -0,0 +1,67 @@ +import 'package:liquify/parser.dart'; +import 'package:test/test.dart'; + +/// A utility class for building matchers to validate AST structures +class ASTMatcher { + /// Matches a Tag node with the given name and optional content/body matchers + static Matcher tag( + String name, { + List? content, + List? body, + }) { + return isA() + .having((t) => t.name, 'name', equals(name)) + .having( + (t) => t.content, + 'content', + content == null ? anything : containsAll(content), + ) + .having( + (t) => t.body, + 'body', + body == null ? anything : containsAll(body), + ); + } + + /// Matches a TextNode with the given text + static Matcher text(String text) { + return isA().having((t) => t.text, 'text', contains(text)); + } + + /// Matches literal content + static Matcher literal(dynamic value) { + return isA().having((l) => l.value, 'value', contains(value)); + } + + /// Matches an identifier with the given name + static Matcher identifier(String name) { + return isA().having((i) => i.name, 'name', contains(name)); + } + + /// Validates that the AST contains nodes matching all the given matchers + static void validateAST(List ast, List matchers) { + for (var matcher in matchers) { + expect( + ast, + contains(matcher), + reason: 'AST should contain node matching: $matcher', + ); + } + } + + /// Helper to find a specific block in the AST by name + static Tag? findBlock(List ast, String blockName) { + for (var node in ast) { + if (node is Tag && node.name == 'block') { + // Check if this block's content contains the target name + for (var content in node.content) { + if (content is Identifier && content.name == blockName || + content is Literal && content.value.toString() == blockName) { + return node; + } + } + } + } + return null; + } +} diff --git a/test/analyzer/resolver/complete_layout_merge_test.dart b/test/analyzer/resolver/complete_layout_merge_test.dart new file mode 100644 index 0000000..2db8cf1 --- /dev/null +++ b/test/analyzer/resolver/complete_layout_merge_test.dart @@ -0,0 +1,173 @@ +import 'package:liquify/src/analyzer/template_analyzer.dart'; +import 'package:liquify/src/analyzer/resolver.dart'; +import 'package:liquify/src/util.dart'; +import 'package:test/test.dart'; + +import '../../shared_test_root.dart'; + +void main() { + group('Complete Layout Merge', () { + late TestRoot root; + late TemplateAnalyzer analyzer; + + setUp(() { + // Configure logging - enable only resolver logs + Logger.disableAllContexts(); + Logger.enableContext('Resolver'); + root = TestRoot(); + analyzer = TemplateAnalyzer(root); + + // Base template: defines a complete layout with several blocks. + root.addFile('base.liquid', ''' + + + + {% block title %}Default Title{% endblock %} + {% block styles %}{% endblock %} + + +
+ {% block header %}Default Header{% endblock %} +
+
+ {% block content %}Default Content{% endblock %} +
+
+ {% block footer %}Default Footer{% endblock %} +
+ + + '''); + + // Parent template: extends base.liquid and overrides the header. + // It defines a nested 'navigation' block inside the header. + root.addFile('parent.liquid', ''' + {% layout 'base.liquid' %} + {% block header %} +
+ {% block navigation %} + + {% endblock %} +

Parent Header

+
+ {% endblock %} + '''); + + // Child template: extends parent.liquid and overrides title, content, + // and the nested navigation block (calling super()) + root.addFile('child.liquid', ''' + {% layout 'parent.liquid' %} + {% block title %}Child Title{% endblock %} + {% block content %} +
+

Child Customized Content

+
+ {% endblock %} + {% block navigation %} + + {% endblock %} + '''); + }); + + test('merges complete layout structure', () async { + // Analyze the child template. + final analysis = analyzer.analyzeTemplate('child.liquid').last; + expect(analysis.structures.containsKey('child.liquid'), isTrue); + + final childStructure = analysis.structures['child.liquid']!; + + // Verify the template structure first + expect(childStructure.templatePath, equals('child.liquid')); + expect(childStructure.parent?.templatePath, equals('parent.liquid')); + expect( + childStructure.parent?.parent?.templatePath, equals('base.liquid')); + + // Verify block structure + expect(childStructure.blocks.length, equals(3)); + expect(childStructure.blocks['title']?.source, equals('child.liquid')); + expect(childStructure.blocks['title']?.isOverride, isTrue); + expect(childStructure.blocks['content']?.source, equals('child.liquid')); + expect(childStructure.blocks['content']?.isOverride, isTrue); + expect(childStructure.blocks['header.navigation']?.source, + equals('child.liquid')); + expect(childStructure.blocks['header.navigation']?.isOverride, isTrue); + expect(childStructure.blocks['header.navigation']?.hasSuperCall, isTrue); + + // Build and verify the merged AST + final mergedAst = buildCompleteMergedAst(childStructure); + + // Convert to string for easier verification + final mergedText = mergedAst.map((node) => node.toString()).join(''); + + // Verify the merged content + expect(mergedText, contains('Child Title')); // Child's title override + expect( + mergedText, + contains( + '')); // Base styles preserved + expect(mergedText, + contains('Child Nav Before')); // Child's navigation prefix + expect( + mergedText, + contains( + 'Default Navigation')); // Parent's navigation (from super call) + expect( + mergedText, contains('Child Nav After')); // Child's navigation suffix + expect(mergedText, + contains('Child Customized Content')); // Child's content override + expect(mergedText, contains('Default Footer')); // Base footer preserved + + // Verify the order of elements + final lines = mergedText + .split('\n') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + expect( + lines, + containsAllInOrder([ + '', + '', + '', + 'Child Title', + '', + '', + '', + '
', + '
', + '', + '

Parent Header

', + '
', + '
', + '
', + '
', + '

Child Customized Content

', + '
', + '
', + '
', + 'Default Footer', + '
', + '', + '' + ])); + + // For debugging purposes, print the merged AST + print('Merged AST nodes:'); + for (var node in mergedAst) { + print(' ${node.runtimeType}: $node'); + } + }); + }); +} diff --git a/test/analyzer/resolver/deeply_nested_super_merge_test.dart b/test/analyzer/resolver/deeply_nested_super_merge_test.dart new file mode 100644 index 0000000..2ecb575 --- /dev/null +++ b/test/analyzer/resolver/deeply_nested_super_merge_test.dart @@ -0,0 +1,89 @@ +import 'package:liquify/src/analyzer/resolver.dart'; +import 'package:liquify/src/analyzer/template_analyzer.dart'; +import 'package:test/test.dart'; +import '../../shared_test_root.dart'; +import 'ast_matcher.dart'; +import 'package:liquify/src/util.dart'; + +void main() { + group('Deeply Nested Super Merge', () { + late TestRoot root; + late TemplateAnalyzer analyzer; + + setUp(() { + // Configure logging - enable only resolver logs + Logger.disableAllContexts(); + Logger.enableContext('Resolver'); + root = TestRoot(); + analyzer = TemplateAnalyzer(root); + + // Grandparent template: defines header with nested navigation. + root.addFile('grandparent.liquid', ''' + + + + {% block title %}Grandparent Title{% endblock %} + + +
+ {% block header %} +
Grandparent Header
+ {% block navigation %} + + {% endblock %} + {% endblock %} +
+
+ {% block content %}Grandparent Content{% endblock %} +
+
+ {% block footer %}Grandparent Footer{% endblock %} +
+ + + '''); + + // Parent template: extends grandparent and overrides navigation. + root.addFile('parent.liquid', ''' + {% layout 'grandparent.liquid' %} + {% block navigation %} + + {% endblock %} + '''); + + // Child template: extends parent and overrides navigation, calling super(). + root.addFile('child.liquid', ''' + {% layout 'parent.liquid' %} + {% block navigation %} + + {% endblock %} + '''); + }); + + test('merged AST reflects deep nesting with super call override', () async { + final analysis = analyzer.analyzeTemplate('child.liquid').last; + final structure = analysis.structures['child.liquid']!; + final mergedAst = buildCompleteMergedAst(structure); + + ASTMatcher.validateAST(mergedAst, [ + ASTMatcher.text(''), + ASTMatcher.text('Grandparent Title'), + ASTMatcher.text('Grandparent Header'), + ASTMatcher.text('Child Nav Before'), + // In our resolution of super(), we expect the parent's override (from parent.liquid) + // to be injected. Parent's navigation content is supposed to be "". + // So we expect "Parent Nav" to appear. + ASTMatcher.text('Parent Nav'), + ASTMatcher.text('Child Nav After'), + ASTMatcher.text('Grandparent Content'), + ASTMatcher.text(''), + ]); + }); + }); +} diff --git a/test/analyzer/resolver/multi_level_inheritance_merge_test.dart b/test/analyzer/resolver/multi_level_inheritance_merge_test.dart new file mode 100644 index 0000000..ce52a0f --- /dev/null +++ b/test/analyzer/resolver/multi_level_inheritance_merge_test.dart @@ -0,0 +1,62 @@ +import 'package:liquify/src/analyzer/template_analyzer.dart'; +import 'package:liquify/src/analyzer/resolver.dart'; +import 'package:liquify/src/util.dart'; +import 'package:test/test.dart'; + +import '../../shared_test_root.dart'; +import 'ast_matcher.dart'; + +void main() { + group('Multi-Level Inheritance Merge', () { + late TestRoot root; + late TemplateAnalyzer analyzer; + + setUp(() { + // Disable analyzer logging and enable only resolver logging + Logger.disableAllContexts(); + Logger.enableContext('Resolver'); + root = TestRoot(); + analyzer = TemplateAnalyzer(root); + + // Grandparent template: complete layout with blocks 'title' and 'content'. + root.addFile('grandparent.liquid', ''' + + + + {% block title %}Grandparent Title{% endblock %} + + + {% block content %}Grandparent Content{% endblock %} + + + '''); + + // Parent template: extends grandparent and overrides the title. + root.addFile('parent.liquid', ''' + {% layout 'grandparent.liquid' %} + {% block title %}Parent Title{% endblock %} + '''); + + // Child template: extends parent and overrides the content. + root.addFile('child.liquid', ''' + {% layout 'parent.liquid' %} + {% block content %}Child Content{% endblock %} + '''); + }); + + test('merged AST combines multi-level inheritance correctly', () async { + final analysis = analyzer.analyzeTemplate('child.liquid').last; + final structure = analysis.structures['child.liquid']!; + final mergedAst = buildCompleteMergedAst(structure); + + ASTMatcher.validateAST(mergedAst, [ + ASTMatcher.text(''), + // In a multi-level scenario, grandparent defines title as "Grandparent Title", + // parent overrides title with "Parent Title", and child overrides content. + ASTMatcher.text('Parent Title'), + ASTMatcher.text('Child Content'), + ASTMatcher.text(''), + ]); + }); + }); +} diff --git a/test/analyzer/resolver/nested_merge_test.dart b/test/analyzer/resolver/nested_merge_test.dart new file mode 100644 index 0000000..1185fb5 --- /dev/null +++ b/test/analyzer/resolver/nested_merge_test.dart @@ -0,0 +1,71 @@ +import 'package:liquify/liquify.dart'; +import 'package:liquify/src/analyzer/resolver.dart'; +import 'package:liquify/src/analyzer/template_analyzer.dart'; +import 'package:test/test.dart'; +import '../../shared_test_root.dart'; +import 'ast_matcher.dart'; +import 'package:liquify/src/util.dart'; + +void main() { + group('Nested Merge', () { + late TestRoot root; + late TemplateAnalyzer analyzer; + + setUp(() { + // Configure logging - enable only resolver logs + Logger.disableAllContexts(); + Logger.enableContext('Resolver'); + root = TestRoot(); + analyzer = TemplateAnalyzer(root); + + // Parent template: defines layout with a header block that contains a nested 'navigation' block. + root.addFile('parent.liquid', ''' + + + + {% block title %}Default Title{% endblock %} + + +
+ {% block header %} +
Default Header
+ {% block navigation %}Default Navigation{% endblock %} + {% endblock %} +
+
+ {% block content %}Default Content{% endblock %} +
+ + + '''); + + // Child template: overrides the 'navigation' block. + root.addFile('child.liquid', ''' + {% layout 'parent.liquid' %} + {% block navigation %}Overridden Navigation{% endblock %} + '''); + }); + + test('merged AST injects nested block override', () async { + final analysis = analyzer.analyzeTemplate('child.liquid').last; + final structure = analysis.structures['child.liquid']!; + final mergedAst = buildCompleteMergedAst(structure); + + ASTMatcher.validateAST(mergedAst, [ + ASTMatcher.text(''), + ASTMatcher.text(''), + ASTMatcher.text(''), + ASTMatcher.text(''), + ASTMatcher.text('
'), + ASTMatcher.text('
'), + ASTMatcher.text('Default Title'), + ASTMatcher.text('Default Header'), + ASTMatcher.text('Overridden Navigation'), + ASTMatcher.text('Default Content'), + ASTMatcher.text(''), + ASTMatcher.text(''), + ASTMatcher.text(''), + ]); + }); + }); +} diff --git a/test/analyzer/resolver/simple_inheritance_merge_test.dart b/test/analyzer/resolver/simple_inheritance_merge_test.dart new file mode 100644 index 0000000..4b3ae11 --- /dev/null +++ b/test/analyzer/resolver/simple_inheritance_merge_test.dart @@ -0,0 +1,53 @@ +import 'package:liquify/src/analyzer/template_analyzer.dart'; +import 'package:liquify/src/analyzer/resolver.dart'; +import 'package:liquify/src/util.dart'; +import 'package:test/test.dart'; +import '../../shared_test_root.dart'; + +void main() { + group('Simple Inheritance Merge', () { + late TestRoot root; + late TemplateAnalyzer analyzer; + + setUp(() { + // Configure logging - enable only resolver logs + Logger.disableAllContexts(); + Logger.enableContext('Resolver'); + root = TestRoot(); + analyzer = TemplateAnalyzer(root); + + // Parent template: a basic layout with two blocks. + root.addFile('parent.liquid', ''' + + + + {% block title %}Default Title{% endblock %} + + + {% block content %}Default Content{% endblock %} + + + '''); + + // Child template: extends parent.liquid and overrides both blocks. + root.addFile('child.liquid', ''' + {% layout 'parent.liquid' %} + {% block title %}Overridden Title{% endblock %} + {% block content %}Overridden Content{% endblock %} + '''); + }); + + test('merged AST contains full layout with overridden blocks', () async { + final analysis = analyzer.analyzeTemplate('child.liquid').last; + final structure = analysis.structures['child.liquid']!; + + // Build the complete merged AST (i.e. parent's raw AST with child overrides applied) + final mergedAst = buildCompleteMergedAst(structure); + final mergedText = mergedAst.map((node) => node.toString()).join(); + + expect(mergedText, contains('')); + expect(mergedText, contains('Overridden Title')); + expect(mergedText, contains('Overridden Content')); + }); + }); +} diff --git a/test/analyzer/resolver/simple_layout_merge_test.dart b/test/analyzer/resolver/simple_layout_merge_test.dart new file mode 100644 index 0000000..0371a46 --- /dev/null +++ b/test/analyzer/resolver/simple_layout_merge_test.dart @@ -0,0 +1,54 @@ +import 'package:liquify/src/analyzer/template_analyzer.dart'; +import 'package:liquify/src/analyzer/resolver.dart'; +import 'package:liquify/src/util.dart'; +import 'package:test/test.dart'; + +import '../../shared_test_root.dart'; +import 'ast_matcher.dart'; + +void main() { + group('Simple Layout Merge', () { + late TestRoot root; + late TemplateAnalyzer analyzer; + + setUp(() { + // Configure logging - enable only resolver logs + Logger.disableAllContexts(); + Logger.enableContext('Resolver'); + root = TestRoot(); + analyzer = TemplateAnalyzer(root); + + // Parent template defines a basic layout with two blocks. + root.addFile('parent.liquid', ''' + + + + {% block title %}Default Title{% endblock %} + + + {% block content %}Default Content{% endblock %} + + + '''); + + // Child template extends parent.liquid and overrides both blocks. + root.addFile('child.liquid', ''' + {% layout 'parent.liquid' %} + {% block title %}Overridden Title{% endblock %} + {% block content %}Overridden Content{% endblock %} + '''); + }); + + test('merges single-level inheritance correctly', () async { + final analysis = analyzer.analyzeTemplate('child.liquid').last; + final structure = analysis.structures['child.liquid']!; + final mergedAst = buildCompleteMergedAst(structure); + + ASTMatcher.validateAST(mergedAst, [ + ASTMatcher.text(''), + ASTMatcher.text('Overridden Title'), + ASTMatcher.text('Overridden Content') + ]); + }); + }); +} diff --git a/test/analyzer/resolver/super_tag_merge_test.dart b/test/analyzer/resolver/super_tag_merge_test.dart new file mode 100644 index 0000000..c40275a --- /dev/null +++ b/test/analyzer/resolver/super_tag_merge_test.dart @@ -0,0 +1,57 @@ +import 'package:liquify/src/analyzer/template_analyzer.dart'; +import 'package:liquify/src/analyzer/resolver.dart'; +import 'package:liquify/src/util.dart'; +import 'package:test/test.dart'; +import '../../shared_test_root.dart'; +import 'ast_matcher.dart'; + +void main() { + group('Super Tag Merge', () { + late TestRoot root; + late TemplateAnalyzer analyzer; + + setUp(() { + // Configure logging - enable only resolver logs + Logger.disableAllContexts(); + Logger.enableContext('Resolver'); + root = TestRoot(); + analyzer = TemplateAnalyzer(root); + + // Parent template: defines a content block. + root.addFile('parent.liquid', ''' + + + + {% block content %} +

Parent Content

+ {% endblock %} + + + '''); + + // Child template: overrides content block and calls super(). + root.addFile('child.liquid', ''' + {% layout 'parent.liquid' %} + {% block content %} +
Child Before
+ {{ super() }} +
Child After
+ {% endblock %} + '''); + }); + + test('merged AST reflects super() call override', () async { + final analysis = analyzer.analyzeTemplate('child.liquid').last; + final structure = analysis.structures['child.liquid']!; + final mergedAst = buildCompleteMergedAst(structure); + + ASTMatcher.validateAST(mergedAst, [ + ASTMatcher.text('Child Before'), + // In this scenario, parent's content is "

Parent Content

" + ASTMatcher.text('Parent Content'), + ASTMatcher.text('Child After'), + // The merged AST should not include a literal "super" but have replaced it. + ]); + }); + }); +} diff --git a/test/evaluator_test.dart b/test/evaluator_test.dart index 476bf5c..ab372d8 100644 --- a/test/evaluator_test.dart +++ b/test/evaluator_test.dart @@ -13,6 +13,91 @@ void main() { evaluator = Evaluator(Environment()); }); + group('Dot notation filters', () { + group("dot notation filters sync", () { + test('first, last and size', () { + evaluator.context.setVariable('company', { + 'departments': { + 'engineering': { + 'employees': [ + {'name': 'Charlie', 'role': 'Developer'}, + {'name': 'Alice', 'role': 'Manager'}, + {'name': 'Bob', 'role': 'Designer'} + ] + } + } + }); + expect( + evaluator.evaluate(MemberAccess(Identifier('company'), [ + Identifier('departments'), + Identifier('engineering'), + Identifier('employees'), + Identifier('first'), + Identifier('name'), + ])), + 'Charlie'); + expect( + evaluator.evaluate(MemberAccess(Identifier('company'), [ + Identifier('departments'), + Identifier('engineering'), + Identifier('employees'), + Identifier('last'), + Identifier('name'), + ])), + 'Bob'); + expect( + evaluator.evaluate(MemberAccess(Identifier('company'), [ + Identifier('departments'), + Identifier('engineering'), + Identifier('employees'), + Identifier('size'), + ])), + 3); + }); + }); + + group("dot notation filters async", () { + test('first, last and size async', () async { + evaluator.context.setVariable('company', { + 'departments': { + 'engineering': { + 'employees': [ + {'name': 'Charlie', 'role': 'Developer'}, + {'name': 'Alice', 'role': 'Manager'}, + {'name': 'Bob', 'role': 'Designer'} + ] + } + } + }); + expect( + await evaluator.evaluateAsync(MemberAccess(Identifier('company'), [ + Identifier('departments'), + Identifier('engineering'), + Identifier('employees'), + Identifier('first'), + Identifier('name'), + ])), + 'Charlie'); + expect( + await evaluator.evaluateAsync(MemberAccess(Identifier('company'), [ + Identifier('departments'), + Identifier('engineering'), + Identifier('employees'), + Identifier('last'), + Identifier('name'), + ])), + 'Bob'); + expect( + await evaluator.evaluateAsync(MemberAccess(Identifier('company'), [ + Identifier('departments'), + Identifier('engineering'), + Identifier('employees'), + Identifier('size'), + ])), + 3); + }); + }); + }); group('Evaluator', () { test('evaluates literals', () { expect(evaluator.evaluate(Literal(5, LiteralType.number)), 5); diff --git a/test/layout_test2.dart b/test/layout_test2.dart new file mode 100644 index 0000000..1e088c0 --- /dev/null +++ b/test/layout_test2.dart @@ -0,0 +1,130 @@ +import 'package:file/memory.dart'; +import 'package:liquify/src/context.dart'; +import 'package:liquify/src/evaluator.dart'; +import 'package:liquify/src/fs.dart'; +import 'package:test/test.dart'; + +import 'shared.dart'; + +void main() { + late Evaluator evaluator; + late MemoryFileSystem fileSystem; + late FileSystemRoot root; + + setUp(() { + evaluator = Evaluator(Environment()); + fileSystem = MemoryFileSystem(); + root = FileSystemRoot('/templates', fileSystem: fileSystem); + evaluator.context.setRoot(root); + + // Set up base layout template + fileSystem.file('/templates/layouts/base.liquid') + ..createSync(recursive: true) + ..writeAsStringSync(''' + + + + {% block title %}Default Title{% endblock %} + + + +
+ {% block header %}Default Header{% endblock %} +
+
+ {% block content %}Default Content{% endblock %} +
+
+ {% block footer %}Default Footer{% endblock %} +
+ + +'''); + + // Set up post layout template + fileSystem.file('/templates/layouts/post.liquid') + ..createSync(recursive: true) + ..writeAsStringSync(''' +{% layout "layouts/base.liquid" %} +{% block title %}Post Title{% endblock %} +{% block content %}Post Content{% endblock %} +'''); + + // Set up actual post template + fileSystem.file('/templates/posts/hello-world.liquid') + ..createSync(recursive: true) + ..writeAsStringSync(''' +{% layout "layouts/post.liquid" %} +{% block content %}Hello, World!{% endblock %} +'''); + }); + + tearDown(() { + evaluator.context.clear(); + }); + + group('Nested Layouts', () { + group('sync evaluation', () { + test('nested layout inheritance', () async { + await testParser(''' + {% layout "posts/hello-world.liquid" %} + ''', (document) { + evaluator.evaluateNodes(document.children); + expect( + evaluator.buffer.toString().trim(), + ''' + + + + Post Title + + + +
+ Default Header +
+
+ Hello, World! +
+
+ Default Footer +
+ + +''' + .trim()); + }); + }); + }); + +// group('async evaluation', () { +// test('nested layout inheritance', () async { +// await testParser(''' +// {% layout "posts/hello-world.liquid" %} +// ''', (document) async { +// await evaluator.evaluateNodesAsync(document.children); +// expect(evaluator.buffer.toString().trim(), ''' +// +// +// +// Post Title +// +// +// +//
+// Default Header +//
+//
+// Hello, World! +//
+//
+// Default Footer +//
+// +// +// '''.trim()); +// }); +// }); +// }); + }); +} diff --git a/test/shared_test_root.dart b/test/shared_test_root.dart new file mode 100644 index 0000000..ba89b86 --- /dev/null +++ b/test/shared_test_root.dart @@ -0,0 +1,23 @@ +import 'package:liquify/src/fs.dart'; + +class TestRoot implements Root { + final Map files = {}; + + @override + Future resolveAsync(String path) async { + final content = files[path]; + if (content == null) throw Exception('File not found: $path'); + return Source(Uri.parse(path), content, this); + } + + @override + Source resolve(String path) { + final content = files[path]; + if (content == null) throw Exception('File not found: $path'); + return Source(Uri.parse(path), content, this); + } + + void addFile(String path, String content) { + files[path] = content; + } +} diff --git a/test/tags/dot_notation.dart b/test/tags/dot_notation.dart new file mode 100644 index 0000000..745b8c5 --- /dev/null +++ b/test/tags/dot_notation.dart @@ -0,0 +1,92 @@ +import 'package:test/test.dart'; +import 'package:liquify/src/evaluator.dart'; +import 'package:liquify/src/context.dart'; +import 'package:liquify/src/ast.dart'; +import 'package:liquify/src/filter_registry.dart'; + +void main() { + group('Dot notation filters', () { + setUp(() { + // Register built-in dot notation filters + FilterRegistry.register('size', (value, args, namedArgs) => value.length, + dotNotation: true); + FilterRegistry.register('first', (value, args, namedArgs) => value.first, + dotNotation: true); + FilterRegistry.register('last', (value, args, namedArgs) => value.last, + dotNotation: true); + }); + + test('should apply size filter using dot notation', () { + final context = Environment({ + 'site': { + 'pages': [1, 2, 3, 4, 5] + } + }); + final evaluator = Evaluator(context); + final node = MemberAccess( + Identifier('site'), + [Identifier('pages'), Identifier('size')], + ); + + final result = evaluator.visitMemberAccess(node); + expect(result, 5); + }); + + test('should apply first filter using dot notation', () { + final context = Environment({ + 'collection': { + 'products': [ + {'title': 'Product 1'}, + {'title': 'Product 2'} + ] + } + }); + final evaluator = Evaluator(context); + final node = MemberAccess( + Identifier('collection'), + [Identifier('products'), Identifier('first')], + ); + + final result = evaluator.visitMemberAccess(node); + expect(result, {'title': 'Product 1'}); + }); + + test('should apply last filter using dot notation', () { + final context = Environment({ + 'collection': { + 'products': [ + {'title': 'Product 1'}, + {'title': 'Product 2'} + ] + } + }); + final evaluator = Evaluator(context); + final node = MemberAccess( + Identifier('collection'), + [Identifier('products'), Identifier('last')], + ); + + final result = evaluator.visitMemberAccess(node); + expect(result, {'title': 'Product 2'}); + }); + + test( + 'should allow library users to register their own dot notation filters', + () { + // Register a custom dot notation filter + FilterRegistry.register( + 'custom', (value, args, namedArgs) => 'custom filter applied', + dotNotation: true); + + final context = Environment({'data': 'test'}); + final evaluator = Evaluator(context); + final node = MemberAccess( + Identifier('data'), + [Identifier('custom')], + ); + + final result = evaluator.visitMemberAccess(node); + expect(result, 'custom filter applied'); + }); + }); +} diff --git a/test/tags/layout_test.dart b/test/tags/layout_test.dart new file mode 100644 index 0000000..66fe951 --- /dev/null +++ b/test/tags/layout_test.dart @@ -0,0 +1,256 @@ +import 'package:file/memory.dart'; +import 'package:liquify/src/context.dart'; +import 'package:liquify/src/evaluator.dart'; +import 'package:liquify/src/fs.dart'; +import 'package:test/test.dart'; + +import '../shared.dart'; + +void main() { + late Evaluator evaluator; + late MemoryFileSystem fileSystem; + late FileSystemRoot root; + + setUp(() { + evaluator = Evaluator(Environment()); + fileSystem = MemoryFileSystem(); + root = FileSystemRoot('/templates', fileSystem: fileSystem); + evaluator.context.setRoot(root); + + // Set up default layout template + fileSystem.file('/templates/default-layout.liquid') + ..createSync(recursive: true) + ..writeAsStringSync(''' +Header +{% block content %}Default content{% endblock %} +Footer'''); + + // Set up multi-block layout template + fileSystem.file('/templates/multi-block-layout.liquid') + ..createSync(recursive: true) + ..writeAsStringSync(''' +{% block header %}Default Header{% endblock %} +{% block content %}Default Content{% endblock %} +{% block footer %}Default Footer{% endblock %}'''); + }); + + tearDown(() { + evaluator.context.clear(); + }); + + group('Layout Tag', () { + group('sync evaluation', () { + test('basic layout usage', () async { + await testParser(''' + {% layout "default-layout.liquid" %} + {% block content %}My page content{% endblock %} + ''', (document) { + evaluator.evaluateNodes(document.children); + expect( + evaluator.buffer.toString().trim(), + ''' +Header +My page content +Footer''' + .trim()); + }); + }); + + test('multiple named blocks', () async { + await testParser(''' + {% layout "multi-block-layout.liquid" %} + {% block header %}Custom Header{% endblock %} + {% block content %}Custom Content{% endblock %} + ''', (document) { + evaluator.evaluateNodes(document.children); + expect( + evaluator.buffer.toString().trim(), + ''' +Custom Header +Custom Content +Default Footer''' + .trim()); + }); + }); + + test('default block contents', () async { + await testParser(''' + {% layout "default-layout.liquid" %} + ''', (document) { + evaluator.evaluateNodes(document.children); + expect( + evaluator.buffer.toString().trim(), + ''' +Header +Default content +Footer''' + .trim()); + }); + }); + + test('passing variables to layout', () async { + await testParser(''' + {% assign title = "My Page" %} + {% layout "default-layout.liquid", my_variable: title %} + {% block content %} + {{ my_variable }} + {% endblock %} + ''', (document) { + evaluator.evaluateNodes(document.children); + final output = evaluator.buffer.toString(); + expect(output, contains('My Page')); + expect(output, contains('Header')); + expect(output, contains('Footer')); + }); + }); + + test('multiple variables with literal values', () async { + await testParser(''' + {% layout "default-layout.liquid", title: "Page Title", subtitle: "Welcome" %} + {% block content %} + {{ title }} - {{ subtitle }} + {% endblock %} + ''', (document) { + evaluator.evaluateNodes(document.children); + final output = evaluator.buffer.toString(); + expect(output, contains('Page Title')); + expect(output, contains('Welcome')); + }); + }); + + test('nested layouts with multiple blocks and variables', () async { + // Set up nested layout template + fileSystem.file('/templates/layouts/nested.liquid') + ..createSync(recursive: true) + ..writeAsStringSync(''' +{% layout "default-layout.liquid" %} +{% block header %}{{ page_title | upcase }}{% endblock %} +{% block content %} +

{{ page_title }}

+ {% block subcontent %}Default subcontent{% endblock %} +{% endblock %}'''); + + await testParser(''' + {% layout "layouts/nested.liquid", page_title: "Welcome" %} + {% block subcontent %} +

Custom subcontent

+ {{ page_title }} + {% endblock %} + ''', (document) { + evaluator.evaluateNodes(document.children); + final output = evaluator.buffer.toString(); + expect(output, contains('

Welcome

')); + expect(output, contains('

Custom subcontent

')); + expect(output, contains('Welcome')); + }); + }); + }); + + group('async evaluation', () { + test('basic layout usage', () async { + await testParser(''' + {% layout "default-layout.liquid" %} + {% block content %}My page content{% endblock %} + ''', (document) async { + await evaluator.evaluateNodesAsync(document.children); + expect( + evaluator.buffer.toString().trim(), + ''' +Header +My page content +Footer''' + .trim()); + }); + }); + + test('multiple named blocks', () async { + await testParser(''' + {% layout "multi-block-layout.liquid" %} + {% block header %}Custom Header{% endblock %} + {% block content %}Custom Content{% endblock %} + ''', (document) async { + await evaluator.evaluateNodesAsync(document.children); + expect( + evaluator.buffer.toString().trim(), + ''' +Custom Header +Custom Content +Default Footer''' + .trim()); + }); + }); + + test('default block contents', () async { + await testParser(''' + {% layout "default-layout.liquid" %} + ''', (document) async { + await evaluator.evaluateNodesAsync(document.children); + expect( + evaluator.buffer.toString().trim(), + ''' +Header +Default content +Footer''' + .trim()); + }); + }); + + test('passing variables to layout', () async { + await testParser(''' + {% assign title = "My Page" %} + {% layout "default-layout.liquid", my_variable: title %} + {% block content %} + {{ my_variable }} + {% endblock %} + ''', (document) async { + await evaluator.evaluateNodesAsync(document.children); + final output = evaluator.buffer.toString(); + expect(output, contains('My Page')); + expect(output, contains('Header')); + expect(output, contains('Footer')); + }); + }); + + test('multiple variables with literal values', () async { + await testParser(''' + {% layout "default-layout.liquid", title: "Page Title", subtitle: "Welcome" %} + {% block content %} + {{ title }} - {{ subtitle }} + {% endblock %} + ''', (document) async { + await evaluator.evaluateNodesAsync(document.children); + final output = evaluator.buffer.toString(); + expect(output, contains('Page Title')); + expect(output, contains('Welcome')); + }); + }); + + test('nested layouts with multiple blocks and variables', () async { + // Set up nested layout template + fileSystem.file('/templates/layouts/nested.liquid') + ..createSync(recursive: true) + ..writeAsStringSync(''' +{% layout "default-layout.liquid" %} +{% block header %}{{ page_title | upcase }}{% endblock %} +{% block content %} +

{{ page_title }}

+ {% block subcontent %}Default subcontent{% endblock %} +{% endblock %}'''); + + await testParser(''' + {% layout "layouts/nested.liquid", page_title: "Welcome" %} + {% block subcontent %} +

Custom subcontent

+ {{ page_title }} + {% endblock %} + ''', (document) async { + await evaluator.evaluateNodesAsync(document.children); + final output = evaluator.buffer.toString(); + expect(output, contains('

Welcome

')); + expect(output, contains('

Custom subcontent

')); + expect(output, contains('Welcome')); + }); + }); + }); + }); +}