diff --git a/.github/labeler.yml b/.github/labeler.yml index 31d0794..327456a 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,3 +1,23 @@ +'p: flutter_template_images': + - changed-files: + - any-glob-to-any-file: + - packages/flutter_template_images/**/* + +'p: multicast_dns': + - changed-files: + - any-glob-to-any-file: + - packages/multicast_dns/**/* + +'p: mustache_template': + - changed-files: + - any-glob-to-any-file: + - third_party/packages/mustache_template/**/* + +'p: standard_message_codec': + - changed-files: + - any-glob-to-any-file: + - packages/standard_message_codec/**/* + 'p: vector_math': - changed-files: - any-glob-to-any-file: diff --git a/.gitignore b/.gitignore index 4d11a1f..4b2f4de 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Directory for files cached by the repo tooling +/.repo_tool_cache/ + .DS_Store .atom/ .idea diff --git a/.repo_tool_config.yaml b/.repo_tool_config.yaml index f829790..ad11512 100644 --- a/.repo_tool_config.yaml +++ b/.repo_tool_config.yaml @@ -26,5 +26,6 @@ allowed_dependencies: - build_runner - build_test - build_web_compilers + - meta - path - test diff --git a/README.md b/README.md index bfd2312..229a56a 100644 --- a/README.md +++ b/README.md @@ -37,4 +37,8 @@ These are the packages hosted in this repository: | Package | Pub | Points | Usage | Issues | Pull requests | |---------|-----|--------|-------|--------|---------------| +| [flutter\_template\_images](./packages/flutter_template_images/) | [![pub package](https://img.shields.io/pub/v/flutter_template_images.svg)](https://pub.dev/packages/flutter_template_images) | [![pub points](https://img.shields.io/pub/points/flutter_template_images)](https://pub.dev/packages/flutter_template_images/score) | [![downloads](https://img.shields.io/pub/dm/flutter_template_images)](https://pub.dev/packages/flutter_template_images/score) | [![GitHub issues by-label](https://img.shields.io/github/issues/flutter/flutter/p%3A%20flutter_template_images?label=)](https://github.com/flutter/flutter/labels/p%3A%20flutter_template_images) | [![GitHub pull requests by-label](https://img.shields.io/github/issues-pr/flutter/core-packages/p%3A%20flutter_template_images?label=)](https://github.com/flutter/core-packages/labels/p%3A%20flutter_template_images) | +| [multicast\_dns](./packages/multicast_dns/) | [![pub package](https://img.shields.io/pub/v/multicast_dns.svg)](https://pub.dev/packages/multicast_dns) | [![pub points](https://img.shields.io/pub/points/multicast_dns)](https://pub.dev/packages/multicast_dns/score) | [![downloads](https://img.shields.io/pub/dm/multicast_dns)](https://pub.dev/packages/multicast_dns/score) | [![GitHub issues by-label](https://img.shields.io/github/issues/flutter/flutter/p%3A%20multicast_dns?label=)](https://github.com/flutter/flutter/labels/p%3A%20multicast_dns) | [![GitHub pull requests by-label](https://img.shields.io/github/issues-pr/flutter/core-packages/p%3A%20multicast_dns?label=)](https://github.com/flutter/core-packages/labels/p%3A%20multicast_dns) | +| [mustache\_template](./third_party/packages/mustache_template/) | [![pub package](https://img.shields.io/pub/v/mustache_template.svg)](https://pub.dev/packages/mustache_template) | [![pub points](https://img.shields.io/pub/points/mustache_template)](https://pub.dev/packages/mustache_template/score) | [![downloads](https://img.shields.io/pub/dm/mustache_template)](https://pub.dev/packages/mustache_template/score) | [![GitHub issues by-label](https://img.shields.io/github/issues/flutter/flutter/p%3A%20mustache_template?label=)](https://github.com/flutter/flutter/labels/p%3A%20mustache_template) | [![GitHub pull requests by-label](https://img.shields.io/github/issues-pr/flutter/core-packages/p%3A%20mustache_template?label=)](https://github.com/flutter/core-packages/labels/p%3A%20mustache_template) | +| [standard\_message\_codec](./packages/standard_message_codec/) | [![pub package](https://img.shields.io/pub/v/standard_message_codec.svg)](https://pub.dev/packages/standard_message_codec) | [![pub points](https://img.shields.io/pub/points/standard_message_codec)](https://pub.dev/packages/standard_message_codec/score) | [![downloads](https://img.shields.io/pub/dm/standard_message_codec)](https://pub.dev/packages/standard_message_codec/score) | [![GitHub issues by-label](https://img.shields.io/github/issues/flutter/flutter/p%3A%20standard_message_codec?label=)](https://github.com/flutter/flutter/labels/p%3A%20standard_message_codec) | [![GitHub pull requests by-label](https://img.shields.io/github/issues-pr/flutter/core-packages/p%3A%20standard_message_codec?label=)](https://github.com/flutter/core-packages/labels/p%3A%20standard_message_codec) | | [vector\_math](./packages/vector_math/) | [![pub package](https://img.shields.io/pub/v/vector_math.svg)](https://pub.dev/packages/vector_math) | [![pub points](https://img.shields.io/pub/points/vector_math)](https://pub.dev/packages/vector_math/score) | [![downloads](https://img.shields.io/pub/dm/vector_math)](https://pub.dev/packages/vector_math/score) | [![GitHub issues by-label](https://img.shields.io/github/issues/flutter/flutter/p%3A%20vector_math?label=)](https://github.com/flutter/flutter/labels/p%3A%20vector_math) | [![GitHub pull requests by-label](https://img.shields.io/github/issues-pr/flutter/core-packages/p%3A%20vector_math?label=)](https://github.com/flutter/core-packages/labels/p%3A%20vector_math) | diff --git a/packages/flutter_template_images/.gitignore b/packages/flutter_template_images/.gitignore new file mode 100644 index 0000000..9e04949 --- /dev/null +++ b/packages/flutter_template_images/.gitignore @@ -0,0 +1,34 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ + +# Exceptions to above rules. +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages diff --git a/packages/flutter_template_images/AUTHORS b/packages/flutter_template_images/AUTHORS new file mode 100644 index 0000000..557dff9 --- /dev/null +++ b/packages/flutter_template_images/AUTHORS @@ -0,0 +1,6 @@ +# Below is a list of people and organizations that have contributed +# to the Flutter project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. diff --git a/packages/flutter_template_images/CHANGELOG.md b/packages/flutter_template_images/CHANGELOG.md new file mode 100644 index 0000000..9e39d26 --- /dev/null +++ b/packages/flutter_template_images/CHANGELOG.md @@ -0,0 +1,62 @@ +## NEXT + +* Updates minimum supported SDK version to Flutter 3.38/Dart 3.10. + +## 5.0.0 + +* Removes `app_shared` and `skeleton`, which are [no longer used](https://github.com/flutter/flutter/issues/160692). + +## 4.3.0 + +* Updates minimum supported SDK version to Flutter 3.22/Dart 3.4. +* Creates a copy of `app_shared` named `app` (`app_shared` will no longer be used). + +## 4.2.1 + +* Adds pub topics to package metadata. +* Updates minimum supported SDK version to Flutter 3.7/Dart 2.19. +* Updates minimum SDK version to Flutter 3.0. + +## 4.2.0 + +* Adds iOS template app icons, updated to square icons with no transparency. + +## 4.1.1 + +* Removes empty Dart file. +* Opts in to NNBD (which is a no-op) so it's not flagged by pub.dev. + +## 4.1.0 + +* Updates package description. +* Adds macOS template app icons, updated to Big Sur style. + +## 4.0.0 + +* Move assets common to all app templates to a new `app_shared` directory + (relands changes reverted in 3.0.0). +* Create `skeleton` directory and assets to support new app template + (formerly known as `list_detail_app`). + +## 3.0.1 + +* Fix maskable icon file names +* Fix maskable icon image dimensions + +## 3.0.0 + +* Reverts to the 1.0 layout, since the new app template never landed. +* Added additional icons for winuwp template. + +## 2.0.0 + +* Move assets common to all app templates to a new `app_shared` directory. +* Create `list_detail_app` directory and assets to support new app template. + +## 1.0.1 + +* Moved Windows app template icon for new folder structure. + +## 1.0.0 + +* Windows app template icon. diff --git a/packages/flutter_template_images/LICENSE b/packages/flutter_template_images/LICENSE new file mode 100644 index 0000000..29b709d --- /dev/null +++ b/packages/flutter_template_images/LICENSE @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/flutter_template_images/README.md b/packages/flutter_template_images/README.md new file mode 100644 index 0000000..3ae9dc9 --- /dev/null +++ b/packages/flutter_template_images/README.md @@ -0,0 +1,9 @@ +# flutter\_template\_images + +Images used by the `flutter_tools` templates. + +This project is an internal dependency of the `flutter` tool, and is +not intended to be used directly. It contains images files used in +`flutter create` templates, to avoid checking them into [the main +Flutter repository](https://github.com/flutter/flutter), where they would +permanently increase the checkout size over time if altered. diff --git a/packages/flutter_template_images/pubspec.yaml b/packages/flutter_template_images/pubspec.yaml new file mode 100644 index 0000000..c689f5c --- /dev/null +++ b/packages/flutter_template_images/pubspec.yaml @@ -0,0 +1,12 @@ +name: flutter_template_images +description: Image files for use in flutter_tools templates without adding binary files to flutter/flutter. +repository: https://github.com/flutter/core-packages/tree/main/packages/flutter_template_images +issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+flutter_template_images%22 +version: 5.0.0 + +environment: + sdk: ^3.10.0 + +topics: + - assets + - image diff --git a/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/packages/flutter_template_images/templates/app/ios.tmpl/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/packages/flutter_template_images/templates/app/macos.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/packages/flutter_template_images/templates/app/macos.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/packages/flutter_template_images/templates/app/macos.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/packages/flutter_template_images/templates/app/macos.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/packages/flutter_template_images/templates/app/macos.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/packages/flutter_template_images/templates/app/macos.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/packages/flutter_template_images/templates/app/macos.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/packages/flutter_template_images/templates/app/macos.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/packages/flutter_template_images/templates/app/macos.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/packages/flutter_template_images/templates/app/macos.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/packages/flutter_template_images/templates/app/macos.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/packages/flutter_template_images/templates/app/macos.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/packages/flutter_template_images/templates/app/macos.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/packages/flutter_template_images/templates/app/macos.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/packages/flutter_template_images/templates/app/macos.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/packages/flutter_template_images/templates/app/macos.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/packages/flutter_template_images/templates/app/macos.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/packages/flutter_template_images/templates/app/macos.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/packages/flutter_template_images/templates/app/macos.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/packages/flutter_template_images/templates/app/macos.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/packages/flutter_template_images/templates/app/macos.tmpl/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/packages/flutter_template_images/templates/app/web/icons/Icon-maskable-192.png b/packages/flutter_template_images/templates/app/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/packages/flutter_template_images/templates/app/web/icons/Icon-maskable-192.png differ diff --git a/packages/flutter_template_images/templates/app/web/icons/Icon-maskable-512.png b/packages/flutter_template_images/templates/app/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/packages/flutter_template_images/templates/app/web/icons/Icon-maskable-512.png differ diff --git a/packages/flutter_template_images/templates/app/windows.tmpl/runner/resources/app_icon.ico b/packages/flutter_template_images/templates/app/windows.tmpl/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/packages/flutter_template_images/templates/app/windows.tmpl/runner/resources/app_icon.ico differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/LargeTile.scale-100.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/LargeTile.scale-100.png new file mode 100644 index 0000000..fa651eb Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/LargeTile.scale-100.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/LargeTile.scale-125.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/LargeTile.scale-125.png new file mode 100644 index 0000000..649e076 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/LargeTile.scale-125.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/LargeTile.scale-150.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/LargeTile.scale-150.png new file mode 100644 index 0000000..fd14c60 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/LargeTile.scale-150.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/LargeTile.scale-200.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/LargeTile.scale-200.png new file mode 100644 index 0000000..873537f Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/LargeTile.scale-200.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/LargeTile.scale-400.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/LargeTile.scale-400.png new file mode 100644 index 0000000..979878f Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/LargeTile.scale-400.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/LockScreenLogo.scale-200.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/LockScreenLogo.scale-200.png new file mode 100644 index 0000000..735f57a Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/LockScreenLogo.scale-200.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SmallTile.scale-100.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SmallTile.scale-100.png new file mode 100644 index 0000000..35e7f62 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SmallTile.scale-100.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SmallTile.scale-125.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SmallTile.scale-125.png new file mode 100644 index 0000000..2a74cc6 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SmallTile.scale-125.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SmallTile.scale-150.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SmallTile.scale-150.png new file mode 100644 index 0000000..1571248 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SmallTile.scale-150.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SmallTile.scale-200.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SmallTile.scale-200.png new file mode 100644 index 0000000..07ec2dd Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SmallTile.scale-200.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SmallTile.scale-400.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SmallTile.scale-400.png new file mode 100644 index 0000000..c205729 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SmallTile.scale-400.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SplashScreen.scale-100.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SplashScreen.scale-100.png new file mode 100644 index 0000000..6012579 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SplashScreen.scale-100.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SplashScreen.scale-125.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SplashScreen.scale-125.png new file mode 100644 index 0000000..0c35be8 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SplashScreen.scale-125.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SplashScreen.scale-150.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SplashScreen.scale-150.png new file mode 100644 index 0000000..f1e60f3 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SplashScreen.scale-150.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SplashScreen.scale-200.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SplashScreen.scale-200.png new file mode 100644 index 0000000..73d2461 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SplashScreen.scale-200.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SplashScreen.scale-400.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SplashScreen.scale-400.png new file mode 100644 index 0000000..b2b7ca9 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/SplashScreen.scale-400.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square150x150Logo.scale-100.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square150x150Logo.scale-100.png new file mode 100644 index 0000000..cdc3e97 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square150x150Logo.scale-100.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square150x150Logo.scale-125.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square150x150Logo.scale-125.png new file mode 100644 index 0000000..71e5a11 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square150x150Logo.scale-125.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square150x150Logo.scale-150.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square150x150Logo.scale-150.png new file mode 100644 index 0000000..60f2e18 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square150x150Logo.scale-150.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square150x150Logo.scale-200.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square150x150Logo.scale-200.png new file mode 100644 index 0000000..2081408 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square150x150Logo.scale-200.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square150x150Logo.scale-400.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square150x150Logo.scale-400.png new file mode 100644 index 0000000..605aacb Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square150x150Logo.scale-400.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.altform-unplated_targetsize-16.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.altform-unplated_targetsize-16.png new file mode 100644 index 0000000..1c78d96 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.altform-unplated_targetsize-16.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.altform-unplated_targetsize-256.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.altform-unplated_targetsize-256.png new file mode 100644 index 0000000..d49d3d8 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.altform-unplated_targetsize-256.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.altform-unplated_targetsize-32.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.altform-unplated_targetsize-32.png new file mode 100644 index 0000000..8896224 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.altform-unplated_targetsize-32.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.altform-unplated_targetsize-48.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.altform-unplated_targetsize-48.png new file mode 100644 index 0000000..6389ede Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.altform-unplated_targetsize-48.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.scale-100.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.scale-100.png new file mode 100644 index 0000000..47e3cd2 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.scale-100.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.scale-125.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.scale-125.png new file mode 100644 index 0000000..50faa93 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.scale-125.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.scale-150.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.scale-150.png new file mode 100644 index 0000000..f0293ed Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.scale-150.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.scale-200.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.scale-200.png new file mode 100644 index 0000000..e54a56d Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.scale-200.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.scale-400.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.scale-400.png new file mode 100644 index 0000000..4b5fb17 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.scale-400.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.targetsize-16.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.targetsize-16.png new file mode 100644 index 0000000..a1a6ec7 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.targetsize-16.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.targetsize-24.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.targetsize-24.png new file mode 100644 index 0000000..c67a8e1 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.targetsize-24.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.targetsize-24_altform-unplated.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.targetsize-24_altform-unplated.png new file mode 100644 index 0000000..47d36f6 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.targetsize-24_altform-unplated.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.targetsize-256.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.targetsize-256.png new file mode 100644 index 0000000..50efc00 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.targetsize-256.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.targetsize-32.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.targetsize-32.png new file mode 100644 index 0000000..f86682c Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.targetsize-32.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.targetsize-48.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.targetsize-48.png new file mode 100644 index 0000000..7561269 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Square44x44Logo.targetsize-48.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/StoreLogo.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/StoreLogo.png new file mode 100644 index 0000000..7385b56 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/StoreLogo.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/StoreLogo.scale-100.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/StoreLogo.scale-100.png new file mode 100644 index 0000000..fcefe81 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/StoreLogo.scale-100.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/StoreLogo.scale-125.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/StoreLogo.scale-125.png new file mode 100644 index 0000000..4381be7 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/StoreLogo.scale-125.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/StoreLogo.scale-150.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/StoreLogo.scale-150.png new file mode 100644 index 0000000..e49390b Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/StoreLogo.scale-150.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/StoreLogo.scale-200.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/StoreLogo.scale-200.png new file mode 100644 index 0000000..fb740e8 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/StoreLogo.scale-200.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/StoreLogo.scale-400.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/StoreLogo.scale-400.png new file mode 100644 index 0000000..d147274 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/StoreLogo.scale-400.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Wide310x150Logo.scale-200.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Wide310x150Logo.scale-200.png new file mode 100644 index 0000000..288995b Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/Wide310x150Logo.scale-200.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/WideTile.scale-100.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/WideTile.scale-100.png new file mode 100644 index 0000000..1cb688c Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/WideTile.scale-100.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/WideTile.scale-125.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/WideTile.scale-125.png new file mode 100644 index 0000000..7292396 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/WideTile.scale-125.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/WideTile.scale-150.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/WideTile.scale-150.png new file mode 100644 index 0000000..d4b275a Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/WideTile.scale-150.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/WideTile.scale-200.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/WideTile.scale-200.png new file mode 100644 index 0000000..6012579 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/WideTile.scale-200.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/WideTile.scale-400.png b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/WideTile.scale-400.png new file mode 100644 index 0000000..73d2461 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Assets/WideTile.scale-400.png differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Windows_TemporaryKey.pfx b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Windows_TemporaryKey.pfx new file mode 100644 index 0000000..1cad999 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/Windows_TemporaryKey.pfx differ diff --git a/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/resources.pri b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/resources.pri new file mode 100644 index 0000000..7de03c9 Binary files /dev/null and b/packages/flutter_template_images/templates/app/winuwp.tmpl/runner_uwp/resources.pri differ diff --git a/packages/multicast_dns/AUTHORS b/packages/multicast_dns/AUTHORS new file mode 100644 index 0000000..add1041 --- /dev/null +++ b/packages/multicast_dns/AUTHORS @@ -0,0 +1,7 @@ +# Below is a list of people and organizations that have contributed +# to the Flutter project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. +The Dartino project authors diff --git a/packages/multicast_dns/CHANGELOG.md b/packages/multicast_dns/CHANGELOG.md new file mode 100644 index 0000000..4313ece --- /dev/null +++ b/packages/multicast_dns/CHANGELOG.md @@ -0,0 +1,80 @@ +## NEXT + +* Updates minimum supported SDK version to Flutter 3.38/Dart 3.10. + +## 0.3.3 + +* Adds an optional error callback for `MDnsClient::start` to prevent uncaught exceptions. + +## 0.3.2+8 + +* Fixes stack overflows ocurring during the parsing of domain names in MDNS messages. +* Updates minimum supported SDK version to Flutter 3.22/Dart 3.4. + +## 0.3.2+7 + +* Optimized Socket Binding: Always bind to 0.0.0.0 for simplicity and efficiency. +* Updates minimum supported SDK version to Flutter 3.16/Dart 3.2. + +## 0.3.2+6 + +* Improves links in README.md. + +## 0.3.2+5 + +* Updates `PendingRequest` to be a `base class` for Dart 3.0 compatibility. +* Updates minimum supported SDK version to Flutter 3.10/Dart 3.0. + +## 0.3.2+4 + +* Adds pub topics to package metadata. +* Updates minimum supported SDK version to Flutter 3.7/Dart 2.19. + +## 0.3.2+3 + +* Removes use of `runtimeType.toString()`. +* Updates minimum SDK version to Flutter 3.0. + +## 0.3.2+2 + +* Fixes lints warnings. + +## 0.3.2+1 + +* Migrates from `ui.hash*` to `Object.hash*`. + +## 0.3.2 + +* Updates package description. +* Make [MDnsClient.start] idempotent. + +## 0.3.1 + +* Close IPv6 sockets on [MDnsClient.stop]. + +## 0.3.0+1 + +* Removed redundant link in README.md file. + +## 0.3.0 + +* Migrate package to null safety. + +## 0.2.2 +* Fixes parsing of TXT records. Continues parsing on non-utf8 strings. + +## 0.2.1 +* Fixes the handling of packets containing non-utf8 strings. + +## 0.2.0 +* Allow configuration of the port and address the mdns query is performed on. + +## 0.1.1 + +* Fixes [flutter/issue/31854](https://github.com/flutter/flutter/issues/31854) where `decodeMDnsResponse` advanced to incorrect code points and ignored some records. + +## 0.1.0 + +* Initial Open Source release. +* Migrates the dartino-sdk's mDNS client to Dart 2.0 and Flutter's analysis rules +* Breaks from original Dartino code, as it does not use native libraries for macOS and overhauls the `ResourceRecord` class. diff --git a/packages/multicast_dns/LICENSE b/packages/multicast_dns/LICENSE new file mode 100644 index 0000000..29b709d --- /dev/null +++ b/packages/multicast_dns/LICENSE @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/multicast_dns/README.md b/packages/multicast_dns/README.md new file mode 100644 index 0000000..f30ce16 --- /dev/null +++ b/packages/multicast_dns/README.md @@ -0,0 +1,13 @@ +# Multicast DNS package + +Based on [RFC 6762 Multicast DNS](https://datatracker.ietf.org/doc/html/rfc6762). + +[![pub package](https://img.shields.io/pub/v/multicast_dns.svg)]( +https://pub.dartlang.org/packages/multicast_dns) + +A Dart package to do service discovery over multicast DNS (mDNS), Bonjour, and Avahi. + +## Usage + +[The example](https://pub.dev/packages/multicast_dns/example) demonstrates how +to use the `MDnsClient` Dart class in your code. diff --git a/packages/multicast_dns/dart_test.yaml b/packages/multicast_dns/dart_test.yaml new file mode 100644 index 0000000..91ec220 --- /dev/null +++ b/packages/multicast_dns/dart_test.yaml @@ -0,0 +1 @@ +test_on: vm diff --git a/packages/multicast_dns/example/main.dart b/packages/multicast_dns/example/main.dart new file mode 100644 index 0000000..89d77d2 --- /dev/null +++ b/packages/multicast_dns/example/main.dart @@ -0,0 +1,42 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Example script to illustrate how to use the mdns package to discover the port +// of a Dart observatory over mDNS. + +// ignore_for_file: avoid_print + +import 'package:multicast_dns/multicast_dns.dart'; + +Future main() async { + // Parse the command line arguments. + + const name = '_dartobservatory._tcp.local'; + final client = MDnsClient(); + // Start the client with default options. + await client.start(); + + // Get the PTR record for the service. + await for (final PtrResourceRecord ptr in client.lookup( + ResourceRecordQuery.serverPointer(name), + )) { + // Use the domainName from the PTR record to get the SRV record, + // which will have the port and local hostname. + // Note that duplicate messages may come through, especially if any + // other mDNS queries are running elsewhere on the machine. + await for (final SrvResourceRecord srv in client.lookup( + ResourceRecordQuery.service(ptr.domainName), + )) { + // Domain name will be something like "io.flutter.example@some-iphone.local._dartobservatory._tcp.local" + final String bundleId = ptr.domainName; //.substring(0, ptr.domainName.indexOf('@')); + print( + 'Dart observatory instance found at ' + '${srv.target}:${srv.port} for "$bundleId".', + ); + } + } + client.stop(); + + print('Done.'); +} diff --git a/packages/multicast_dns/example/mdns_resolve.dart b/packages/multicast_dns/example/mdns_resolve.dart new file mode 100644 index 0000000..32f5d5b --- /dev/null +++ b/packages/multicast_dns/example/mdns_resolve.dart @@ -0,0 +1,38 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Example script to illustrate how to use the mdns package to lookup names +// on the local network. + +// ignore_for_file: avoid_print + +import 'package:multicast_dns/multicast_dns.dart'; + +Future main(List args) async { + if (args.length != 1) { + print(''' +Please provide an address as argument. + +For example: + dart mdns_resolve.dart dartino.local'''); + return; + } + + final String name = args[0]; + + final client = MDnsClient(); + await client.start(); + await for (final IPAddressResourceRecord record in client.lookup( + ResourceRecordQuery.addressIPv4(name), + )) { + print('Found address (${record.address}).'); + } + + await for (final IPAddressResourceRecord record in client.lookup( + ResourceRecordQuery.addressIPv6(name), + )) { + print('Found address (${record.address}).'); + } + client.stop(); +} diff --git a/packages/multicast_dns/example/mdns_sd.dart b/packages/multicast_dns/example/mdns_sd.dart new file mode 100644 index 0000000..7fd5414 --- /dev/null +++ b/packages/multicast_dns/example/mdns_sd.dart @@ -0,0 +1,69 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Example script to illustrate how to use the mdns package to discover services +// on the local network. + +// ignore_for_file: avoid_print + +import 'package:multicast_dns/multicast_dns.dart'; + +Future main(List args) async { + if (args.isEmpty) { + print(''' +Please provide the name of a service as argument. + +For example: + dart mdns_sd.dart [--verbose] _workstation._tcp.local'''); + return; + } + + final bool verbose = args.contains('--verbose') || args.contains('-v'); + final String name = args.last; + final client = MDnsClient(); + await client.start(); + + await for (final PtrResourceRecord ptr in client.lookup( + ResourceRecordQuery.serverPointer(name), + )) { + if (verbose) { + print(ptr); + } + await for (final SrvResourceRecord srv in client.lookup( + ResourceRecordQuery.service(ptr.domainName), + )) { + if (verbose) { + print(srv); + } + if (verbose) { + await client + .lookup(ResourceRecordQuery.text(ptr.domainName)) + .forEach(print); + } + await for (final IPAddressResourceRecord ip in client.lookup( + ResourceRecordQuery.addressIPv4(srv.target), + )) { + if (verbose) { + print(ip); + } + print( + 'Service instance found at ' + '${srv.target}:${srv.port} with ${ip.address}.', + ); + } + await for (final IPAddressResourceRecord ip in client.lookup( + ResourceRecordQuery.addressIPv6(srv.target), + )) { + if (verbose) { + print(ip); + } + print( + 'Service instance found at ' + '${srv.target}:${srv.port} with ${ip.address}.', + ); + } + } + } + client.stop(); +} diff --git a/packages/multicast_dns/lib/multicast_dns.dart b/packages/multicast_dns/lib/multicast_dns.dart new file mode 100644 index 0000000..98eeff7 --- /dev/null +++ b/packages/multicast_dns/lib/multicast_dns.dart @@ -0,0 +1,258 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; + +import 'src/constants.dart'; +import 'src/lookup_resolver.dart'; +import 'src/native_protocol_client.dart'; +import 'src/packet.dart'; +import 'src/resource_record.dart'; + +export 'package:multicast_dns/src/resource_record.dart'; + +/// A callback type for [MDnsQuerier.start] to iterate available network +/// interfaces. +/// +/// Implementations must ensure they return interfaces appropriate for the +/// [type] parameter. +/// +/// See also: +/// * [MDnsQuerier.allInterfacesFactory] +typedef NetworkInterfacesFactory = + Future> Function(InternetAddressType type); + +/// A factory for construction of datagram sockets. +/// +/// This can be injected into the [MDnsClient] to provide alternative +/// implementations of [RawDatagramSocket.bind]. +typedef RawDatagramSocketFactory = + Future Function( + dynamic host, + int port, { + bool reuseAddress, + bool reusePort, + int ttl, + }); + +/// Client for DNS lookup and publishing using the mDNS protocol. +/// +/// Users should call [MDnsQuerier.start] when ready to start querying and +/// listening. [MDnsQuerier.stop] must be called when done to clean up +/// resources. +/// +/// This client only supports "One-Shot Multicast DNS Queries" as described in +/// section 5.1 of [RFC 6762](https://tools.ietf.org/html/rfc6762). +class MDnsClient { + /// Create a new [MDnsClient]. + MDnsClient({RawDatagramSocketFactory rawDatagramSocketFactory = RawDatagramSocket.bind}) + : _rawDatagramSocketFactory = rawDatagramSocketFactory; + + bool _starting = false; + bool _started = false; + RawDatagramSocket? _incomingIPv4; + final List _ipv6InterfaceSockets = []; + final LookupResolver _resolver = LookupResolver(); + final ResourceRecordCache _cache = ResourceRecordCache(); + final RawDatagramSocketFactory _rawDatagramSocketFactory; + + InternetAddress? _mDnsAddress; + int? _mDnsPort; + + /// Find all network interfaces with an the [InternetAddressType] specified. + Future> allInterfacesFactory(InternetAddressType type) { + return NetworkInterface.list(includeLinkLocal: true, type: type, includeLoopback: true); + } + + /// Start the mDNS client. + /// + /// With no arguments, this method will listen on the IPv4 multicast address + /// on all IPv4 network interfaces. + /// + /// The [listenAddress] parameter must be either [InternetAddress.anyIPv4] or + /// [InternetAddress.anyIPv6], and will default to anyIPv4. + /// + /// The [interfaceFactory] defaults to [allInterfacesFactory]. + /// + /// The [mDnsPort] allows configuring what port is used for the mDNS + /// query. If not provided, defaults to `5353`. + /// + /// The [mDnsAddress] allows configuring what internet address is used + /// for the mDNS query. If not provided, defaults to either `224.0.0.251` or + /// or `FF02::FB`. + /// + /// If provided, [onError] will be called in case of a stream error. If + /// omitted any errors on the stream are considered unhandled, and will be + /// passed to the current [Zone]'s error handler. + /// + /// Subsequent calls to this method are ignored while the mDNS client is in + /// started state. + Future start({ + InternetAddress? listenAddress, + NetworkInterfacesFactory? interfacesFactory, + int mDnsPort = mDnsPort, + InternetAddress? mDnsAddress, + Function? onError, + }) async { + listenAddress ??= InternetAddress.anyIPv4; + interfacesFactory ??= allInterfacesFactory; + + assert( + listenAddress.address == InternetAddress.anyIPv4.address || + listenAddress.address == InternetAddress.anyIPv6.address, + ); + + if (_started || _starting) { + return; + } + _starting = true; + + final int selectedMDnsPort = _mDnsPort = mDnsPort; + _mDnsAddress = mDnsAddress; + + // Listen on all addresses. + final RawDatagramSocket incoming = await _rawDatagramSocketFactory( + listenAddress.address, + selectedMDnsPort, + reuseAddress: true, + reusePort: true, + ttl: 255, + ); + + // Can't send to IPv6 any address. + if (incoming.address != InternetAddress.anyIPv6) { + _incomingIPv4 = incoming; + } else { + _ipv6InterfaceSockets.add(incoming); + } + + _mDnsAddress ??= incoming.address.type == InternetAddressType.IPv4 + ? mDnsAddressIPv4 + : mDnsAddressIPv6; + + final List interfaces = (await interfacesFactory( + listenAddress.type, + )).toList(); + + for (final interface in interfaces) { + final InternetAddress targetAddress = interface.addresses[0]; + + // Ensure that we're using this address/interface for multicast. + if (targetAddress.type == InternetAddressType.IPv6) { + final RawDatagramSocket socket = await _rawDatagramSocketFactory( + targetAddress, + selectedMDnsPort, + reuseAddress: true, + reusePort: true, + ttl: 255, + ); + _ipv6InterfaceSockets.add(socket); + socket.setRawOption( + RawSocketOption.fromInt( + RawSocketOption.levelIPv6, + RawSocketOption.IPv6MulticastInterface, + interface.index, + ), + ); + } + + // Join multicast on this interface. + incoming.joinMulticast(_mDnsAddress!, interface); + } + incoming.listen((RawSocketEvent event) => _handleIncoming(event, incoming), onError: onError); + _started = true; + _starting = false; + } + + /// Stop the client and close any associated sockets. + void stop() { + if (!_started) { + return; + } + if (_starting) { + throw StateError('Cannot stop mDNS client while it is starting.'); + } + + _incomingIPv4?.close(); + _incomingIPv4 = null; + + for (final RawDatagramSocket socket in _ipv6InterfaceSockets) { + socket.close(); + } + _ipv6InterfaceSockets.clear(); + + _resolver.clearPendingRequests(); + + _started = false; + } + + /// Lookup a [ResourceRecord], potentially from the cache. + /// + /// The [type] parameter must be a valid [ResourceRecordType]. The [fullyQualifiedName] + /// parameter is the name of the service to lookup, and must not be null. The + /// [timeout] parameter specifies how long the internal cache should hold on + /// to the record. The [multicast] parameter specifies whether the query + /// should be sent as unicast (QU) or multicast (QM). + /// + /// Some publishers have been observed to not respond to unicast requests + /// properly, so the default is true. + Stream lookup( + ResourceRecordQuery query, { + Duration timeout = const Duration(seconds: 5), + }) { + final int? selectedMDnsPort = _mDnsPort; + if (!_started || selectedMDnsPort == null) { + throw StateError('mDNS client must be started before calling lookup.'); + } + // Look for entries in the cache. + final cached = []; + _cache.lookup(query.fullyQualifiedName, query.resourceRecordType, cached); + if (cached.isNotEmpty) { + final controller = StreamController(); + cached.forEach(controller.add); + controller.close(); + return controller.stream; + } + + // Add the pending request before sending the query. + final Stream results = _resolver.addPendingRequest( + query.resourceRecordType, + query.fullyQualifiedName, + timeout, + ); + + final List packet = query.encode(); + + if (_mDnsAddress?.type == InternetAddressType.IPv4) { + // Send and listen on same "ANY" interface + _incomingIPv4?.send(packet, _mDnsAddress!, selectedMDnsPort); + } else { + for (final RawDatagramSocket socket in _ipv6InterfaceSockets) { + socket.send(packet, _mDnsAddress!, selectedMDnsPort); + } + } + + return results; + } + + // Process incoming datagrams. + void _handleIncoming(RawSocketEvent event, RawDatagramSocket incoming) { + if (event == RawSocketEvent.read) { + final Datagram? datagram = incoming.receive(); + if (datagram == null) { + return; + } + + // Check for published responses. + final List? response = decodeMDnsResponse(datagram.data); + if (response != null) { + _cache.updateRecords(response); + _resolver.handleResponse(response); + return; + } + // TODO(dnfield): Support queries coming in for published entries. + } + } +} diff --git a/packages/multicast_dns/lib/src/constants.dart b/packages/multicast_dns/lib/src/constants.dart new file mode 100644 index 0000000..7517f53 --- /dev/null +++ b/packages/multicast_dns/lib/src/constants.dart @@ -0,0 +1,37 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +/// The IPv4 mDNS Address. +final InternetAddress mDnsAddressIPv4 = InternetAddress('224.0.0.251'); + +/// The IPv6 mDNS Address. +final InternetAddress mDnsAddressIPv6 = InternetAddress('FF02::FB'); + +/// The mDNS port. +const int mDnsPort = 5353; + +/// Enumeration of supported resource record class types. +abstract class ResourceRecordClass { + // This class is intended to be used as a namespace, and should not be + // extended directly. + ResourceRecordClass._(); + + /// Internet address class ("IN"). + static const int internet = 1; +} + +/// Enumeration of DNS question types. +abstract class QuestionType { + // This class is intended to be used as a namespace, and should not be + // extended directly. + QuestionType._(); + + /// "QU" Question. + static const int unicast = 0x8000; + + /// "QM" Question. + static const int multicast = 0x0000; +} diff --git a/packages/multicast_dns/lib/src/lookup_resolver.dart b/packages/multicast_dns/lib/src/lookup_resolver.dart new file mode 100644 index 0000000..9a6add1 --- /dev/null +++ b/packages/multicast_dns/lib/src/lookup_resolver.dart @@ -0,0 +1,94 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:collection'; + +import 'resource_record.dart'; + +/// Class for maintaining state about pending mDNS requests. +base class PendingRequest extends LinkedListEntry { + /// Creates a new PendingRequest. + PendingRequest(this.type, this.domainName, this.controller); + + /// The [ResourceRecordType] of the request. + final int type; + + /// The domain name to look up via mDNS. + /// + /// For example, `'_http._tcp.local` to look up HTTP services on the local + /// domain. + final String domainName; + + /// A StreamController managing the request. + final StreamController controller; + + /// The timer for the request. + Timer? timer; +} + +/// Class for keeping track of pending lookups and processing incoming +/// query responses. +class LookupResolver { + final LinkedList _pendingRequests = LinkedList(); + + /// Adds a request and returns a [Stream] of [ResourceRecord] responses. + Stream addPendingRequest(int type, String name, Duration timeout) { + final controller = StreamController(); + final request = PendingRequest(type, name, controller); + final timer = Timer(timeout, () { + request.unlink(); + controller.close(); + }); + request.timer = timer; + _pendingRequests.add(request); + return controller.stream; + } + + /// Parses [ResoureRecord]s received and delivers them to the appropriate + /// listener(s) added via [addPendingRequest]. + void handleResponse(List response) { + for (final r in response) { + final int type = r.resourceRecordType; + String name = r.name.toLowerCase(); + if (name.endsWith('.')) { + name = name.substring(0, name.length - 1); + } + + bool responseMatches(PendingRequest request) { + String requestName = request.domainName.toLowerCase(); + // make, e.g. "_http" become "_http._tcp.local". + if (!requestName.endsWith('local')) { + if (!requestName.endsWith('._tcp.local') && + !requestName.endsWith('._udp.local') && + !requestName.endsWith('._tcp') && + !requestName.endsWith('.udp')) { + requestName += '._tcp'; + } + requestName += '.local'; + } + return requestName == name && request.type == type; + } + + for (final PendingRequest pendingRequest in _pendingRequests) { + if (responseMatches(pendingRequest)) { + if (pendingRequest.controller.isClosed) { + return; + } + pendingRequest.controller.add(r); + } + } + } + } + + /// Removes any pending requests and ends processing. + void clearPendingRequests() { + while (_pendingRequests.isNotEmpty) { + final PendingRequest request = _pendingRequests.first; + request.unlink(); + request.timer?.cancel(); + request.controller.close(); + } + } +} diff --git a/packages/multicast_dns/lib/src/native_protocol_client.dart b/packages/multicast_dns/lib/src/native_protocol_client.dart new file mode 100644 index 0000000..96eb89f --- /dev/null +++ b/packages/multicast_dns/lib/src/native_protocol_client.dart @@ -0,0 +1,73 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:collection'; + +import 'resource_record.dart'; + +/// Cache for resource records that have been received. +/// +/// There can be multiple entries for the same name and type. +/// +/// The cache is updated with a list of records, because it needs to remove +/// all entries that correspond to the name and type of the name/type +/// combinations of records that should be updated. For example, a host may +/// remove one of its IP addresses and report the remaining address as a +/// response - then we need to clear all previous entries for that host before +/// updating the cache. +class ResourceRecordCache { + /// Creates a new ResourceRecordCache. + ResourceRecordCache(); + + final Map>> _cache = + >>{}; + + /// The number of entries in the cache. + int get entryCount { + var count = 0; + for (final SplayTreeMap> map in _cache.values) { + for (final List records in map.values) { + count += records.length; + } + } + return count; + } + + /// Update the records in this cache. + void updateRecords(List records) { + // TODO(karlklose): include flush bit in the record and only flush if + // necessary. + // Clear the cache for all name/type combinations to be updated. + final seenRecordTypes = >{}; + for (final record in records) { + // TODO(dnfield): Update this to use set literal syntax when we're able to bump the SDK constraint. + seenRecordTypes[record.resourceRecordType] ??= + Set(); // ignore: prefer_collection_literals + if (seenRecordTypes[record.resourceRecordType]!.add(record.name)) { + _cache[record.resourceRecordType] ??= SplayTreeMap>(); + + _cache[record.resourceRecordType]![record.name] = [record]; + } else { + _cache[record.resourceRecordType]![record.name]!.add(record); + } + } + } + + /// Get a record from this cache. + void lookup(String name, int type, List results) { + assert(ResourceRecordType.debugAssertValid(type)); + final int time = DateTime.now().millisecondsSinceEpoch; + final SplayTreeMap>? candidates = _cache[type]; + if (candidates == null) { + return; + } + + final List? candidateRecords = candidates[name]; + if (candidateRecords == null) { + return; + } + candidateRecords.removeWhere((ResourceRecord candidate) => candidate.validUntil < time); + results.addAll(candidateRecords.cast()); + } +} diff --git a/packages/multicast_dns/lib/src/packet.dart b/packages/multicast_dns/lib/src/packet.dart new file mode 100644 index 0000000..bbbe8dc --- /dev/null +++ b/packages/multicast_dns/lib/src/packet.dart @@ -0,0 +1,362 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'constants.dart'; +import 'resource_record.dart'; + +// Offsets into the header. See https://tools.ietf.org/html/rfc1035. +const int _kIdOffset = 0; +const int _kFlagsOffset = 2; +const int _kQdcountOffset = 4; +const int _kAncountOffset = 6; +const int _kNscountOffset = 8; +const int _kArcountOffset = 10; +const int _kHeaderSize = 12; + +/// Processes a DNS query name into a list of parts. +/// +/// Will attempt to append 'local' if the name is something like '_http._tcp', +/// and '._tcp.local' if name is something like '_http'. +List processDnsNameParts(String name) { + final List parts = name.split('.'); + if (parts.length == 1) { + return [parts[0], '_tcp', 'local']; + } else if (parts.length == 2 && parts[1].startsWith('_')) { + return [parts[0], parts[1], 'local']; + } + + return parts; +} + +/// Encode an mDNS query packet. +/// +/// The [type] parameter must be a valid [ResourceRecordType] value. The +/// [multicast] parameter must not be null. +/// +/// This is a low level API; most consumers should prefer +/// [ResourceRecordQuery.encode], which offers some convenience wrappers around +/// selecting the correct [type] and setting the [name] parameter correctly. +List encodeMDnsQuery( + String name, { + int type = ResourceRecordType.addressIPv4, + bool multicast = true, +}) { + assert(ResourceRecordType.debugAssertValid(type)); + + final List nameParts = processDnsNameParts(name); + final List> rawNameParts = nameParts + .map>((String part) => utf8.encode(part)) + .toList(); + + // Calculate the size of the packet. + int size = _kHeaderSize; + for (var i = 0; i < rawNameParts.length; i++) { + size += 1 + rawNameParts[i].length; + } + + size += 1; // End with empty part + size += 4; // Trailer (QTYPE and QCLASS). + final data = Uint8List(size); + final packetByteData = ByteData.view(data.buffer); + // Query identifier - just use 0. + packetByteData.setUint16(_kIdOffset, 0); + // Flags - 0 for query. + packetByteData.setUint16(_kFlagsOffset, 0); + // Query count. + packetByteData.setUint16(_kQdcountOffset, 1); + // Number of answers - 0 for query. + packetByteData.setUint16(_kAncountOffset, 0); + // Number of name server records - 0 for query. + packetByteData.setUint16(_kNscountOffset, 0); + // Number of resource records - 0 for query. + packetByteData.setUint16(_kArcountOffset, 0); + int offset = _kHeaderSize; + for (var i = 0; i < rawNameParts.length; i++) { + data[offset++] = rawNameParts[i].length; + data.setRange(offset, offset + rawNameParts[i].length, rawNameParts[i]); + offset += rawNameParts[i].length; + } + + data[offset] = 0; // Empty part. + offset++; + packetByteData.setUint16(offset, type); // QTYPE. + offset += 2; + packetByteData.setUint16( + offset, + ResourceRecordClass.internet | (multicast ? QuestionType.multicast : QuestionType.unicast), + ); + + return data; +} + +/// Result of reading a Fully Qualified Domain Name (FQDN). +class _FQDNReadResult { + /// Creates a new FQDN read result. + _FQDNReadResult(this.fqdnParts, this.bytesRead); + + /// The raw parts of the FQDN. + final List fqdnParts; + + /// The bytes consumed from the packet for this FQDN. + final int bytesRead; + + /// Returns the Fully Qualified Domain Name. + String get fqdn => fqdnParts.join('.'); + + @override + String toString() => fqdn; +} + +/// Reads a FQDN from raw packet data. +String readFQDN(List packet, [int offset = 0]) { + final Uint8List data = packet is Uint8List ? packet : Uint8List.fromList(packet); + final byteData = ByteData.view(data.buffer); + + return _readFQDN(data, byteData, offset, data.length).fqdn; +} + +// Read a FQDN at the given offset. Returns a pair with the FQDN +// parts and the number of bytes consumed. +// +// If decoding fails (e.g. due to an invalid packet) `null` is returned. +_FQDNReadResult _readFQDN(Uint8List data, ByteData byteData, int offset, int length) { + void checkLength(int required) { + if (length < required) { + throw MDnsDecodeException(required); + } + } + + final parts = []; + final prevOffset = offset; + final offsetsToVisit = [offset]; + var upperLimitOffset = offset; + var highestOffsetRead = offset; + + while (offsetsToVisit.isNotEmpty) { + offset = offsetsToVisit.removeLast(); + + while (true) { + // At least one byte is required. + checkLength(offset + 1); + // Check for compressed. + if (data[offset] & 0xc0 == 0xc0) { + // At least two bytes are required for a compressed FQDN (see RFC1035 section 4.1.4). + checkLength(offset + 2); + + // A compressed FQDN has a new offset in the lower 14 bits. + final int pointerDest = byteData.getUint16(offset) & ~0xc000; + // Pointers can only point to prior occurances of some name. + // This check also guards against pointers that form loops. + if (pointerDest >= upperLimitOffset) { + throw MDnsDecodeException(offset); + } + upperLimitOffset = pointerDest; + offsetsToVisit.add(pointerDest); + highestOffsetRead = max(highestOffsetRead, offset + 2); + break; + } else { + // A normal FQDN part has a length and a UTF-8 encoded name + // part. If the length is 0 this is the end of the FQDN. + final int partLength = data[offset]; + offset++; + if (partLength > 0) { + checkLength(offset + partLength); + final partBytes = Uint8List.view(data.buffer, offset, partLength); + offset += partLength; + // According to the RFC, this is supposed to be utf-8 encoded, but + // we should continue decoding even if it isn't to avoid dropping the + // rest of the data, which might still be useful. + parts.add(utf8.decode(partBytes, allowMalformed: true)); + highestOffsetRead = max(highestOffsetRead, offset); + } else { + highestOffsetRead = max(highestOffsetRead, offset); + break; + } + } + } + } + return _FQDNReadResult(parts, highestOffsetRead - prevOffset); +} + +/// Decode an mDNS response packet. +/// +/// If decoding fails (e.g. due to an invalid packet) `null` is returned. +/// +/// See https://tools.ietf.org/html/rfc1035 for the format. +List? decodeMDnsResponse(List packet) { + final int length = packet.length; + if (length < _kHeaderSize) { + return null; + } + + final Uint8List data = packet is Uint8List ? packet : Uint8List.fromList(packet); + final packetBytes = ByteData.view(data.buffer); + + final int answerCount = packetBytes.getUint16(_kAncountOffset); + final int authorityCount = packetBytes.getUint16(_kNscountOffset); + final int additionalCount = packetBytes.getUint16(_kArcountOffset); + final int remainingCount = answerCount + authorityCount + additionalCount; + + if (remainingCount == 0) { + return null; + } + + final int questionCount = packetBytes.getUint16(_kQdcountOffset); + int offset = _kHeaderSize; + + void checkLength(int required) { + if (length < required) { + throw MDnsDecodeException(required); + } + } + + ResourceRecord? readResourceRecord() { + // First read the FQDN. + final _FQDNReadResult result = _readFQDN(data, packetBytes, offset, length); + final String fqdn = result.fqdn; + offset += result.bytesRead; + checkLength(offset + 2); + final int type = packetBytes.getUint16(offset); + offset += 2; + // The first bit of the rrclass field is set to indicate that the answer is + // unique and the querier should flush the cached answer for this name + // (RFC 6762, Sec. 10.2). We ignore it for now since we don't cache answers. + checkLength(offset + 2); + final int resourceRecordClass = packetBytes.getUint16(offset) & 0x7fff; + + if (resourceRecordClass != ResourceRecordClass.internet) { + // We do not support other classes. + return null; + } + + offset += 2; + checkLength(offset + 4); + final int ttl = packetBytes.getInt32(offset); + offset += 4; + + checkLength(offset + 2); + final int readDataLength = packetBytes.getUint16(offset); + offset += 2; + final int validUntil = DateTime.now().millisecondsSinceEpoch + ttl * 1000; + switch (type) { + case ResourceRecordType.addressIPv4: + checkLength(offset + readDataLength); + final addr = StringBuffer(); + final int stop = offset + readDataLength; + addr.write(packetBytes.getUint8(offset)); + offset++; + for (; offset < stop; offset++) { + addr.write('.'); + addr.write(packetBytes.getUint8(offset)); + } + return IPAddressResourceRecord(fqdn, validUntil, address: InternetAddress(addr.toString())); + case ResourceRecordType.addressIPv6: + checkLength(offset + readDataLength); + final addr = StringBuffer(); + final int stop = offset + readDataLength; + addr.write(packetBytes.getUint16(offset).toRadixString(16)); + offset += 2; + for (; offset < stop; offset += 2) { + addr.write(':'); + addr.write(packetBytes.getUint16(offset).toRadixString(16)); + } + return IPAddressResourceRecord(fqdn, validUntil, address: InternetAddress(addr.toString())); + case ResourceRecordType.service: + checkLength(offset + 2); + final int priority = packetBytes.getUint16(offset); + offset += 2; + checkLength(offset + 2); + final int weight = packetBytes.getUint16(offset); + offset += 2; + checkLength(offset + 2); + final int port = packetBytes.getUint16(offset); + offset += 2; + final _FQDNReadResult result = _readFQDN(data, packetBytes, offset, length); + offset += result.bytesRead; + return SrvResourceRecord( + fqdn, + validUntil, + target: result.fqdn, + port: port, + priority: priority, + weight: weight, + ); + case ResourceRecordType.serverPointer: + checkLength(offset + readDataLength); + final _FQDNReadResult result = _readFQDN(data, packetBytes, offset, length); + offset += readDataLength; + return PtrResourceRecord(fqdn, validUntil, domainName: result.fqdn); + case ResourceRecordType.text: + checkLength(offset + readDataLength); + // The first byte of the buffer is the length of the first string of + // the TXT record. Further length-prefixed strings may follow. We + // concatenate them with newlines. + final strings = StringBuffer(); + var index = 0; + while (index < readDataLength) { + final int txtLength = data[offset + index]; + index++; + if (txtLength == 0) { + continue; + } + final String text = utf8.decode( + Uint8List.view(data.buffer, offset + index, txtLength), + allowMalformed: true, + ); + strings.writeln(text); + index += txtLength; + } + offset += readDataLength; + return TxtResourceRecord(fqdn, validUntil, text: strings.toString()); + default: + checkLength(offset + readDataLength); + offset += readDataLength; + return null; + } + } + + // This list can't be fixed length right now because we might get + // resource record types we don't support, and consumers expect this list + // to not have null entries. + final result = []; + + try { + for (var i = 0; i < questionCount; i++) { + final _FQDNReadResult result = _readFQDN(data, packetBytes, offset, length); + offset += result.bytesRead; + checkLength(offset + 4); + offset += 4; + } + for (var i = 0; i < remainingCount; i++) { + final ResourceRecord? record = readResourceRecord(); + if (record != null) { + result.add(record); + } + } + } on MDnsDecodeException { + // If decoding fails return null. + return null; + } + return result; +} + +/// This exception is thrown by the decoder when the packet is invalid. +class MDnsDecodeException implements Exception { + /// Creates a new MDnsDecodeException, indicating an error in decoding at the + /// specified [offset]. + /// + /// The [offset] parameter should not be null. + const MDnsDecodeException(this.offset); + + /// The offset in the packet at which the exception occurred. + final int offset; + + @override + String toString() => 'Decoding error at $offset'; +} diff --git a/packages/multicast_dns/lib/src/resource_record.dart b/packages/multicast_dns/lib/src/resource_record.dart new file mode 100644 index 0000000..86a0a4e --- /dev/null +++ b/packages/multicast_dns/lib/src/resource_record.dart @@ -0,0 +1,330 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; +import 'constants.dart'; +import 'packet.dart'; + +/// Enumeration of support resource record types. +abstract class ResourceRecordType { + // This class is intended to be used as a namespace, and should not be + // extended directly. + ResourceRecordType._(); + + /// An IPv4 Address record, also known as an "A" record. It has a value of 1. + static const int addressIPv4 = 1; + + /// An IPv6 Address record, also known as an "AAAA" record. It has a vaule of + /// 28. + static const int addressIPv6 = 28; + + /// An IP Address reverse map record, also known as a "PTR" recored. It has a + /// value of 12. + static const int serverPointer = 12; + + /// An available service record, also known as an "SRV" record. It has a + /// value of 33. + static const int service = 33; + + /// A text record, also known as a "TXT" record. It has a value of 16. + static const int text = 16; + + // TODO(dnfield): Support ANY in some meaningful way. Might be server only. + // /// A query for all records of all types known to the name server. + // static const int any = 255; + + /// Checks that a given int is a valid ResourceRecordType. + /// + /// This method is intended to be called only from an `assert()`. + static bool debugAssertValid(int resourceRecordType) { + return resourceRecordType == addressIPv4 || + resourceRecordType == addressIPv6 || + resourceRecordType == serverPointer || + resourceRecordType == service || + resourceRecordType == text; + } + + /// Prints a debug-friendly version of the resource record type value. + static String toDebugString(int resourceRecordType) { + switch (resourceRecordType) { + case addressIPv4: + return 'A (IPv4 Address)'; + case addressIPv6: + return 'AAAA (IPv6 Address)'; + case serverPointer: + return 'PTR (Domain Name Pointer)'; + case service: + return 'SRV (Service record)'; + case text: + return 'TXT (Text)'; + } + return 'Unknown ($resourceRecordType)'; + } +} + +/// Represents a DNS query. +@immutable +class ResourceRecordQuery { + /// Creates a new ResourceRecordQuery. + /// + /// Most callers should prefer one of the named constructors. + ResourceRecordQuery(this.resourceRecordType, this.fullyQualifiedName, this.questionType) + : assert(ResourceRecordType.debugAssertValid(resourceRecordType)); + + /// An A (IPv4) query. + ResourceRecordQuery.addressIPv4(String name, {bool isMulticast = true}) + : this( + ResourceRecordType.addressIPv4, + name, + isMulticast ? QuestionType.multicast : QuestionType.unicast, + ); + + /// An AAAA (IPv6) query. + ResourceRecordQuery.addressIPv6(String name, {bool isMulticast = true}) + : this( + ResourceRecordType.addressIPv6, + name, + isMulticast ? QuestionType.multicast : QuestionType.unicast, + ); + + /// A PTR (Server pointer) query. + ResourceRecordQuery.serverPointer(String name, {bool isMulticast = true}) + : this( + ResourceRecordType.serverPointer, + name, + isMulticast ? QuestionType.multicast : QuestionType.unicast, + ); + + /// An SRV (Service) query. + ResourceRecordQuery.service(String name, {bool isMulticast = true}) + : this( + ResourceRecordType.service, + name, + isMulticast ? QuestionType.multicast : QuestionType.unicast, + ); + + /// A TXT (Text record) query. + ResourceRecordQuery.text(String name, {bool isMulticast = true}) + : this( + ResourceRecordType.text, + name, + isMulticast ? QuestionType.multicast : QuestionType.unicast, + ); + + /// Tye type of resource record - one of [ResourceRecordType]'s values. + final int resourceRecordType; + + /// The Fully Qualified Domain Name associated with the request. + final String fullyQualifiedName; + + /// The [QuestionType], i.e. multicast or unicast. + final int questionType; + + /// Convenience accessor to determine whether the question type is multicast. + bool get isMulticast => questionType == QuestionType.multicast; + + /// Convenience accessor to determine whether the question type is unicast. + bool get isUnicast => questionType == QuestionType.unicast; + + /// Encodes this query to the raw wire format. + List encode() { + return encodeMDnsQuery(fullyQualifiedName, type: resourceRecordType, multicast: isMulticast); + } + + @override + int get hashCode => Object.hash(resourceRecordType, fullyQualifiedName, questionType); + + @override + bool operator ==(Object other) { + return other is ResourceRecordQuery && + other.resourceRecordType == resourceRecordType && + other.fullyQualifiedName == fullyQualifiedName && + other.questionType == questionType; + } + + @override + String toString() => + 'ResourceRecordQuery{$fullyQualifiedName, type: ${ResourceRecordType.toDebugString(resourceRecordType)}, isMulticast: $isMulticast}'; +} + +/// Base implementation of DNS resource records (RRs). +@immutable +abstract class ResourceRecord { + /// Creates a new ResourceRecord. + const ResourceRecord(this.resourceRecordType, this.name, this.validUntil); + + /// The FQDN for this record. + final String name; + + /// The epoch time at which point this record is valid for in the cache. + final int validUntil; + + /// The raw resource record value. See [ResourceRecordType] for supported values. + final int resourceRecordType; + + String get _additionalInfo; + + @override + String toString() => + '$runtimeType{$name, validUntil: ${DateTime.fromMillisecondsSinceEpoch(validUntil)}, $_additionalInfo}'; + + @override + int get hashCode => Object.hash(name, validUntil, resourceRecordType); + + @override + bool operator ==(Object other) { + return other is ResourceRecord && + other.name == name && + other.validUntil == validUntil && + other.resourceRecordType == resourceRecordType; + } + + /// Low level method for encoding this record into an mDNS packet. + /// + /// Subclasses should provide the packet format of their encapsulated data + /// into a `Uint8List`, which could then be used to write a pakcet to send + /// as a response for this record type. + Uint8List encodeResponseRecord(); +} + +/// A Service Pointer for reverse mapping an IP address (DNS "PTR"). +class PtrResourceRecord extends ResourceRecord { + /// Creates a new PtrResourceRecord. + const PtrResourceRecord(String name, int validUntil, {required this.domainName}) + : super(ResourceRecordType.serverPointer, name, validUntil); + + /// The FQDN for this record. + final String domainName; + + @override + String get _additionalInfo => 'domainName: $domainName'; + + @override + int get hashCode => Object.hash(domainName.hashCode, super.hashCode); + + @override + bool operator ==(Object other) { + return super == other && other is PtrResourceRecord && other.domainName == domainName; + } + + @override + Uint8List encodeResponseRecord() { + return Uint8List.fromList(utf8.encode(domainName)); + } +} + +/// An IP Address record for IPv4 (DNS "A") or IPv6 (DNS "AAAA") records. +class IPAddressResourceRecord extends ResourceRecord { + /// Creates a new IPAddressResourceRecord. + IPAddressResourceRecord(String name, int validUntil, {required this.address}) + : super( + address.type == InternetAddressType.IPv4 + ? ResourceRecordType.addressIPv4 + : ResourceRecordType.addressIPv6, + name, + validUntil, + ); + + /// The [InternetAddress] for this record. + final InternetAddress address; + + @override + String get _additionalInfo => 'address: $address'; + + @override + int get hashCode => Object.hash(address.hashCode, super.hashCode); + + @override + bool operator ==(Object other) { + return super == other && other is IPAddressResourceRecord && other.address == address; + } + + @override + Uint8List encodeResponseRecord() { + return Uint8List.fromList(address.rawAddress); + } +} + +/// A Service record, capturing a host target and port (DNS "SRV"). +class SrvResourceRecord extends ResourceRecord { + /// Creates a new service record. + const SrvResourceRecord( + String name, + int validUntil, { + required this.target, + required this.port, + required this.priority, + required this.weight, + }) : super(ResourceRecordType.service, name, validUntil); + + /// The hostname for this record. + final String target; + + /// The port for this record. + final int port; + + /// The relative priority of this service. + final int priority; + + /// The weight (used when multiple services have the same priority). + final int weight; + + @override + String get _additionalInfo => + 'target: $target, port: $port, priority: $priority, weight: $weight'; + + @override + int get hashCode => Object.hash(target, port, priority, weight, super.hashCode); + + @override + bool operator ==(Object other) { + return super == other && + other is SrvResourceRecord && + other.target == target && + other.port == port && + other.priority == priority && + other.weight == weight; + } + + @override + Uint8List encodeResponseRecord() { + final List data = utf8.encode(target); + final result = Uint8List(data.length + 7); + final resultData = ByteData.view(result.buffer); + resultData.setUint16(0, priority); + resultData.setUint16(2, weight); + resultData.setUint16(4, port); + result[6] = data.length; + return result..setRange(7, data.length, data); + } +} + +/// A Text record, contianing additional textual data (DNS "TXT"). +class TxtResourceRecord extends ResourceRecord { + /// Creates a new text record. + const TxtResourceRecord(String name, int validUntil, {required this.text}) + : super(ResourceRecordType.text, name, validUntil); + + /// The raw text from this record. + final String text; + + @override + String get _additionalInfo => 'text: $text'; + + @override + int get hashCode => Object.hash(text.hashCode, super.hashCode); + + @override + bool operator ==(Object other) => + super == other && other is TxtResourceRecord && other.text == text; + + @override + Uint8List encodeResponseRecord() { + return Uint8List.fromList(utf8.encode(text)); + } +} diff --git a/packages/multicast_dns/pubspec.yaml b/packages/multicast_dns/pubspec.yaml new file mode 100644 index 0000000..8a3b5a1 --- /dev/null +++ b/packages/multicast_dns/pubspec.yaml @@ -0,0 +1,19 @@ +name: multicast_dns +description: Dart package for performing mDNS queries (e.g. Bonjour, Avahi). +repository: https://github.com/flutter/core-packages/tree/main/packages/multicast_dns +issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+multicast_dns%22 +version: 0.3.3 + +environment: + sdk: ^3.10.0 + +dependencies: + meta: ^1.3.0 + +dev_dependencies: + test: "^1.16.5" + +topics: + - bonjour + - mdns + - network diff --git a/packages/multicast_dns/test/client_test.dart b/packages/multicast_dns/test/client_test.dart new file mode 100644 index 0000000..1250802 --- /dev/null +++ b/packages/multicast_dns/test/client_test.dart @@ -0,0 +1,326 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; +// import 'dart:typed_data'; + +import 'package:multicast_dns/multicast_dns.dart'; +import 'package:test/fake.dart'; +import 'package:test/test.dart'; + +void main() { + test('Can inject datagram socket factory and configure mdns port', () async { + late int lastPort; + final datagramSocket = FakeRawDatagramSocket(); + final client = MDnsClient( + rawDatagramSocketFactory: + ( + dynamic host, + int port, { + bool reuseAddress = true, + bool reusePort = true, + int ttl = 1, + }) async { + lastPort = port; + return datagramSocket; + }, + ); + + await client.start( + mDnsPort: 1234, + interfacesFactory: (InternetAddressType type) async => [], + ); + + expect(lastPort, 1234); + }); + + test('Closes IPv4 sockets', () async { + final datagramSocket = FakeRawDatagramSocket(); + final client = MDnsClient( + rawDatagramSocketFactory: + ( + dynamic host, + int port, { + bool reuseAddress = true, + bool reusePort = true, + int ttl = 1, + }) async { + return datagramSocket; + }, + ); + + await client.start( + mDnsPort: 1234, + interfacesFactory: (InternetAddressType type) async => [], + ); + expect(datagramSocket.closed, false); + client.stop(); + expect(datagramSocket.closed, true); + }); + + test('Closes IPv6 sockets', () async { + final datagramSocket = FakeRawDatagramSocket(); + datagramSocket.address = InternetAddress.anyIPv6; + final client = MDnsClient( + rawDatagramSocketFactory: + ( + dynamic host, + int port, { + bool reuseAddress = true, + bool reusePort = true, + int ttl = 1, + }) async { + return datagramSocket; + }, + ); + + await client.start( + mDnsPort: 1234, + interfacesFactory: (InternetAddressType type) async => [], + ); + expect(datagramSocket.closed, false); + client.stop(); + expect(datagramSocket.closed, true); + }); + + test('start() is idempotent', () async { + final datagramSocket = FakeRawDatagramSocket(); + datagramSocket.address = InternetAddress.anyIPv4; + final client = MDnsClient( + rawDatagramSocketFactory: + ( + dynamic host, + int port, { + bool reuseAddress = true, + bool reusePort = true, + int ttl = 1, + }) async { + return datagramSocket; + }, + ); + + await client.start(interfacesFactory: (InternetAddressType type) async => []); + await client.start(); + await client.lookup(ResourceRecordQuery.serverPointer('_')).toList(); + }); + + // group('Bind a single socket to ANY IPv4 and more than one when IPv6', () { + // final testCases = >[ + // { + // 'name': 'IPv4', + // 'datagramSocketType': InternetAddress.anyIPv4, + // 'interfacePrefix': '192.168.2.', + // }, + // { + // 'name': 'IPv6', + // 'datagramSocketType': InternetAddress.anyIPv6, + // 'interfacePrefix': '2001:0db8:85a3:0000:0000:8a2e:7335:030', + // }, + // ]; + + // for (final testCase in testCases) { + // test('Bind a single socket to ANY ${testCase["name"]}', () async { + // final datagramSocket = FakeRawDatagramSocket(); + + // datagramSocket.address = + // testCase['datagramSocketType']! as InternetAddress; + + // final selectedInterfacesForSendingPackets = []; + // final client = MDnsClient( + // rawDatagramSocketFactory: + // ( + // dynamic host, + // int port, { + // bool reuseAddress = true, + // bool reusePort = true, + // int ttl = 1, + // }) async { + // selectedInterfacesForSendingPackets.add(host); + // return datagramSocket; + // }, + // ); + + // const numberOfFakeInterfaces = 10; + // Future> fakeNetworkInterfacesFactory( + // InternetAddressType type, + // ) async { + // final fakeInterfaces = []; + + // // Generate "fake" interfaces + // for (var i = 0; i < numberOfFakeInterfaces; i++) { + // fakeInterfaces.add( + // FakeNetworkInterface('inetfake$i', [ + // FakeInterfaceAddress(InternetAddress("${testCase['interfacePrefix']! as String}$i")), + // ], 0), + // ); + // } + + // // ignore: always_specify_types + // return Future.value(fakeInterfaces); + // } + + // final listenAddress = + // testCase['datagramSocketType']! as InternetAddress; + + // await client.start( + // listenAddress: listenAddress, + // mDnsPort: 1234, + // interfacesFactory: fakeNetworkInterfacesFactory, + // ); + // client.stop(); + + // if (testCase['datagramSocketType'] == InternetAddress.anyIPv4) { + // expect(selectedInterfacesForSendingPackets.length, 1); + // } else { + // // + 1 because of unspecified address (::) + // expect( + // selectedInterfacesForSendingPackets.length, + // numberOfFakeInterfaces + 1, + // ); + // } + // expect(selectedInterfacesForSendingPackets[0], listenAddress.address); + // }); + // } + // }); + + test('Calls onError callback in case of socket error', () async { + final datagramSocket = FakeRawDatagramSocketThatSendsError(); + final client = MDnsClient( + rawDatagramSocketFactory: + ( + dynamic host, + int port, { + bool reuseAddress = true, + bool reusePort = true, + int ttl = 1, + }) async { + return datagramSocket; + }, + ); + + final onErrorCalledCompleter = Completer(); + await client.start( + mDnsPort: 1234, + interfacesFactory: (InternetAddressType type) async => [], + onError: (Object e) { + expect(e, 'Error'); + onErrorCalledCompleter.complete(); + }, + ); + + await onErrorCalledCompleter.future.timeout(const Duration(seconds: 5)); + }); +} + +class FakeRawDatagramSocket extends Fake implements RawDatagramSocket { + @override + InternetAddress address = InternetAddress.anyIPv4; + + @override + StreamSubscription listen( + void Function(RawSocketEvent event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + return const Stream.empty().listen( + onData, + onError: onError, + cancelOnError: cancelOnError, + onDone: onDone, + ); + } + + bool closed = false; + + @override + void close() { + closed = true; + } + + @override + int send(List buffer, InternetAddress address, int port) { + return buffer.length; + } + + @override + void joinMulticast(InternetAddress group, [NetworkInterface? interface]) { + // nothing to do here + } + @override + void setRawOption(RawSocketOption option) { + // nothing to do here + } +} + +class FakeRawDatagramSocketThatSendsError extends Fake implements RawDatagramSocket { + @override + InternetAddress address = InternetAddress.anyIPv4; + + @override + StreamSubscription listen( + void Function(RawSocketEvent event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + return Stream.error( + 'Error', + ).listen(onData, onError: onError, cancelOnError: cancelOnError, onDone: onDone); + } +} + +// class FakeInterfaceAddress implements InterfaceAddress { +// const FakeInterfaceAddress(this._internetAddress); + +// final InternetAddress _internetAddress; + +// @override +// String get address => _internetAddress.address; + +// @override +// String get host => _internetAddress.host; + +// @override +// bool get isLinkLocal => _internetAddress.isLinkLocal; + +// @override +// bool get isLoopback => _internetAddress.isLoopback; + +// @override +// bool get isMulticast => _internetAddress.isMulticast; + +// @override +// Uint8List get rawAddress => _internetAddress.rawAddress; + +// @override +// Future reverse() => _internetAddress.reverse(); + +// @override +// InternetAddressType get type => _internetAddress.type; + +// @override +// int get prefixLength => 0; + +// @override +// InternetAddress? get broadcast => throw UnimplementedError(); +// } + +// class FakeNetworkInterface implements NetworkInterface { +// FakeNetworkInterface(this._name, this._addresses, this._index); + +// final String _name; +// final List _addresses; +// final int _index; + +// @override +// List get addresses => _addresses; + +// @override +// String get name => _name; + +// @override +// int get index => _index; +// } diff --git a/packages/multicast_dns/test/decode_test.dart b/packages/multicast_dns/test/decode_test.dart new file mode 100644 index 0000000..930a4b9 --- /dev/null +++ b/packages/multicast_dns/test/decode_test.dart @@ -0,0 +1,1636 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import 'package:multicast_dns/src/packet.dart'; +import 'package:multicast_dns/src/resource_record.dart'; +import 'package:test/test.dart'; + +const int _kSrvHeaderSize = 6; + +void main() { + testValidPackages(); + testBadPackages(); + testNonUtf8DomainName(); + // testHexDumpList(); + testPTRRData(); + testSRVRData(); +} + +void testValidPackages() { + test('Can decode valid packets', () { + List result = decodeMDnsResponse(package1)!; + expect(result, isNotNull); + expect(result.length, 1); + var ipResult = result[0] as IPAddressResourceRecord; + expect(ipResult.name, 'raspberrypi.local'); + expect(ipResult.address.address, '192.168.1.191'); + + result = decodeMDnsResponse(package2)!; + expect(result.length, 2); + ipResult = result[0] as IPAddressResourceRecord; + expect(ipResult.name, 'raspberrypi.local'); + expect(ipResult.address.address, '192.168.1.191'); + ipResult = result[1] as IPAddressResourceRecord; + expect(ipResult.name, 'raspberrypi.local'); + expect(ipResult.address.address, '169.254.95.83'); + + result = decodeMDnsResponse(package3)!; + expect(result.length, 8); + expect(result, [ + TxtResourceRecord( + 'raspberrypi [b8:27:eb:03:92:4b]._workstation._tcp.local', + result[0].validUntil, + text: '', + ), + PtrResourceRecord( + '_udisks-ssh._tcp.local', + result[1].validUntil, + domainName: 'raspberrypi._udisks-ssh._tcp.local', + ), + SrvResourceRecord( + 'raspberrypi._udisks-ssh._tcp.local', + result[2].validUntil, + target: 'raspberrypi.local', + port: 22, + priority: 0, + weight: 0, + ), + TxtResourceRecord('raspberrypi._udisks-ssh._tcp.local', result[3].validUntil, text: ''), + PtrResourceRecord( + '_services._dns-sd._udp.local', + result[4].validUntil, + domainName: '_udisks-ssh._tcp.local', + ), + PtrResourceRecord( + '_workstation._tcp.local', + result[5].validUntil, + domainName: 'raspberrypi [b8:27:eb:03:92:4b]._workstation._tcp.local', + ), + SrvResourceRecord( + 'raspberrypi [b8:27:eb:03:92:4b]._workstation._tcp.local', + result[6].validUntil, + target: 'raspberrypi.local', + port: 9, + priority: 0, + weight: 0, + ), + PtrResourceRecord( + '_services._dns-sd._udp.local', + result[7].validUntil, + domainName: '_workstation._tcp.local', + ), + ]); + + result = decodeMDnsResponse(packagePtrResponse)!; + expect(6, result.length); + expect(result, [ + PtrResourceRecord( + '_fletch_agent._tcp.local', + result[0].validUntil, + domainName: 'fletch-agent on raspberrypi._fletch_agent._tcp.local', + ), + TxtResourceRecord( + 'fletch-agent on raspberrypi._fletch_agent._tcp.local', + result[1].validUntil, + text: '', + ), + SrvResourceRecord( + 'fletch-agent on raspberrypi._fletch_agent._tcp.local', + result[2].validUntil, + target: 'raspberrypi.local', + port: 12121, + priority: 0, + weight: 0, + ), + IPAddressResourceRecord( + 'raspberrypi.local', + result[3].validUntil, + address: InternetAddress('fe80:0000:0000:0000:ba27:ebff:fe69:6e3a'), + ), + IPAddressResourceRecord( + 'raspberrypi.local', + result[4].validUntil, + address: InternetAddress('192.168.1.1'), + ), + IPAddressResourceRecord( + 'raspberrypi.local', + result[5].validUntil, + address: InternetAddress('169.254.167.172'), + ), + ]); + }); + + // Fixes https://github.com/flutter/flutter/issues/31854 + test('Can decode packages with question, answer and additional', () { + final List result = decodeMDnsResponse(packetWithQuestionAnArCount)!; + expect(result, isNotNull); + expect(result.length, 2); + expect(result, [ + PtrResourceRecord( + '_______________.____._____', + result[0].validUntil, + domainName: '_______________________._______________.____._____', + ), + PtrResourceRecord( + '_______________.____._____', + result[1].validUntil, + domainName: '____________________________._______________.____._____', + ), + ]); + }); + + // Fixes https://github.com/flutter/flutter/issues/31854 + test('Can decode packages without question and with answer and additional', () { + final List result = decodeMDnsResponse(packetWithoutQuestionWithAnArCount)!; + expect(result, isNotNull); + expect(result.length, 2); + expect(result, [ + PtrResourceRecord( + '_______________.____._____', + result[0].validUntil, + domainName: '______________________._______________.____._____', + ), + TxtResourceRecord( + '______________________.____________.____._____', + result[1].validUntil, + text: 'model=MacBookPro14,3\nosxvers=18\necolor=225,225,223\n', + ), + ]); + }); + + test('Can decode packages with a long text resource', () { + final List result = decodeMDnsResponse(packetWithLongTxt)!; + expect(result, isNotNull); + expect(result.length, 2); + expect(result, [ + PtrResourceRecord( + '_______________.____._____', + result[0].validUntil, + domainName: '______________________._______________.____._____', + ), + TxtResourceRecord( + '______________________.____________.____._____', + result[1].validUntil, + text: '${')' * 129}\n', + ), + ]); + }); +} + +void testBadPackages() { + test('Returns null for invalid packets', () { + for (final p in >[package1, package2, package3]) { + for (var i = 0; i < p.length; i++) { + expect(decodeMDnsResponse(p.sublist(0, i)), isNull); + } + } + }); + + test('Detects cyclic pointers and returns null', () { + expect(decodeMDnsResponse(cycle), isNull); + }); +} + +void testPTRRData() { + test('Can read FQDN from PTR data', () { + expect('sgjesse-macbookpro2 [78:31:c1:b8:55:38]._workstation._tcp.local', readFQDN(ptrRData)); + expect('fletch-agent._fletch_agent._tcp.local', readFQDN(ptrRData2)); + }); +} + +void testSRVRData() { + test('Can read FQDN from SRV data', () { + expect('fletch.local', readFQDN(srvRData, _kSrvHeaderSize)); + }); +} + +void testNonUtf8DomainName() { + test('Returns non-null for non-utf8 domain name', () { + final List result = decodeMDnsResponse(nonUtf8Package)!; + expect(result, isNotNull); + expect(result[0] is TxtResourceRecord, isTrue); + final txt = result[0] as TxtResourceRecord; + expect(txt.name, contains('�')); + }); +} + +// One address. +const List package1 = [ + 0x00, + 0x00, + 0x84, + 0x00, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x0b, + 0x72, + 0x61, + 0x73, + 0x70, + 0x62, + 0x65, + 0x72, + 0x72, + 0x79, + 0x70, + 0x69, + 0x05, + 0x6c, + 0x6f, + 0x63, + 0x61, + 0x6c, + 0x00, + 0x00, + 0x01, + 0x80, + 0x01, + 0x00, + 0x00, + 0x00, + 0x78, + 0x00, + 0x04, + 0xc0, + 0xa8, + 0x01, + 0xbf, +]; + +// Two addresses. +const List package2 = [ + 0x00, + 0x00, + 0x84, + 0x00, + 0x00, + 0x00, + 0x00, + 0x02, + 0x00, + 0x00, + 0x00, + 0x00, + 0x0b, + 0x72, + 0x61, + 0x73, + 0x70, + 0x62, + 0x65, + 0x72, + 0x72, + 0x79, + 0x70, + 0x69, + 0x05, + 0x6c, + 0x6f, + 0x63, + 0x61, + 0x6c, + 0x00, + 0x00, + 0x01, + 0x80, + 0x01, + 0x00, + 0x00, + 0x00, + 0x78, + 0x00, + 0x04, + 0xc0, + 0xa8, + 0x01, + 0xbf, + 0xc0, + 0x0c, + 0x00, + 0x01, + 0x80, + 0x01, + 0x00, + 0x00, + 0x00, + 0x78, + 0x00, + 0x04, + 0xa9, + 0xfe, + 0x5f, + 0x53, +]; + +// Eight mixed answers. +const List package3 = [ + 0x00, + 0x00, + 0x84, + 0x00, + 0x00, + 0x00, + 0x00, + 0x08, + 0x00, + 0x00, + 0x00, + 0x00, + 0x1f, + 0x72, + 0x61, + 0x73, + 0x70, + 0x62, + 0x65, + 0x72, + 0x72, + 0x79, + 0x70, + 0x69, + 0x20, + 0x5b, + 0x62, + 0x38, + 0x3a, + 0x32, + 0x37, + 0x3a, + 0x65, + 0x62, + 0x3a, + 0x30, + 0x33, + 0x3a, + 0x39, + 0x32, + 0x3a, + 0x34, + 0x62, + 0x5d, + 0x0c, + 0x5f, + 0x77, + 0x6f, + 0x72, + 0x6b, + 0x73, + 0x74, + 0x61, + 0x74, + 0x69, + 0x6f, + 0x6e, + 0x04, + 0x5f, + 0x74, + 0x63, + 0x70, + 0x05, + 0x6c, + 0x6f, + 0x63, + 0x61, + 0x6c, + 0x00, + 0x00, + 0x10, + 0x80, + 0x01, + 0x00, + 0x00, + 0x11, + 0x94, + 0x00, + 0x01, + 0x00, + 0x0b, + 0x5f, + 0x75, + 0x64, + 0x69, + 0x73, + 0x6b, + 0x73, + 0x2d, + 0x73, + 0x73, + 0x68, + 0xc0, + 0x39, + 0x00, + 0x0c, + 0x00, + 0x01, + 0x00, + 0x00, + 0x11, + 0x94, + 0x00, + 0x0e, + 0x0b, + 0x72, + 0x61, + 0x73, + 0x70, + 0x62, + 0x65, + 0x72, + 0x72, + 0x79, + 0x70, + 0x69, + 0xc0, + 0x50, + 0xc0, + 0x68, + 0x00, + 0x21, + 0x80, + 0x01, + 0x00, + 0x00, + 0x00, + 0x78, + 0x00, + 0x14, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x16, + 0x0b, + 0x72, + 0x61, + 0x73, + 0x70, + 0x62, + 0x65, + 0x72, + 0x72, + 0x79, + 0x70, + 0x69, + 0xc0, + 0x3e, + 0xc0, + 0x68, + 0x00, + 0x10, + 0x80, + 0x01, + 0x00, + 0x00, + 0x11, + 0x94, + 0x00, + 0x01, + 0x00, + 0x09, + 0x5f, + 0x73, + 0x65, + 0x72, + 0x76, + 0x69, + 0x63, + 0x65, + 0x73, + 0x07, + 0x5f, + 0x64, + 0x6e, + 0x73, + 0x2d, + 0x73, + 0x64, + 0x04, + 0x5f, + 0x75, + 0x64, + 0x70, + 0xc0, + 0x3e, + 0x00, + 0x0c, + 0x00, + 0x01, + 0x00, + 0x00, + 0x11, + 0x94, + 0x00, + 0x02, + 0xc0, + 0x50, + 0xc0, + 0x2c, + 0x00, + 0x0c, + 0x00, + 0x01, + 0x00, + 0x00, + 0x11, + 0x94, + 0x00, + 0x02, + 0xc0, + 0x0c, + 0xc0, + 0x0c, + 0x00, + 0x21, + 0x80, + 0x01, + 0x00, + 0x00, + 0x00, + 0x78, + 0x00, + 0x08, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x09, + 0xc0, + 0x88, + 0xc0, + 0xa3, + 0x00, + 0x0c, + 0x00, + 0x01, + 0x00, + 0x00, + 0x11, + 0x94, + 0x00, + 0x02, + 0xc0, + 0x2c, +]; + +/// Contains compressed domain names where a there is a cycle amongst the +/// offset pointers. +const List cycle = [ + 0x00, + 0x00, + 0x84, + 0x00, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x07, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, // "example" + 0xC0, 0x16, // Pointer to "com" + 0x03, 0x63, 0x6f, 0x6d, // "com" + 0xC0, 0x0c, // Pointer to "example" + 0x00, + 0x00, + 0x01, + 0x80, + 0x01, + 0x00, + 0x00, + 0x00, + 0x78, + 0x00, + 0x04, + 0xc0, + 0xa8, + 0x01, + 0xbf, +]; + +const List packagePtrResponse = [ + 0x00, + 0x00, + 0x84, + 0x00, + 0x00, + 0x00, + 0x00, + 0x06, + 0x00, + 0x00, + 0x00, + 0x00, + 0x0d, + 0x5f, + 0x66, + 0x6c, + 0x65, + 0x74, + 0x63, + 0x68, + 0x5f, + 0x61, + 0x67, + 0x65, + 0x6e, + 0x74, + 0x04, + 0x5f, + 0x74, + 0x63, + 0x70, + 0x05, + 0x6c, + 0x6f, + 0x63, + 0x61, + 0x6c, + 0x00, + 0x00, + 0x0c, + 0x00, + 0x01, + 0x00, + 0x00, + 0x11, + 0x94, + 0x00, + 0x1e, + 0x1b, + 0x66, + 0x6c, + 0x65, + 0x74, + 0x63, + 0x68, + 0x2d, + 0x61, + 0x67, + 0x65, + 0x6e, + 0x74, + 0x20, + 0x6f, + 0x6e, + 0x20, + 0x72, + 0x61, + 0x73, + 0x70, + 0x62, + 0x65, + 0x72, + 0x72, + 0x79, + 0x70, + 0x69, + 0xc0, + 0x0c, + 0xc0, + 0x30, + 0x00, + 0x10, + 0x80, + 0x01, + 0x00, + 0x00, + 0x11, + 0x94, + 0x00, + 0x01, + 0x00, + 0xc0, + 0x30, + 0x00, + 0x21, + 0x80, + 0x01, + 0x00, + 0x00, + 0x00, + 0x78, + 0x00, + 0x14, + 0x00, + 0x00, + 0x00, + 0x00, + 0x2f, + 0x59, + 0x0b, + 0x72, + 0x61, + 0x73, + 0x70, + 0x62, + 0x65, + 0x72, + 0x72, + 0x79, + 0x70, + 0x69, + 0xc0, + 0x1f, + 0xc0, + 0x6d, + 0x00, + 0x1c, + 0x80, + 0x01, + 0x00, + 0x00, + 0x00, + 0x78, + 0x00, + 0x10, + 0xfe, + 0x80, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0xba, + 0x27, + 0xeb, + 0xff, + 0xfe, + 0x69, + 0x6e, + 0x3a, + 0xc0, + 0x6d, + 0x00, + 0x01, + 0x80, + 0x01, + 0x00, + 0x00, + 0x00, + 0x78, + 0x00, + 0x04, + 0xc0, + 0xa8, + 0x01, + 0x01, + 0xc0, + 0x6d, + 0x00, + 0x01, + 0x80, + 0x01, + 0x00, + 0x00, + 0x00, + 0x78, + 0x00, + 0x04, + 0xa9, + 0xfe, + 0xa7, + 0xac, +]; + +const List ptrRData = [ + 0x27, + 0x73, + 0x67, + 0x6a, + 0x65, + 0x73, + 0x73, + 0x65, + 0x2d, + 0x6d, + 0x61, + 0x63, + 0x62, + 0x6f, + 0x6f, + 0x6b, + 0x70, + 0x72, + 0x6f, + 0x32, + 0x20, + 0x5b, + 0x37, + 0x38, + 0x3a, + 0x33, + 0x31, + 0x3a, + 0x63, + 0x31, + 0x3a, + 0x62, + 0x38, + 0x3a, + 0x35, + 0x35, + 0x3a, + 0x33, + 0x38, + 0x5d, + 0x0c, + 0x5f, + 0x77, + 0x6f, + 0x72, + 0x6b, + 0x73, + 0x74, + 0x61, + 0x74, + 0x69, + 0x6f, + 0x6e, + 0x04, + 0x5f, + 0x74, + 0x63, + 0x70, + 0x05, + 0x6c, + 0x6f, + 0x63, + 0x61, + 0x6c, + 0x00, +]; + +const List ptrRData2 = [ + 0x0c, + 0x66, + 0x6c, + 0x65, + 0x74, + 0x63, + 0x68, + 0x2d, + 0x61, + 0x67, + 0x65, + 0x6e, + 0x74, + 0x0d, + 0x5f, + 0x66, + 0x6c, + 0x65, + 0x74, + 0x63, + 0x68, + 0x5f, + 0x61, + 0x67, + 0x65, + 0x6e, + 0x74, + 0x04, + 0x5f, + 0x74, + 0x63, + 0x70, + 0x05, + 0x6c, + 0x6f, + 0x63, + 0x61, + 0x6c, + 0x00, +]; + +const List srvRData = [ + 0x00, + 0x00, + 0x00, + 0x00, + 0x2f, + 0x59, + 0x06, + 0x66, + 0x6c, + 0x65, + 0x74, + 0x63, + 0x68, + 0x05, + 0x6c, + 0x6f, + 0x63, + 0x61, + 0x6c, + 0x00, +]; + +const List packetWithQuestionAnArCount = [ + 0, + 0, + 2, + 0, + 0, + 1, + 0, + 1, + 0, + 0, + 0, + 1, + 15, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 4, + 95, + 95, + 95, + 95, + 5, + 95, + 95, + 95, + 95, + 95, + 0, + 0, + 12, + 0, + 1, + 192, + 12, + 0, + 12, + 0, + 1, + 0, + 0, + 14, + 13, + 0, + 26, + 23, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 192, + 12, + 192, + 12, + 0, + 12, + 0, + 1, + 0, + 0, + 14, + 13, + 0, + 31, + 28, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 192, + 12, +]; + +const List packetWithoutQuestionWithAnArCount = [ + 0, + 0, + 132, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + 15, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 4, + 95, + 95, + 95, + 95, + 5, + 95, + 95, + 95, + 95, + 95, + 0, + 0, + 12, + 0, + 1, + 0, + 0, + 17, + 148, + 0, + 25, + 22, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 192, + 12, + 22, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 12, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 192, + 28, + 0, + 16, + 0, + 1, + 0, + 0, + 17, + 148, + 0, + 51, + 20, + 109, + 111, + 100, + 101, + 108, + 61, + 77, + 97, + 99, + 66, + 111, + 111, + 107, + 80, + 114, + 111, + 49, + 52, + 44, + 51, + 10, + 111, + 115, + 120, + 118, + 101, + 114, + 115, + 61, + 49, + 56, + 18, + 101, + 99, + 111, + 108, + 111, + 114, + 61, + 50, + 50, + 53, + 44, + 50, + 50, + 53, + 44, + 50, + 50, + 51, +]; + +// This is the same as packetWithoutQuestionWithAnArCount, but the text +// resource just has a single long string. If the length isn't decoded +// separately from the string, there will be utf8 decoding failures. +const List packetWithLongTxt = [ + 0, + 0, + 132, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + 15, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 4, + 95, + 95, + 95, + 95, + 5, + 95, + 95, + 95, + 95, + 95, + 0, + 0, + 12, + 0, + 1, + 0, + 0, + 17, + 148, + 0, + 25, + 22, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 192, + 12, + 22, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 12, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 95, + 192, + 28, + 0, + 16, + 0, + 1, + 0, + 0, + 17, + 148, + 0, + 51, + // Long string starts here. + 129, + 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, // 16 + 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, // 32 + 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, // + 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, // 64 + 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, // + 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, // + 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, // + 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, 41, // 128, + 41, // 129 +]; + +// Package with a domain name that is not valid utf-8. +const List nonUtf8Package = [ + 0x00, + 0x00, + 0x84, + 0x00, + 0x00, + 0x00, + 0x00, + 0x08, + 0x00, + 0x00, + 0x00, + 0x00, + 0x1f, + 0x72, + 0x61, + 0x73, + 0x70, + 0x62, + 0x65, + 0x72, + 0x72, + 0x79, + 0x70, + 0x69, + 0x20, + 0x5b, + 0x62, + 0x38, + 0x3a, + 0x32, + 0x37, + 0x3a, + 0x65, + 0x62, + 0xd2, + 0x30, + 0x33, + 0x3a, + 0x39, + 0x32, + 0x3a, + 0x34, + 0x62, + 0x5d, + 0x0c, + 0x5f, + 0x77, + 0x6f, + 0x72, + 0x6b, + 0x73, + 0x74, + 0x61, + 0x74, + 0x69, + 0x6f, + 0x6e, + 0x04, + 0x5f, + 0x74, + 0x63, + 0x70, + 0x05, + 0x6c, + 0x6f, + 0x63, + 0x61, + 0x6c, + 0x00, + 0x00, + 0x10, + 0x80, + 0x01, + 0x00, + 0x00, + 0x11, + 0x94, + 0x00, + 0x01, + 0x00, + 0x0b, + 0x5f, + 0x75, + 0x64, + 0x69, + 0x73, + 0x6b, + 0x73, + 0x2d, + 0x73, + 0x73, + 0x68, + 0xc0, + 0x39, + 0x00, + 0x0c, + 0x00, + 0x01, + 0x00, + 0x00, + 0x11, + 0x94, + 0x00, + 0x0e, + 0x0b, + 0x72, + 0x61, + 0x73, + 0x70, + 0x62, + 0x65, + 0x72, + 0x72, + 0x79, + 0x70, + 0x69, + 0xc0, + 0x50, + 0xc0, + 0x68, + 0x00, + 0x21, + 0x80, + 0x01, + 0x00, + 0x00, + 0x00, + 0x78, + 0x00, + 0x14, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x16, + 0x0b, + 0x72, + 0x61, + 0x73, + 0x70, + 0x62, + 0x65, + 0x72, + 0x72, + 0x79, + 0x70, + 0x69, + 0xc0, + 0x3e, + 0xc0, + 0x68, + 0x00, + 0x10, + 0x80, + 0x01, + 0x00, + 0x00, + 0x11, + 0x94, + 0x00, + 0x01, + 0x00, + 0x09, + 0x5f, + 0x73, + 0x65, + 0x72, + 0x76, + 0x69, + 0x63, + 0x65, + 0x73, + 0x07, + 0x5f, + 0x64, + 0x6e, + 0x73, + 0x2d, + 0x73, + 0x64, + 0x04, + 0x5f, + 0x75, + 0x64, + 0x70, + 0xc0, + 0x3e, + 0x00, + 0x0c, + 0x00, + 0x01, + 0x00, + 0x00, + 0x11, + 0x94, + 0x00, + 0x02, + 0xc0, + 0x50, + 0xc0, + 0x2c, + 0x00, + 0x0c, + 0x00, + 0x01, + 0x00, + 0x00, + 0x11, + 0x94, + 0x00, + 0x02, + 0xc0, + 0x0c, + 0xc0, + 0x0c, + 0x00, + 0x21, + 0x80, + 0x01, + 0x00, + 0x00, + 0x00, + 0x78, + 0x00, + 0x08, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x09, + 0xc0, + 0x88, + 0xc0, + 0xa3, + 0x00, + 0x0c, + 0x00, + 0x01, + 0x00, + 0x00, + 0x11, + 0x94, + 0x00, + 0x02, + 0xc0, + 0x2c, +]; diff --git a/packages/multicast_dns/test/lookup_resolver_test.dart b/packages/multicast_dns/test/lookup_resolver_test.dart new file mode 100644 index 0000000..6346c07 --- /dev/null +++ b/packages/multicast_dns/test/lookup_resolver_test.dart @@ -0,0 +1,109 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import 'package:multicast_dns/src/lookup_resolver.dart'; +import 'package:multicast_dns/src/resource_record.dart'; +import 'package:test/test.dart'; + +void main() { + testTimeout(); + testResult(); + testResult2(); + testResult3(); +} + +ResourceRecord ip4Result(String name, InternetAddress address) { + final int validUntil = DateTime.now().millisecondsSinceEpoch + 2000; + return IPAddressResourceRecord(name, validUntil, address: address); +} + +void testTimeout() { + test('Resolver does not return with short timeout', () async { + const shortTimeout = Duration(milliseconds: 1); + final resolver = LookupResolver(); + final Stream result = resolver.addPendingRequest( + ResourceRecordType.addressIPv4, + 'xxx', + shortTimeout, + ); + expect(await result.isEmpty, isTrue); + }); +} + +// One pending request and one response. +void testResult() { + test('One pending request and one response', () async { + const noTimeout = Duration(days: 1); + final resolver = LookupResolver(); + final Stream futureResult = resolver.addPendingRequest( + ResourceRecordType.addressIPv4, + 'xxx.local', + noTimeout, + ); + final ResourceRecord response = ip4Result('xxx.local', InternetAddress('1.2.3.4')); + resolver.handleResponse([response]); + final result = await futureResult.first as IPAddressResourceRecord; + expect('1.2.3.4', result.address.address); + resolver.clearPendingRequests(); + }); +} + +void testResult2() { + test('Two requests', () async { + const noTimeout = Duration(days: 1); + final resolver = LookupResolver(); + final Stream futureResult1 = resolver.addPendingRequest( + ResourceRecordType.addressIPv4, + 'xxx.local', + noTimeout, + ); + final Stream futureResult2 = resolver.addPendingRequest( + ResourceRecordType.addressIPv4, + 'yyy.local', + noTimeout, + ); + final ResourceRecord response1 = ip4Result('xxx.local', InternetAddress('1.2.3.4')); + final ResourceRecord response2 = ip4Result('yyy.local', InternetAddress('2.3.4.5')); + resolver.handleResponse([response2, response1]); + final result1 = await futureResult1.first as IPAddressResourceRecord; + final result2 = await futureResult2.first as IPAddressResourceRecord; + expect('1.2.3.4', result1.address.address); + expect('2.3.4.5', result2.address.address); + resolver.clearPendingRequests(); + }); +} + +void testResult3() { + test('Multiple requests', () async { + const noTimeout = Duration(days: 1); + final resolver = LookupResolver(); + final ResourceRecord response0 = ip4Result('zzz.local', InternetAddress('2.3.4.5')); + resolver.handleResponse([response0]); + final Stream futureResult1 = resolver.addPendingRequest( + ResourceRecordType.addressIPv4, + 'xxx.local', + noTimeout, + ); + resolver.handleResponse([response0]); + final Stream futureResult2 = resolver.addPendingRequest( + ResourceRecordType.addressIPv4, + 'yyy.local', + noTimeout, + ); + resolver.handleResponse([response0]); + final ResourceRecord response1 = ip4Result('xxx.local', InternetAddress('1.2.3.4')); + resolver.handleResponse([response0]); + final ResourceRecord response2 = ip4Result('yyy.local', InternetAddress('2.3.4.5')); + resolver.handleResponse([response0]); + resolver.handleResponse([response2, response1]); + resolver.handleResponse([response0]); + final result1 = await futureResult1.first as IPAddressResourceRecord; + final result2 = await futureResult2.first as IPAddressResourceRecord; + expect('1.2.3.4', result1.address.address); + expect('2.3.4.5', result2.address.address); + resolver.clearPendingRequests(); + }); +} diff --git a/packages/multicast_dns/test/resource_record_cache_test.dart b/packages/multicast_dns/test/resource_record_cache_test.dart new file mode 100644 index 0000000..5b4ad99 --- /dev/null +++ b/packages/multicast_dns/test/resource_record_cache_test.dart @@ -0,0 +1,78 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Test that the resource record cache works correctly. In particular, make +// sure that it removes all entries for a name before insertingrecords +// of that name. + +import 'dart:io'; + +import 'package:multicast_dns/src/native_protocol_client.dart' show ResourceRecordCache; +import 'package:multicast_dns/src/resource_record.dart'; +import 'package:test/test.dart'; + +void main() { + testOverwrite(); + testTimeout(); +} + +void testOverwrite() { + test('Cache can overwrite entries', () { + final ip1 = InternetAddress('192.168.1.1'); + final ip2 = InternetAddress('192.168.1.2'); + final int valid = DateTime.now().millisecondsSinceEpoch + 86400 * 1000; + + final cache = ResourceRecordCache(); + + // Add two different records. + cache.updateRecords([ + IPAddressResourceRecord('hest', valid, address: ip1), + IPAddressResourceRecord('fisk', valid, address: ip2), + ]); + expect(cache.entryCount, 2); + + // Update these records. + cache.updateRecords([ + IPAddressResourceRecord('hest', valid, address: ip1), + IPAddressResourceRecord('fisk', valid, address: ip2), + ]); + expect(cache.entryCount, 2); + + // Add two records with the same name (should remove the old one + // with that name only.) + cache.updateRecords([ + IPAddressResourceRecord('hest', valid, address: ip1), + IPAddressResourceRecord('hest', valid, address: ip2), + ]); + expect(cache.entryCount, 3); + + // Overwrite the two cached entries with one with the same name. + cache.updateRecords([IPAddressResourceRecord('hest', valid, address: ip1)]); + expect(cache.entryCount, 2); + }); +} + +void testTimeout() { + test('Cache can evict records after timeout', () { + final ip1 = InternetAddress('192.168.1.1'); + final int valid = DateTime.now().millisecondsSinceEpoch + 86400 * 1000; + final int notValid = DateTime.now().millisecondsSinceEpoch - 1; + + final cache = ResourceRecordCache(); + + cache.updateRecords([IPAddressResourceRecord('hest', valid, address: ip1)]); + expect(cache.entryCount, 1); + + cache.updateRecords([IPAddressResourceRecord('fisk', notValid, address: ip1)]); + + var results = []; + cache.lookup('hest', ResourceRecordType.addressIPv4, results); + expect(results.isEmpty, isFalse); + + results = []; + cache.lookup('fisk', ResourceRecordType.addressIPv4, results); + expect(results.isEmpty, isTrue); + expect(cache.entryCount, 1); + }); +} diff --git a/packages/multicast_dns/tool/packet_gen.dart b/packages/multicast_dns/tool/packet_gen.dart new file mode 100644 index 0000000..d3df26c --- /dev/null +++ b/packages/multicast_dns/tool/packet_gen.dart @@ -0,0 +1,72 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: avoid_print + +// Support code to generate the hex-lists in test/decode_test.dart from +// a hex-stream. +import 'dart:io'; + +void formatHexStream(String hexStream) { + var s = ''; + for (var i = 0; i < hexStream.length / 2; i++) { + if (s.isNotEmpty) { + s += ', '; + } + s += '0x'; + final String x = hexStream.substring(i * 2, i * 2 + 2); + s += x; + if (((i + 1) % 8) == 0) { + s += ','; + print(s); + s = ''; + } + } + if (s.isNotEmpty) { + print(s); + } +} + +// Support code for generating the hex-lists in test/decode_test.dart. +void hexDumpList(List package) { + var s = ''; + for (var i = 0; i < package.length; i++) { + if (s.isNotEmpty) { + s += ', '; + } + s += '0x'; + final String x = package[i].toRadixString(16); + if (x.length == 1) { + s += '0'; + } + s += x; + if (((i + 1) % 8) == 0) { + s += ','; + print(s); + s = ''; + } + } + if (s.isNotEmpty) { + print(s); + } +} + +void dumpDatagram(Datagram datagram) { + String toHex(List ints) { + final buffer = StringBuffer(); + for (var i = 0; i < ints.length; i++) { + buffer.write(ints[i].toRadixString(16).padLeft(2, '0')); + if ((i + 1) % 10 == 0) { + buffer.writeln(); + } else { + buffer.write(' '); + } + } + return buffer.toString(); + } + + print('${datagram.address.address}:${datagram.port}:'); + print(toHex(datagram.data)); + print(''); +} diff --git a/packages/standard_message_codec/.gitignore b/packages/standard_message_codec/.gitignore new file mode 100644 index 0000000..9331cd4 --- /dev/null +++ b/packages/standard_message_codec/.gitignore @@ -0,0 +1,6 @@ +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ diff --git a/packages/standard_message_codec/AUTHORS b/packages/standard_message_codec/AUTHORS new file mode 100644 index 0000000..557dff9 --- /dev/null +++ b/packages/standard_message_codec/AUTHORS @@ -0,0 +1,6 @@ +# Below is a list of people and organizations that have contributed +# to the Flutter project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. diff --git a/packages/standard_message_codec/CHANGELOG.md b/packages/standard_message_codec/CHANGELOG.md new file mode 100644 index 0000000..a95327f --- /dev/null +++ b/packages/standard_message_codec/CHANGELOG.md @@ -0,0 +1,24 @@ +## NEXT + +* Updates minimum supported SDK version to Flutter 3.38/Dart 3.10. + +## 0.0.1+4 + +* Adds pub topics to package metadata. +* Updates minimum supported SDK version to Flutter 3.7/Dart 2.19. + +## 0.0.1+3 + +* Minor README updates. + +## 0.0.1+2 + +* Fixes lint warnings. + +## 0.0.1+1 + +* Fixes minimum version of `test` dependency. + +## 0.0.1 + +* Initial release of standard message codec extracted from the Flutter SDK. diff --git a/packages/standard_message_codec/LICENSE b/packages/standard_message_codec/LICENSE new file mode 100644 index 0000000..29b709d --- /dev/null +++ b/packages/standard_message_codec/LICENSE @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/standard_message_codec/README.md b/packages/standard_message_codec/README.md new file mode 100644 index 0000000..b6e020c --- /dev/null +++ b/packages/standard_message_codec/README.md @@ -0,0 +1,55 @@ + + +An efficient and schemaless binary format used by the Flutter SDK. + +## Features + +### Efficiency + +The standard message codec is a binary format, as opposed to text based formats +like JSON. Consider the following snippet of JSON: + +```json +{ + "data": [1, 2, 3, 4], +} +``` + +In order for this message to be decoded into a Dart map, a utf8 binary file must +first be parsed and validated into a Dart string. Then a second pass is performed +which looks for specific characters that indicate JSON structures - for example +"{" and "}". No sizes or lengths are known ahead of time while, parsing, so the +resulting Dart list created for the "data" key is append to as decoding happens. + +In contrast, decoding the standard message codec version of this message avoids +utf8 decoding, instead operating on the bytes themselves. The only string constructed +will be for the "data" key. The length of the list in the data field is encoded in +the structure, meaning the correct length object can be allocated and filled in +as decoding happens. + +### Schemaless + +Using standard message codec does not require a schema (like protobuf) or any +generated code. This makes it easy to use for dynamic messages and simplifies +the integration into existing codebases. + +The tradeoff for this ease of use is that it becomes the application's +responsibility to verify the structure of messages sent/received. There is also +no automatic backwards compatibility like protobuf. + +## Getting started + +standard_message_codec can be used to encode and decode messages in either Flutter +or pure Dart applications. + + +```dart +void main() { + final ByteData? data = const StandardMessageCodec().encodeMessage({ + 'foo': true, + 3: 'fizz', + }); + print('The encoded message is $data'); +} + +``` diff --git a/packages/standard_message_codec/dart_test.yaml b/packages/standard_message_codec/dart_test.yaml new file mode 100644 index 0000000..cdb656d --- /dev/null +++ b/packages/standard_message_codec/dart_test.yaml @@ -0,0 +1,6 @@ +# See https://github.com/dart-lang/test/blob/master/pkgs/test/doc/configuration.md#arguments +override_platforms: + chrome: + settings: + executable: chrome + arguments: --no-sandbox diff --git a/packages/standard_message_codec/example/README.md b/packages/standard_message_codec/example/README.md new file mode 100644 index 0000000..39712c7 --- /dev/null +++ b/packages/standard_message_codec/example/README.md @@ -0,0 +1,3 @@ +# example_standard_message_codec + +A sample app for demonstrating the StandardMessageCodec diff --git a/packages/standard_message_codec/example/lib/readme_excerpts.dart b/packages/standard_message_codec/example/lib/readme_excerpts.dart new file mode 100644 index 0000000..6d6b1e4 --- /dev/null +++ b/packages/standard_message_codec/example/lib/readme_excerpts.dart @@ -0,0 +1,22 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// This file exists solely to host compiled excerpts for README.md, and is not +// intended for use as an actual example application. + +// ignore_for_file: avoid_print + +import 'dart:typed_data'; +import 'package:standard_message_codec/standard_message_codec.dart'; + +// #docregion Encoding +void main() { + final ByteData? data = const StandardMessageCodec().encodeMessage({ + 'foo': true, + 3: 'fizz', + }); + print('The encoded message is $data'); +} + +// #enddocregion Encoding diff --git a/packages/standard_message_codec/example/pubspec.yaml b/packages/standard_message_codec/example/pubspec.yaml new file mode 100644 index 0000000..bb82064 --- /dev/null +++ b/packages/standard_message_codec/example/pubspec.yaml @@ -0,0 +1,14 @@ +name: standard_message_codec_examples +description: Example code for standard message codec usage +version: 0.0.1 +publish_to: none + +environment: + sdk: ^3.10.0 + +dependencies: + standard_message_codec: + path: ../ + +dev_dependencies: + build_runner: ^2.1.10 diff --git a/packages/standard_message_codec/lib/src/serialization.dart b/packages/standard_message_codec/lib/src/serialization.dart new file mode 100644 index 0000000..10a98fd --- /dev/null +++ b/packages/standard_message_codec/lib/src/serialization.dart @@ -0,0 +1,271 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; +import 'dart:typed_data'; + +export 'dart:typed_data' + show ByteData, Endian, Float32List, Float64List, Int32List, Int64List, Uint8List; + +/// Write-only buffer for incrementally building a [ByteData] instance. +/// +/// A WriteBuffer instance can be used only once. Attempts to reuse will result +/// in [StateError]s being thrown. +/// +/// The byte order used is [Endian.host] throughout. +class WriteBuffer { + /// Creates an interface for incrementally building a [ByteData] instance. + /// [startCapacity] determines the start size of the [WriteBuffer] in bytes. + /// The closer that value is to the real size used, the better the + /// performance. + factory WriteBuffer({int startCapacity = 8}) { + assert(startCapacity > 0); + final eightBytes = ByteData(8); + final Uint8List eightBytesAsList = eightBytes.buffer.asUint8List(); + return WriteBuffer._(Uint8List(startCapacity), eightBytes, eightBytesAsList); + } + + WriteBuffer._(this._buffer, this._eightBytes, this._eightBytesAsList); + + Uint8List _buffer; + int _currentSize = 0; + bool _isDone = false; + final ByteData _eightBytes; + final Uint8List _eightBytesAsList; + static final Uint8List _zeroBuffer = Uint8List(8); + + void _add(int byte) { + if (_currentSize == _buffer.length) { + _resize(); + } + _buffer[_currentSize] = byte; + _currentSize += 1; + } + + void _append(Uint8List other) { + final int newSize = _currentSize + other.length; + if (newSize >= _buffer.length) { + _resize(newSize); + } + _buffer.setRange(_currentSize, newSize, other); + _currentSize += other.length; + } + + void _addAll(Uint8List data, [int start = 0, int? end]) { + final int newEnd = end ?? _eightBytesAsList.length; + final int newSize = _currentSize + (newEnd - start); + if (newSize >= _buffer.length) { + _resize(newSize); + } + _buffer.setRange(_currentSize, newSize, data); + _currentSize = newSize; + } + + void _resize([int? requiredLength]) { + final int doubleLength = _buffer.length * 2; + final int newLength = math.max(requiredLength ?? 0, doubleLength); + final newBuffer = Uint8List(newLength); + newBuffer.setRange(0, _buffer.length, _buffer); + _buffer = newBuffer; + } + + /// Write a Uint8 into the buffer. + void putUint8(int byte) { + assert(!_isDone); + _add(byte); + } + + /// Write a Uint16 into the buffer. + void putUint16(int value, {Endian? endian}) { + assert(!_isDone); + _eightBytes.setUint16(0, value, endian ?? Endian.host); + _addAll(_eightBytesAsList, 0, 2); + } + + /// Write a Uint32 into the buffer. + void putUint32(int value, {Endian? endian}) { + assert(!_isDone); + _eightBytes.setUint32(0, value, endian ?? Endian.host); + _addAll(_eightBytesAsList, 0, 4); + } + + /// Write an Int32 into the buffer. + void putInt32(int value, {Endian? endian}) { + assert(!_isDone); + _eightBytes.setInt32(0, value, endian ?? Endian.host); + _addAll(_eightBytesAsList, 0, 4); + } + + /// Write an Int64 into the buffer. + void putInt64(int value, {Endian? endian}) { + assert(!_isDone); + _eightBytes.setInt64(0, value, endian ?? Endian.host); + _addAll(_eightBytesAsList, 0, 8); + } + + /// Write an Float64 into the buffer. + void putFloat64(double value, {Endian? endian}) { + assert(!_isDone); + _alignTo(8); + _eightBytes.setFloat64(0, value, endian ?? Endian.host); + _addAll(_eightBytesAsList); + } + + /// Write all the values from a [Uint8List] into the buffer. + void putUint8List(Uint8List list) { + assert(!_isDone); + _append(list); + } + + /// Write all the values from an [Int32List] into the buffer. + void putInt32List(Int32List list) { + assert(!_isDone); + _alignTo(4); + _append(list.buffer.asUint8List(list.offsetInBytes, 4 * list.length)); + } + + /// Write all the values from an [Int64List] into the buffer. + void putInt64List(Int64List list) { + assert(!_isDone); + _alignTo(8); + _append(list.buffer.asUint8List(list.offsetInBytes, 8 * list.length)); + } + + /// Write all the values from a [Float32List] into the buffer. + void putFloat32List(Float32List list) { + assert(!_isDone); + _alignTo(4); + _append(list.buffer.asUint8List(list.offsetInBytes, 4 * list.length)); + } + + /// Write all the values from a [Float64List] into the buffer. + void putFloat64List(Float64List list) { + assert(!_isDone); + _alignTo(8); + _append(list.buffer.asUint8List(list.offsetInBytes, 8 * list.length)); + } + + void _alignTo(int alignment) { + assert(!_isDone); + final int mod = _currentSize % alignment; + if (mod != 0) { + _addAll(_zeroBuffer, 0, alignment - mod); + } + } + + /// Finalize and return the written [ByteData]. + ByteData done() { + if (_isDone) { + throw StateError('done() must not be called more than once on the same $runtimeType.'); + } + final ByteData result = _buffer.buffer.asByteData(0, _currentSize); + _buffer = Uint8List(0); + _isDone = true; + return result; + } +} + +/// Read-only buffer for reading sequentially from a [ByteData] instance. +/// +/// The byte order used is [Endian.host] throughout. +class ReadBuffer { + /// Creates a [ReadBuffer] for reading from the specified [data]. + ReadBuffer(this.data); + + /// The underlying data being read. + final ByteData data; + + /// The position to read next. + int _position = 0; + + /// Whether the buffer has data remaining to read. + bool get hasRemaining => _position < data.lengthInBytes; + + /// Reads a Uint8 from the buffer. + int getUint8() { + return data.getUint8(_position++); + } + + /// Reads a Uint16 from the buffer. + int getUint16({Endian? endian}) { + final int value = data.getUint16(_position, endian ?? Endian.host); + _position += 2; + return value; + } + + /// Reads a Uint32 from the buffer. + int getUint32({Endian? endian}) { + final int value = data.getUint32(_position, endian ?? Endian.host); + _position += 4; + return value; + } + + /// Reads an Int32 from the buffer. + int getInt32({Endian? endian}) { + final int value = data.getInt32(_position, endian ?? Endian.host); + _position += 4; + return value; + } + + /// Reads an Int64 from the buffer. + int getInt64({Endian? endian}) { + final int value = data.getInt64(_position, endian ?? Endian.host); + _position += 8; + return value; + } + + /// Reads a Float64 from the buffer. + double getFloat64({Endian? endian}) { + _alignTo(8); + final double value = data.getFloat64(_position, endian ?? Endian.host); + _position += 8; + return value; + } + + /// Reads the given number of Uint8s from the buffer. + Uint8List getUint8List(int length) { + final Uint8List list = data.buffer.asUint8List(data.offsetInBytes + _position, length); + _position += length; + return list; + } + + /// Reads the given number of Int32s from the buffer. + Int32List getInt32List(int length) { + _alignTo(4); + final Int32List list = data.buffer.asInt32List(data.offsetInBytes + _position, length); + _position += 4 * length; + return list; + } + + /// Reads the given number of Int64s from the buffer. + Int64List getInt64List(int length) { + _alignTo(8); + final Int64List list = data.buffer.asInt64List(data.offsetInBytes + _position, length); + _position += 8 * length; + return list; + } + + /// Reads the given number of Float32s from the buffer + Float32List getFloat32List(int length) { + _alignTo(4); + final Float32List list = data.buffer.asFloat32List(data.offsetInBytes + _position, length); + _position += 4 * length; + return list; + } + + /// Reads the given number of Float64s from the buffer. + Float64List getFloat64List(int length) { + _alignTo(8); + final Float64List list = data.buffer.asFloat64List(data.offsetInBytes + _position, length); + _position += 8 * length; + return list; + } + + void _alignTo(int alignment) { + final int mod = _position % alignment; + if (mod != 0) { + _position += alignment - mod; + } + } +} diff --git a/packages/standard_message_codec/lib/standard_message_codec.dart b/packages/standard_message_codec/lib/standard_message_codec.dart new file mode 100644 index 0000000..ddb3177 --- /dev/null +++ b/packages/standard_message_codec/lib/standard_message_codec.dart @@ -0,0 +1,405 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; + +import 'src/serialization.dart'; + +export 'src/serialization.dart' show ReadBuffer, WriteBuffer; + +const int _writeBufferStartCapacity = 64; + +/// A message encoding/decoding mechanism. +/// +/// Both operations throw an exception, if conversion fails. Such situations +/// should be treated as programming errors. +/// +/// See also: +/// +/// * [BasicMessageChannel], which use [MessageCodec]s for communication +/// between Flutter and platform plugins. +abstract class MessageCodec { + /// Encodes the specified [message] in binary. + /// + /// Returns null if the message is null. + ByteData? encodeMessage(T message); + + /// Decodes the specified [message] from binary. + /// + /// Returns null if the message is null. + T? decodeMessage(ByteData? message); +} + +/// [MessageCodec] using the Flutter standard binary encoding. +/// +/// Supported messages are acyclic values of these forms: +/// +/// * null +/// * [bool]s +/// * [num]s +/// * [String]s +/// * [Uint8List]s, [Int32List]s, [Int64List]s, [Float64List]s +/// * [List]s of supported values +/// * [Map]s from supported values to supported values +/// +/// Decoded values will use `List` and `Map` +/// irrespective of content. +/// +/// The type returned from [decodeMessage] is `dynamic` (not `Object?`), which +/// means *no type checking is performed on its return value*. It is strongly +/// recommended that the return value be immediately cast to a known type to +/// prevent runtime errors due to typos that the type checker could otherwise +/// catch. +/// +/// The codec is extensible by subclasses overriding [writeValue] and +/// [readValueOfType]. +/// +/// ## Android specifics +/// +/// On Android, messages are represented as follows: +/// +/// * null: null +/// * [bool]\: `java.lang.Boolean` +/// * [int]\: `java.lang.Integer` for values that are representable using 32-bit +/// two's complement; `java.lang.Long` otherwise +/// * [double]\: `java.lang.Double` +/// * [String]\: `java.lang.String` +/// * [Uint8List]\: `byte[]` +/// * [Int32List]\: `int[]` +/// * [Int64List]\: `long[]` +/// * [Float64List]\: `double[]` +/// * [List]\: `java.util.ArrayList` +/// * [Map]\: `java.util.HashMap` +/// +/// When sending a `java.math.BigInteger` from Java, it is converted into a +/// [String] with the hexadecimal representation of the integer. (The value is +/// tagged as being a big integer; subclasses of this class could be made to +/// support it natively; see the discussion at [writeValue].) This codec does +/// not support sending big integers from Dart. +/// +/// ## iOS specifics +/// +/// On iOS, messages are represented as follows: +/// +/// * null: nil +/// * [bool]\: `NSNumber numberWithBool:` +/// * [int]\: `NSNumber numberWithInt:` for values that are representable using +/// 32-bit two's complement; `NSNumber numberWithLong:` otherwise +/// * [double]\: `NSNumber numberWithDouble:` +/// * [String]\: `NSString` +/// * [Uint8List], [Int32List], [Int64List], [Float64List]\: +/// `FlutterStandardTypedData` +/// * [List]\: `NSArray` +/// * [Map]\: `NSDictionary` +class StandardMessageCodec implements MessageCodec { + /// Creates a [MessageCodec] using the Flutter standard binary encoding. + const StandardMessageCodec(); + + // The codec serializes messages as outlined below. This format must match the + // Android and iOS counterparts and cannot change (as it's possible for + // someone to end up using this for persistent storage). + // + // * A single byte with one of the constant values below determines the + // type of the value. + // * The serialization of the value itself follows the type byte. + // * Numbers are represented using the host endianness throughout. + // * Lengths and sizes of serialized parts are encoded using an expanding + // format optimized for the common case of small non-negative integers: + // * values 0..253 inclusive using one byte with that value; + // * values 254..2^16 inclusive using three bytes, the first of which is + // 254, the next two the usual unsigned representation of the value; + // * values 2^16+1..2^32 inclusive using five bytes, the first of which is + // 255, the next four the usual unsigned representation of the value. + // * null, true, and false have empty serialization; they are encoded directly + // in the type byte (using _valueNull, _valueTrue, _valueFalse) + // * Integers representable in 32 bits are encoded using 4 bytes two's + // complement representation. + // * Larger integers are encoded using 8 bytes two's complement + // representation. + // * doubles are encoded using the IEEE 754 64-bit double-precision binary + // format. Zero bytes are added before the encoded double value to align it + // to a 64 bit boundary in the full message. + // * Strings are encoded using their UTF-8 representation. First the length + // of that in bytes is encoded using the expanding format, then follows the + // UTF-8 encoding itself. + // * Uint8Lists, Int32Lists, Int64Lists, Float32Lists, and Float64Lists are + // encoded by first encoding the list's element count in the expanding + // format, then the smallest number of zero bytes needed to align the + // position in the full message with a multiple of the number of bytes per + // element, then the encoding of the list elements themselves, end-to-end + // with no additional type information, using two's complement or IEEE 754 + // as applicable. + // * Lists are encoded by first encoding their length in the expanding format, + // then follows the recursive encoding of each element value, including the + // type byte (Lists are assumed to be heterogeneous). + // * Maps are encoded by first encoding their length in the expanding format, + // then follows the recursive encoding of each key/value pair, including the + // type byte for both (Maps are assumed to be heterogeneous). + // + // The type labels below must not change, since it's possible for this interface + // to be used for persistent storage. + static const int _valueNull = 0; + static const int _valueTrue = 1; + static const int _valueFalse = 2; + static const int _valueInt32 = 3; + static const int _valueInt64 = 4; + static const int _valueLargeInt = 5; + static const int _valueFloat64 = 6; + static const int _valueString = 7; + static const int _valueUint8List = 8; + static const int _valueInt32List = 9; + static const int _valueInt64List = 10; + static const int _valueFloat64List = 11; + static const int _valueList = 12; + static const int _valueMap = 13; + static const int _valueFloat32List = 14; + + @override + ByteData? encodeMessage(Object? message) { + if (message == null) { + return null; + } + final buffer = WriteBuffer(startCapacity: _writeBufferStartCapacity); + writeValue(buffer, message); + return buffer.done(); + } + + @override + dynamic decodeMessage(ByteData? message) { + if (message == null) { + return null; + } + final buffer = ReadBuffer(message); + final Object? result = readValue(buffer); + if (buffer.hasRemaining) { + throw const FormatException('Message corrupted'); + } + return result; + } + + /// Writes [value] to [buffer] by first writing a type discriminator + /// byte, then the value itself. + /// + /// This method may be called recursively to serialize container values. + /// + /// Type discriminators 0 through 127 inclusive are reserved for use by the + /// base class, as follows: + /// + /// * null = 0 + /// * true = 1 + /// * false = 2 + /// * 32 bit integer = 3 + /// * 64 bit integer = 4 + /// * larger integers = 5 (see below) + /// * 64 bit floating-point number = 6 + /// * String = 7 + /// * Uint8List = 8 + /// * Int32List = 9 + /// * Int64List = 10 + /// * Float64List = 11 + /// * List = 12 + /// * Map = 13 + /// * Float32List = 14 + /// * Reserved for future expansion: 15..127 + /// + /// The codec can be extended by overriding this method, calling super + /// for values that the extension does not handle. Type discriminators + /// used by extensions must be greater than or equal to 128 in order to avoid + /// clashes with any later extensions to the base class. + /// + /// The "larger integers" type, 5, is never used by [writeValue]. A subclass + /// could represent big integers from another package using that type. The + /// format is first the type byte (0x05), then the actual number as an ASCII + /// string giving the hexadecimal representation of the integer, with the + /// string's length as encoded by [writeSize] followed by the string bytes. On + /// Android, that would get converted to a `java.math.BigInteger` object. On + /// iOS, the string representation is returned. + void writeValue(WriteBuffer buffer, Object? value) { + if (value == null) { + buffer.putUint8(_valueNull); + } else if (value is bool) { + buffer.putUint8(value ? _valueTrue : _valueFalse); + } else if (value is double) { + // Double precedes int because in JS everything is a double. + // Therefore in JS, both `is int` and `is double` always + // return `true`. If we check int first, we'll end up treating + // all numbers as ints and attempt the int32/int64 conversion, + // which is wrong. This precedence rule is irrelevant when + // decoding because we use tags to detect the type of value. + buffer.putUint8(_valueFloat64); + buffer.putFloat64(value); + // ignore: avoid_double_and_int_checks, JS code always goes through the `double` path above + } else if (value is int) { + if (-0x7fffffff - 1 <= value && value <= 0x7fffffff) { + buffer.putUint8(_valueInt32); + buffer.putInt32(value); + } else { + buffer.putUint8(_valueInt64); + buffer.putInt64(value); + } + } else if (value is String) { + buffer.putUint8(_valueString); + final asciiBytes = Uint8List(value.length); + Uint8List? utf8Bytes; + var utf8Offset = 0; + // Only do utf8 encoding if we encounter non-ascii characters. + for (var i = 0; i < value.length; i += 1) { + final int char = value.codeUnitAt(i); + if (char <= 0x7f) { + asciiBytes[i] = char; + } else { + utf8Bytes = utf8.encoder.convert(value.substring(i)); + utf8Offset = i; + break; + } + } + if (utf8Bytes != null) { + writeSize(buffer, utf8Offset + utf8Bytes.length); + buffer.putUint8List(Uint8List.sublistView(asciiBytes, 0, utf8Offset)); + buffer.putUint8List(utf8Bytes); + } else { + writeSize(buffer, asciiBytes.length); + buffer.putUint8List(asciiBytes); + } + } else if (value is Uint8List) { + buffer.putUint8(_valueUint8List); + writeSize(buffer, value.length); + buffer.putUint8List(value); + } else if (value is Int32List) { + buffer.putUint8(_valueInt32List); + writeSize(buffer, value.length); + buffer.putInt32List(value); + } else if (value is Int64List) { + buffer.putUint8(_valueInt64List); + writeSize(buffer, value.length); + buffer.putInt64List(value); + } else if (value is Float32List) { + buffer.putUint8(_valueFloat32List); + writeSize(buffer, value.length); + buffer.putFloat32List(value); + } else if (value is Float64List) { + buffer.putUint8(_valueFloat64List); + writeSize(buffer, value.length); + buffer.putFloat64List(value); + } else if (value is List) { + buffer.putUint8(_valueList); + writeSize(buffer, value.length); + for (final Object? item in value) { + writeValue(buffer, item); + } + } else if (value is Map) { + buffer.putUint8(_valueMap); + writeSize(buffer, value.length); + value.forEach((Object? key, Object? value) { + writeValue(buffer, key); + writeValue(buffer, value); + }); + } else { + throw ArgumentError.value(value); + } + } + + /// Reads a value from [buffer] as written by [writeValue]. + /// + /// This method is intended for use by subclasses overriding + /// [readValueOfType]. + Object? readValue(ReadBuffer buffer) { + if (!buffer.hasRemaining) { + throw const FormatException('Message corrupted'); + } + final int type = buffer.getUint8(); + return readValueOfType(type, buffer); + } + + /// Reads a value of the indicated [type] from [buffer]. + /// + /// The codec can be extended by overriding this method, calling super for + /// types that the extension does not handle. See the discussion at + /// [writeValue]. + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case _valueNull: + return null; + case _valueTrue: + return true; + case _valueFalse: + return false; + case _valueInt32: + return buffer.getInt32(); + case _valueInt64: + return buffer.getInt64(); + case _valueFloat64: + return buffer.getFloat64(); + case _valueLargeInt: + case _valueString: + final int length = readSize(buffer); + return utf8.decoder.convert(buffer.getUint8List(length)); + case _valueUint8List: + final int length = readSize(buffer); + return buffer.getUint8List(length); + case _valueInt32List: + final int length = readSize(buffer); + return buffer.getInt32List(length); + case _valueInt64List: + final int length = readSize(buffer); + return buffer.getInt64List(length); + case _valueFloat32List: + final int length = readSize(buffer); + return buffer.getFloat32List(length); + case _valueFloat64List: + final int length = readSize(buffer); + return buffer.getFloat64List(length); + case _valueList: + final int length = readSize(buffer); + final result = List.filled(length, null); + for (var i = 0; i < length; i++) { + result[i] = readValue(buffer); + } + return result; + case _valueMap: + final int length = readSize(buffer); + final result = {}; + for (var i = 0; i < length; i++) { + result[readValue(buffer)] = readValue(buffer); + } + return result; + default: + throw const FormatException('Message corrupted'); + } + } + + /// Writes a non-negative 32-bit integer [value] to [buffer] + /// using an expanding 1-5 byte encoding that optimizes for small values. + /// + /// This method is intended for use by subclasses overriding + /// [writeValue]. + void writeSize(WriteBuffer buffer, int value) { + assert(0 <= value && value <= 0xffffffff); + if (value < 254) { + buffer.putUint8(value); + } else if (value <= 0xffff) { + buffer.putUint8(254); + buffer.putUint16(value); + } else { + buffer.putUint8(255); + buffer.putUint32(value); + } + } + + /// Reads a non-negative int from [buffer] as written by [writeSize]. + /// + /// This method is intended for use by subclasses overriding + /// [readValueOfType]. + int readSize(ReadBuffer buffer) { + final int value = buffer.getUint8(); + switch (value) { + case 254: + return buffer.getUint16(); + case 255: + return buffer.getUint32(); + default: + return value; + } + } +} diff --git a/packages/standard_message_codec/pubspec.yaml b/packages/standard_message_codec/pubspec.yaml new file mode 100644 index 0000000..66263a8 --- /dev/null +++ b/packages/standard_message_codec/pubspec.yaml @@ -0,0 +1,16 @@ +name: standard_message_codec +description: An efficient and schemaless binary encoding format for Flutter and Dart. +version: 0.0.1+4 +repository: https://github.com/flutter/core-packages/tree/main/packages/standard_message_codec +issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3Astandard_message_codec + +environment: + sdk: ^3.10.0 + +dev_dependencies: + test: ^1.16.0 + +topics: + - decode + - encode + - interop diff --git a/packages/standard_message_codec/test/standard_message_codec_test.dart b/packages/standard_message_codec/test/standard_message_codec_test.dart new file mode 100644 index 0000000..470ae5c --- /dev/null +++ b/packages/standard_message_codec/test/standard_message_codec_test.dart @@ -0,0 +1,156 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:typed_data'; + +import 'package:standard_message_codec/standard_message_codec.dart'; +import 'package:test/test.dart'; + +const StandardMessageCodec messageCodec = StandardMessageCodec(); + +void main() { + group('Standard method codec', () { + test('Should encode and decode objects produced from codec', () { + final ByteData? data = messageCodec.encodeMessage({'foo': true, 3: 'fizz'}); + + expect(messageCodec.decodeMessage(data), {'foo': true, 3: 'fizz'}); + }); + }); + + group('Write and read buffer round-trip', () { + test('of empty buffer', () { + final write = WriteBuffer(); + final ByteData written = write.done(); + + expect(written.lengthInBytes, 0); + }); + + test('of single byte', () { + final write = WriteBuffer(); + write.putUint8(201); + final ByteData written = write.done(); + expect(written.lengthInBytes, equals(1)); + final read = ReadBuffer(written); + expect(read.getUint8(), equals(201)); + }); + + test('of 32-bit integer', () { + final write = WriteBuffer(); + write.putInt32(-9); + final ByteData written = write.done(); + expect(written.lengthInBytes, equals(4)); + final read = ReadBuffer(written); + expect(read.getInt32(), equals(-9)); + }); + + test('of 32-bit integer in big endian', () { + final write = WriteBuffer(); + write.putInt32(-9, endian: Endian.big); + final ByteData written = write.done(); + expect(written.lengthInBytes, equals(4)); + final read = ReadBuffer(written); + expect(read.getInt32(endian: Endian.big), equals(-9)); + }); + + test('of 64-bit integer', () { + final write = WriteBuffer(); + write.putInt64(-9000000000000); + final ByteData written = write.done(); + expect(written.lengthInBytes, equals(8)); + final read = ReadBuffer(written); + expect(read.getInt64(), equals(-9000000000000)); + }, testOn: 'vm' /* Int64 isn't supported on web */); + + test('of 64-bit integer in big endian', () { + final write = WriteBuffer(); + write.putInt64(-9000000000000, endian: Endian.big); + final ByteData written = write.done(); + expect(written.lengthInBytes, equals(8)); + final read = ReadBuffer(written); + expect(read.getInt64(endian: Endian.big), equals(-9000000000000)); + }, testOn: 'vm' /* Int64 isn't supported on web */); + + test('of double', () { + final write = WriteBuffer(); + write.putFloat64(3.14); + final ByteData written = write.done(); + expect(written.lengthInBytes, equals(8)); + final read = ReadBuffer(written); + expect(read.getFloat64(), equals(3.14)); + }); + test('of double in big endian', () { + final write = WriteBuffer(); + write.putFloat64(3.14, endian: Endian.big); + final ByteData written = write.done(); + expect(written.lengthInBytes, equals(8)); + final read = ReadBuffer(written); + expect(read.getFloat64(endian: Endian.big), equals(3.14)); + }); + test('of 32-bit int list when unaligned', () { + final integers = Int32List.fromList([-99, 2, 99]); + final write = WriteBuffer(); + write.putUint8(9); + write.putInt32List(integers); + final ByteData written = write.done(); + expect(written.lengthInBytes, equals(16)); + final read = ReadBuffer(written); + read.getUint8(); + expect(read.getInt32List(3), equals(integers)); + }); + + test('of 64-bit int list when unaligned', () { + final integers = Int64List.fromList([-99, 2, 99]); + final write = WriteBuffer(); + write.putUint8(9); + write.putInt64List(integers); + final ByteData written = write.done(); + expect(written.lengthInBytes, equals(32)); + final read = ReadBuffer(written); + read.getUint8(); + expect(read.getInt64List(3), equals(integers)); + }, testOn: 'vm' /* Int64 isn't supported on web */); + + test('of float list when unaligned', () { + final floats = Float32List.fromList([3.14, double.nan]); + final write = WriteBuffer(); + write.putUint8(9); + write.putFloat32List(floats); + final ByteData written = write.done(); + expect(written.lengthInBytes, equals(12)); + final read = ReadBuffer(written); + read.getUint8(); + final Float32List readFloats = read.getFloat32List(2); + expect(readFloats[0], closeTo(3.14, 0.0001)); + expect(readFloats[1], isNaN); + }); + + test('of double list when unaligned', () { + final doubles = Float64List.fromList([3.14, double.nan]); + final write = WriteBuffer(); + write.putUint8(9); + write.putFloat64List(doubles); + final ByteData written = write.done(); + expect(written.lengthInBytes, equals(24)); + final read = ReadBuffer(written); + read.getUint8(); + final Float64List readDoubles = read.getFloat64List(2); + expect(readDoubles[0], equals(3.14)); + expect(readDoubles[1], isNaN); + }); + + test('done twice', () { + final write = WriteBuffer(); + write.done(); + expect(() => write.done(), throwsStateError); + }); + + test('empty WriteBuffer', () { + expect(() => WriteBuffer(startCapacity: 0), throwsA(isA())); + }); + + test('size 1', () { + expect(() => WriteBuffer(startCapacity: 1), returnsNormally); + }); + }); +} diff --git a/third_party/packages/mustache_template/.gitignore b/third_party/packages/mustache_template/.gitignore new file mode 100644 index 0000000..263526e --- /dev/null +++ b/third_party/packages/mustache_template/.gitignore @@ -0,0 +1 @@ +test/spec diff --git a/third_party/packages/mustache_template/CHANGELOG.md b/third_party/packages/mustache_template/CHANGELOG.md new file mode 100644 index 0000000..80e6518 --- /dev/null +++ b/third_party/packages/mustache_template/CHANGELOG.md @@ -0,0 +1,89 @@ +## NEXT + +* Updates minimum supported SDK version to Flutter 3.38/Dart 3.10. + +## 2.0.4 + +* Fixes a broken README link to the Mustache manual. + +## 2.0.3 + +* Updates minimum supported SDK version to Flutter 3.35/Dart 3.9. +* Fixes error handling in LambdaContext by adding missing throw statements. +* Uses StateError instead of Exception for unreachable code in parser. + +## 2.0.2 + +* Adds emoji support. + +## 2.0.1 + +* Transfers the package source from https://github.com/jonahwilliams/mustache + to https://github.com/flutter/packages. +* Updates minimum supported SDK version to Dart 3.7. +* Updates code for new analysis options. + +## 2.0.0 + +* Support for null safe dart added. + +## 1.0.0+1 + +* Fixed regression where lookups from list did not work. Removed failing tests + that depend on reflection. + +## 1.0.0 + +* Forked from original repo. Support for mirrors removed. + +## Fork + +## 1.1.1 + +* Fixed error "boolean expression must not be null". Thanks Nico. + +## 1.1.0 + +* Better support for class members in sections. Thanks to Janice Collins. +* Set the SDK constraint to Dart 2+. + +## 1.0.2 + Set the max SDK constraint to <3.0.0. + +## 0.2.5 + +* Remove MustacheFormatException +* Allow templates to specify default delimiters. Thanks to Joris Hermans. +* Fix #24: renderString shrinks multiple newlines to just one (Thanks to John Ryan for the repro). + +## 0.2.4 + +* Fix #23 failure if tag or comment contains "=" + +## 0.2.3 + +* Change handling of lenient sections to match python mustache implementation. + +## 0.2.2 + +* Fix MirrorsUsed tag for using mirrors on dart2js. +* Clean up dead code. + +## 0.2.1 + +* Added new methods to LambdaContext. + +## 0.2 + +* Deprecated parse() function - please update your code to use new Template(source). +* Deprecated MustacheFormatException - please update your code to use TemplateException. +* Breaking change: Template.render and Template.renderString methods no longer + take the optional lenient and htmlEscapeValues. These should now be passed to + the Template constructor. +* Fully passing all mustache spec tests. +* Added support for MirrorsUsed. +* Implemented partials. #11 +* Implemented lambdas. #4 +* Implemented change delimiter tag. +* Add template name parameter, and show this in error messages. +* Allow whitespace at begining of tags. #10 diff --git a/third_party/packages/mustache_template/LICENSE b/third_party/packages/mustache_template/LICENSE new file mode 100644 index 0000000..6bb39e4 --- /dev/null +++ b/third_party/packages/mustache_template/LICENSE @@ -0,0 +1,8 @@ +Copyright (c) 2013, Greg Lowe +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/third_party/packages/mustache_template/METADATA b/third_party/packages/mustache_template/METADATA new file mode 100644 index 0000000..5025060 --- /dev/null +++ b/third_party/packages/mustache_template/METADATA @@ -0,0 +1,15 @@ +name: "mustache_template" +description: + "Mustache template Dart library" + +third_party { + identifier { + type: "Git" + value: "https://github.com/jonahwilliams/mustache" + primary_source: true + version: "c4344e0dd45f6605758eb11aa4837859e2c055f0" + } + version: "c4344e0dd45f6605758eb11aa4837859e2c055f0" + last_upgrade_date { year: 2025 month: 9 day: 3 } + license_type: NOTICE +} diff --git a/third_party/packages/mustache_template/README.md b/third_party/packages/mustache_template/README.md new file mode 100644 index 0000000..cf4dab6 --- /dev/null +++ b/third_party/packages/mustache_template/README.md @@ -0,0 +1,117 @@ +# Mustache templates + +A Dart library to parse and render [mustache templates](https://mustache.github.io/). + +See the [mustache manual](https://mustache.github.io/mustache.5.html) for detailed usage information. + +This library passes all [mustache specification](https://github.com/mustache/spec/tree/master/specs) tests. + +## Example usage +```dart +import 'package:mustache_template/mustache_template.dart'; + +main() { + var source = ''' + {{# names }} +
{{ lastname }}, {{ firstname }}
+ {{/ names }} + {{^ names }} +
No names.
+ {{/ names }} + {{! I am a comment. }} + '''; + + var template = Template(source, name: 'template-filename.html'); + + var output = template.renderString({'names': [ + {'firstname': 'Greg', 'lastname': 'Lowe'}, + {'firstname': 'Bob', 'lastname': 'Johnson'} + ]}); + + print(output); +} +``` + +A template is parsed when it is created, after parsing it can be rendered any number of times with different values. A TemplateException is thrown if there is a problem parsing or rendering the template. + +The Template contstructor allows passing a name, this name will be used in error messages. When working with a number of templates, it is important to pass a name so that the error messages specify which template caused the error. + +By default all output from `{{variable}}` tags is html escaped, this behaviour can be changed by passing htmlEscapeValues : false to the Template constructor. You can also use a `{{{triple mustache}}}` tag, or a unescaped variable tag `{{&unescaped}}`, the output from these tags is not escaped. + +## Differences between strict mode and lenient mode. + +### Strict mode (default) + +* Tag names may only contain the characters a-z, A-Z, 0-9, underscore, period and minus. Other characters in tags will cause a TemplateException to be thrown during parsing. + +* During rendering, if no map key or object member which matches the tag name is found, then a TemplateException will be thrown. + +### Lenient mode + +* Tag names may use any characters. +* During rendering, if no map key or object member which matches the tag name is found, then silently ignore and output nothing. + +## Nested paths + +```dart + var t = Template('{{ author.name }}'); + var output = template.renderString({'author': {'name': 'Greg Lowe'}}); +``` + +## Partials - example usage + +```dart + +var partial = Template('{{ foo }}', name: 'partial'); + +var resolver = (String name) { + if (name == 'partial-name') { // Name of partial tag. + return partial; + } +}; + +var t = Template('{{> partial-name }}', partialResolver: resolver); + +var output = t.renderString({'foo': 'bar'}); // bar + +``` + +## Lambdas - example usage + +```dart +var t = Template('{{# foo }}'); +var lambda = (_) => 'bar'; +t.renderString({'foo': lambda}); // bar +``` + +```dart +var t = Template('{{# foo }}hidden{{/ foo }}'); +var lambda = (_) => 'shown'; +t.renderString('foo': lambda); // shown +``` + +```dart +var t = Template('{{# foo }}oi{{/ foo }}'); +var lambda = (LambdaContext ctx) => '${ctx.renderString().toUpperCase()}'; +t.renderString({'foo': lambda}); // OI +``` + +```dart +var t = Template('{{# foo }}{{bar}}{{/ foo }}'); +var lambda = (LambdaContext ctx) => '${ctx.renderString().toUpperCase()}'; +t.renderString({'foo': lambda, 'bar': 'pub'}); // PUB +``` + +```dart +var t = Template('{{# foo }}{{bar}}{{/ foo }}'); +var lambda = (LambdaContext ctx) => '${ctx.renderString().toUpperCase()}'; +t.renderString({'foo': lambda, 'bar': 'pub'}); // PUB +``` + +In the following example `LambdaContext.renderSource(source)` re-parses the source string in the current context, this is the default behaviour in many mustache implementations. Since re-parsing the content is slow, and often not required, this library makes this step optional. + +```dart +var t = Template('{{# foo }}{{bar}}{{/ foo }}'); +var lambda = (LambdaContext ctx) => ctx.renderSource(ctx.source + ' {{cmd}}'); +t.renderString({'foo': lambda, 'bar': 'pub', 'cmd': 'build'}); // pub build +``` diff --git a/third_party/packages/mustache_template/ci_config.yaml b/third_party/packages/mustache_template/ci_config.yaml new file mode 100644 index 0000000..b352e13 --- /dev/null +++ b/third_party/packages/mustache_template/ci_config.yaml @@ -0,0 +1,2 @@ +# TODO(stuartmorgan): Remove this; see https://github.com/flutter/flutter/issues/102679 +exempt_from_excerpts: true diff --git a/third_party/packages/mustache_template/dart_test.yaml b/third_party/packages/mustache_template/dart_test.yaml new file mode 100644 index 0000000..cdb656d --- /dev/null +++ b/third_party/packages/mustache_template/dart_test.yaml @@ -0,0 +1,6 @@ +# See https://github.com/dart-lang/test/blob/master/pkgs/test/doc/configuration.md#arguments +override_platforms: + chrome: + settings: + executable: chrome + arguments: --no-sandbox diff --git a/third_party/packages/mustache_template/lib/mustache.dart b/third_party/packages/mustache_template/lib/mustache.dart new file mode 100644 index 0000000..678f5a0 --- /dev/null +++ b/third_party/packages/mustache_template/lib/mustache.dart @@ -0,0 +1,98 @@ +import 'src/template.dart' as t; + +/// A Template can be efficiently rendered multiple times with different +/// values. +abstract class Template { + /// The constructor parses the template source and throws [TemplateException] + /// if the syntax of the source is invalid. + /// Tag names may only contain characters a-z, A-Z, 0-9, underscore, and minus, + /// unless lenient mode is specified. + factory Template( + String source, { + bool lenient, + bool htmlEscapeValues, + String name, + PartialResolver? partialResolver, + String delimiters, + }) = t.Template.fromSource; + + /// An optional name used to identify the template in error logging. + String? get name; + + /// The template that should be filled when calling [render] or + /// [renderString]. + String get source; + + /// [values] can be a combination of Map, List, String. Any non-String object + /// will be converted using toString(). Null values will cause a + /// [TemplateException], unless lenient module is enabled. + String renderString(Object? values); + + /// [values] can be a combination of Map, List, String. Any non-String object + /// will be converted using toString(). Null values will cause a + /// [TemplateException], unless lenient module is enabled. + void render(Object? values, StringSink sink); +} + +// TODO(stuartmorgan): Remove this. See https://github.com/flutter/flutter/issues/174722. +// ignore: public_member_api_docs +typedef PartialResolver = Template? Function(String); + +// TODO(stuartmorgan): Remove this. See https://github.com/flutter/flutter/issues/174722. +// ignore: public_member_api_docs +typedef LambdaFunction = Object Function(LambdaContext context); + +/// Passed as an argument to a mustache lambda function. The methods on +/// this object may only be called before the lambda function returns. If a +/// method is called after it has returned an exception will be thrown. +abstract class LambdaContext { + /// Render the current section tag in the current context and return the + /// result as a string. If provided, value will be added to the top of the + /// context's stack. + String renderString({Object? value}); + + /// Render and directly output the current section tag. If provided, value + /// will be added to the top of the context's stack. + void render({Object value}); + + /// Output a string. The output will not be html escaped, and will be written + /// before the output returned from the lambda. + void write(Object object); + + /// Get the unevaluated template source for the current section tag. + String get source; + + /// Evaluate the string as a mustache template using the current context. If + /// provided, value will be added to the top of the context's stack. + String renderSource(String source, {Object? value}); + + /// Lookup the value of a variable in the current context. + Object? lookup(String variableName); +} + +/// [TemplateException] is used to obtain the line and column numbers +/// of the token which caused parse or render to fail. +abstract class TemplateException implements Exception { + /// A message describing the problem parsing or rendering the template. + String get message; + + /// The name used to identify the template, as passed to the Template + /// constructor. + String? get templateName; + + /// The 1-based line number of the token where formatting error was found. + int get line; + + /// The 1-based column number of the token where formatting error was found. + int get column; + + /// The character offset within the template source. + int? get offset; + + /// The template source. + String? get source; + + /// A short source substring of the source at the point the problem occurred + /// with parsing or rendering. + String get context; +} diff --git a/third_party/packages/mustache_template/lib/mustache_template.dart b/third_party/packages/mustache_template/lib/mustache_template.dart new file mode 100644 index 0000000..299de3a --- /dev/null +++ b/third_party/packages/mustache_template/lib/mustache_template.dart @@ -0,0 +1 @@ +export 'mustache.dart'; diff --git a/third_party/packages/mustache_template/lib/src/lambda_context.dart b/third_party/packages/mustache_template/lib/src/lambda_context.dart new file mode 100644 index 0000000..1813637 --- /dev/null +++ b/third_party/packages/mustache_template/lib/src/lambda_context.dart @@ -0,0 +1,122 @@ +// TODO(stuartmorgan): Remove this. See https://github.com/flutter/flutter/issues/174722. +// ignore_for_file: public_member_api_docs + +import '../mustache.dart' as m; + +import 'node.dart'; +import 'parser.dart' as parser; +import 'renderer.dart'; +import 'template_exception.dart'; + +/// Passed as an argument to a mustache lambda function. +class LambdaContext implements m.LambdaContext { + LambdaContext(this._node, this._renderer); + final Node _node; + final Renderer _renderer; + bool _closed = false; + + void close() { + _closed = true; + } + + void _checkClosed() { + if (_closed) { + throw _error('LambdaContext accessed outside of callback.'); + } + } + + TemplateException _error(String msg) { + return TemplateException(msg, _renderer.templateName, _renderer.source, _node.start); + } + + @override + String renderString({Object? value}) { + _checkClosed(); + if (_node is! SectionNode) { + throw _error('LambdaContext.renderString() can only be called on section tags.'); + } + final sink = StringBuffer(); + _renderSubtree(sink, value); + return sink.toString(); + } + + void _renderSubtree(StringSink sink, Object? value) { + final renderer = Renderer.subtree(_renderer, sink); + final section = _node as SectionNode; + if (value != null) { + renderer.push(value); + } + renderer.render(section.children); + } + + @override + void render({Object? value}) { + _checkClosed(); + if (_node is! SectionNode) { + throw _error('LambdaContext.render() can only be called on section tags.'); + } + _renderSubtree(_renderer.sink, value); + } + + @override + void write(Object object) { + _checkClosed(); + _renderer.write(object); + } + + @override + String get source { + _checkClosed(); + + if (_node is! SectionNode) { + return ''; + } + + final SectionNode node = _node; + final List nodes = node.children; + if (nodes.isEmpty) { + return ''; + } + + if (nodes.length == 1 && nodes.first is TextNode) { + return (nodes.single as TextNode).text; + } + + return _renderer.source.substring(node.contentStart, node.contentEnd); + } + + @override + String renderSource(String source, {Object? value}) { + _checkClosed(); + final sink = StringBuffer(); + + // Lambdas used for sections should parse with the current delimiters. + var delimiters = '{{ }}'; + if (_node is SectionNode) { + final SectionNode node = _node; + delimiters = node.delimiters; + } + + final List nodes = parser.parse( + source, + _renderer.lenient, + _renderer.templateName, + delimiters, + ); + + final renderer = Renderer.lambda(_renderer, source, _renderer.indent, sink); + + if (value != null) { + renderer.push(value); + } + renderer.render(nodes); + + return sink.toString(); + } + + @override + Object? lookup(String variableName) { + _checkClosed(); + return _renderer.resolveValue(variableName); + } +} diff --git a/third_party/packages/mustache_template/lib/src/node.dart b/third_party/packages/mustache_template/lib/src/node.dart new file mode 100644 index 0000000..a6a208c --- /dev/null +++ b/third_party/packages/mustache_template/lib/src/node.dart @@ -0,0 +1,94 @@ +// TODO(stuartmorgan): Remove this. See https://github.com/flutter/flutter/issues/174722. +// ignore_for_file: public_member_api_docs + +abstract class Node { + Node(this.start, this.end); + + // The offset of the start of the token in the file. Unless this is a section + // or inverse section, then this stores the start of the content of the + // section. + final int start; + final int end; + + void accept(Visitor visitor); + void visitChildren(Visitor visitor) {} +} + +abstract class Visitor { + void visitText(TextNode node); + void visitVariable(VariableNode node); + void visitSection(SectionNode node); + void visitPartial(PartialNode node); +} + +class TextNode extends Node { + TextNode(this.text, int start, int end) : super(start, end); + + final String text; + + @override + String toString() => '(TextNode "$_debugText" $start $end)'; + + String get _debugText { + final String t = text.replaceAll('\n', r'\n'); + return t.length < 50 ? t : '${t.substring(0, 48)}...'; + } + + @override + void accept(Visitor visitor) => visitor.visitText(this); +} + +class VariableNode extends Node { + VariableNode(this.name, int start, int end, {this.escape = true}) : super(start, end); + + final String name; + final bool escape; + + @override + void accept(Visitor visitor) => visitor.visitVariable(this); + + @override + String toString() => '(VariableNode "$name" escape: $escape $start $end)'; +} + +class SectionNode extends Node { + SectionNode(this.name, int start, int end, this.delimiters, {this.inverse = false}) + : contentStart = end, + super(start, end); + + final String name; + final String delimiters; + final bool inverse; + final int contentStart; + int? contentEnd; // Set in parser when close tag is parsed. + final List children = []; + + @override + void accept(Visitor visitor) => visitor.visitSection(this); + + @override + void visitChildren(Visitor visitor) { + for (final Node node in children) { + node.accept(visitor); + } + } + + @override + String toString() => '(SectionNode $name inverse: $inverse $start $end)'; +} + +class PartialNode extends Node { + PartialNode(this.name, int start, int end, this.indent) : super(start, end); + + final String name; + + // Used to store the preceding whitespace before a partial tag, so that + // it's content can be correctly indented. + final String indent; + + @override + void accept(Visitor visitor) => visitor.visitPartial(this); + + @override + String toString() => '(PartialNode $name $start $end "$indent")'; +} diff --git a/third_party/packages/mustache_template/lib/src/parser.dart b/third_party/packages/mustache_template/lib/src/parser.dart new file mode 100644 index 0000000..e92378e --- /dev/null +++ b/third_party/packages/mustache_template/lib/src/parser.dart @@ -0,0 +1,387 @@ +// TODO(stuartmorgan): Remove this. See https://github.com/flutter/flutter/issues/174722. +// ignore_for_file: public_member_api_docs + +import 'node.dart'; +import 'scanner.dart'; +import 'template_exception.dart'; +import 'token.dart'; + +List parse(String source, bool lenient, String? templateName, String delimiters) { + final parser = Parser(source, templateName, delimiters, lenient: lenient); + return parser.parse(); +} + +class Tag { + Tag(this.type, this.name, this.start, this.end); + final TagType type; + final String name; + final int start; + final int end; +} + +class TagType { + const TagType(this.name); + final String name; + + static const TagType openSection = TagType('openSection'); + static const TagType openInverseSection = TagType('openInverseSection'); + static const TagType closeSection = TagType('closeSection'); + static const TagType variable = TagType('variable'); + static const TagType tripleMustache = TagType('tripleMustache'); + static const TagType unescapedVariable = TagType('unescapedVariable'); + static const TagType partial = TagType('partial'); + static const TagType comment = TagType('comment'); + static const TagType changeDelimiter = TagType('changeDelimiter'); +} + +class Parser { + Parser(String source, String? templateName, String delimiters, {bool lenient = false}) + : _source = source, + _templateName = templateName, + _delimiters = delimiters, + _lenient = lenient, + _scanner = Scanner(source, templateName, delimiters); + + final String _source; + final bool _lenient; + final String? _templateName; + final String _delimiters; + final Scanner _scanner; + final List _stack = []; + late List _tokens; + late String _currentDelimiters; + int _offset = 0; + + List parse() { + _tokens = _scanner.scan(); + _currentDelimiters = _delimiters; + _stack.clear(); + _stack.add(SectionNode('root', 0, 0, _delimiters)); + + // Handle a standalone tag on first line, including special case where the + // first line is empty. + final Token? lineEnd = _readIf(TokenType.lineEnd, eofOk: true); + if (lineEnd != null) { + _appendTextToken(lineEnd); + } + _parseLine(); + + for (Token? token = _peek(); token != null; token = _peek()) { + switch (token.type) { + case TokenType.text: + case TokenType.whitespace: + _read(); + _appendTextToken(token); + + case TokenType.openDelimiter: + final Tag? tag = _readTag(); + final Node? node = _createNodeFromTag(tag); + if (tag != null) { + _appendTag(tag, node); + } + + case TokenType.changeDelimiter: + _read(); + _currentDelimiters = token.value; + + case TokenType.lineEnd: + _appendTextToken(_read()!); + _parseLine(); + + default: + throw StateError('Unreachable code.'); + } + } + + if (_stack.length != 1) { + throw TemplateException( + "Unclosed tag: '${_stack.last.name}'.", + _templateName, + _source, + _stack.last.start, + ); + } + + return _stack.last.children; + } + + // Returns null on EOF. + Token? _peek() => _offset < _tokens.length ? _tokens[_offset] : null; + + // Returns null on EOF. + Token? _read() { + Token? t; + if (_offset < _tokens.length) { + t = _tokens[_offset]; + _offset++; + } + return t; + } + + Token _expect(TokenType type) { + final Token? token = _read(); + if (token == null) { + throw _errorEof(); + } + if (token.type != type) { + throw _error('Expected: $type found: ${token.type}.', _offset); + } + return token; + } + + Token? _readIf(TokenType type, {bool eofOk = false}) { + final Token? token = _peek(); + if (!eofOk && token == null) { + throw _errorEof(); + } + return token != null && token.type == type ? _read() : null; + } + + TemplateException _errorEof() => _error('Unexpected end of input.', _source.length - 1); + + TemplateException _error(String msg, int offset) => + TemplateException(msg, _templateName, _source, offset); + + // Add a text node to top most section on the stack and merge consecutive + // text nodes together. + void _appendTextToken(Token token) { + assert( + const [ + TokenType.text, + TokenType.lineEnd, + TokenType.whitespace, + ].contains(token.type), + ); + final List children = _stack.last.children; + if (children.isEmpty || children.last is! TextNode) { + children.add(TextNode(token.value, token.start, token.end)); + } else { + final last = children.removeLast() as TextNode; + final node = TextNode(last.text + token.value, last.start, token.end); + children.add(node); + } + } + + // Add the node to top most section on the stack. If a section node then + // push it onto the stack, if a close section tag, then pop the stack. + void _appendTag(Tag tag, Node? node) { + switch (tag.type) { + // {{#...}} {{^...}} + case TagType.openSection: + case TagType.openInverseSection: + _stack.last.children.add(node!); + _stack.add(node as SectionNode); + + // {{/...}} + case TagType.closeSection: + if (tag.name != _stack.last.name) { + throw TemplateException( + 'Mismatched tag, expected: ' + "'${_stack.last.name}', was: '${tag.name}'", + _templateName, + _source, + tag.start, + ); + } + final SectionNode node = _stack.removeLast(); + node.contentEnd = tag.start; + + // {{...}} {{&...}} {{{...}}} + case TagType.variable: + case TagType.unescapedVariable: + case TagType.tripleMustache: + case TagType.partial: + if (node != null) { + _stack.last.children.add(node); + } + + case TagType.comment: + case TagType.changeDelimiter: + // Ignore. + break; + + default: + throw StateError('Unreachable code.'); + } + } + + // Handle standalone tags and indented partials. + // + // A "standalone tag" in the spec is a tag one a line where the line only + // contains whitespace. During rendering the whitespace is omitted. + // Standalone partials also indent their content to match the tag during + // rendering. + + // match: + // lineEnd whitespace openDelimiter any* closeDelimiter whitespace lineEnd + // + // Where lineEnd can also mean start/end of the source. + void _parseLine() { + // If first token is a newline append it. + final Token? t = _peek(); + if (t != null && t.type == TokenType.lineEnd) { + _appendTextToken(t); + } + + // Continue parsing standalone lines until we find one than isn't a + // standalone line. + while (_peek() != null) { + _readIf(TokenType.lineEnd, eofOk: true); + final Token? precedingWhitespace = _readIf(TokenType.whitespace, eofOk: true); + final String indent = precedingWhitespace == null ? '' : precedingWhitespace.value; + final Tag? tag = _readTag(); + final Node? tagNode = _createNodeFromTag(tag, partialIndent: indent); + final Token? followingWhitespace = _readIf(TokenType.whitespace, eofOk: true); + + const standaloneTypes = [ + TagType.openSection, + TagType.closeSection, + TagType.openInverseSection, + TagType.partial, + TagType.comment, + TagType.changeDelimiter, + ]; + + if (tag != null && + (_peek() == null || _peek()!.type == TokenType.lineEnd) && + standaloneTypes.contains(tag.type)) { + // This is a tag on a "standalone line", so do not create text nodes + // for whitespace, or the following newline. + _appendTag(tag, tagNode); + // Now continue to loop and parse the next line. + } else { + // This is not a standalone line so add the whitespace to the ast. + if (precedingWhitespace != null) { + _appendTextToken(precedingWhitespace); + } + if (tag != null) { + _appendTag(tag, tagNode); + } + if (followingWhitespace != null) { + _appendTextToken(followingWhitespace); + } + // Done parsing standalone lines. Exit the loop. + break; + } + } + } + + final RegExp _validIdentifier = RegExp(r'^[0-9a-zA-Z\_\-\.]+$'); + + static const Map _tagTypeMap = { + '#': TagType.openSection, + '^': TagType.openInverseSection, + '/': TagType.closeSection, + '&': TagType.unescapedVariable, + '>': TagType.partial, + '!': TagType.comment, + }; + + // If open delimiter, or change delimiter token then return a tag. + // If EOF or any another token then return null. + Tag? _readTag() { + final Token? t = _peek(); + if (t == null || (t.type != TokenType.changeDelimiter && t.type != TokenType.openDelimiter)) { + return null; + } else if (t.type == TokenType.changeDelimiter) { + _read(); + // Remember the current delimiters. + _currentDelimiters = t.value; + + // Change delimiter tags are already parsed by the scanner. + // So just create a tag and return it. + return Tag(TagType.changeDelimiter, t.value, t.start, t.end); + } + + // Start parsing a typical tag. + + final Token open = _expect(TokenType.openDelimiter); + + _readIf(TokenType.whitespace); + + // A sigil is the character which identifies which sort of tag it is, + // i.e. '#', '/', or '>'. + // Variable tags and triple mustache tags don't have a sigil. + TagType? tagType; + + if (open.value == '{{{') { + tagType = TagType.tripleMustache; + } else { + final Token? sigil = _readIf(TokenType.sigil); + tagType = sigil == null ? TagType.variable : _tagTypeMap[sigil.value]; + } + + _readIf(TokenType.whitespace); + + // TODOsplit up names here instead of during render. + // Also check that they are valid token types. + // TODOsplit up names here instead of during render. + // Also check that they are valid token types. + final list = []; + for (Token? t = _peek(); t != null && t.type != TokenType.closeDelimiter; t = _peek()) { + _read(); + list.add(t); + } + final String name = list.map((Token t) => t.value).join().trim(); + if (_peek() == null) { + throw _errorEof(); + } + + // Check to see if the tag name is valid. + if (tagType != TagType.comment) { + if (name == '') { + throw _error('Empty tag name.', open.start); + } + if (!_lenient) { + if (name.contains('\t') || name.contains('\n') || name.contains('\r')) { + throw _error('Tags may not contain newlines or tabs.', open.start); + } + + if (!_validIdentifier.hasMatch(name)) { + throw _error( + 'Unless in lenient mode, tags may only contain the ' + 'characters a-z, A-Z, minus, underscore and period.', + open.start, + ); + } + } + } + + final Token close = _expect(TokenType.closeDelimiter); + + return Tag(tagType!, name, open.start, close.end); + } + + Node? _createNodeFromTag(Tag? tag, {String partialIndent = ''}) { + // Handle EOF case. + if (tag == null) { + return null; + } + + Node? node; + switch (tag.type) { + case TagType.openSection: + case TagType.openInverseSection: + final inverse = tag.type == TagType.openInverseSection; + node = SectionNode(tag.name, tag.start, tag.end, _currentDelimiters, inverse: inverse); + + case TagType.variable: + case TagType.unescapedVariable: + case TagType.tripleMustache: + final escape = tag.type == TagType.variable; + node = VariableNode(tag.name, tag.start, tag.end, escape: escape); + + case TagType.partial: + node = PartialNode(tag.name, tag.start, tag.end, partialIndent); + + case TagType.closeSection: + case TagType.comment: + case TagType.changeDelimiter: + node = null; + + default: + throw StateError('Unreachable code.'); + } + return node; + } +} diff --git a/third_party/packages/mustache_template/lib/src/renderer.dart b/third_party/packages/mustache_template/lib/src/renderer.dart new file mode 100644 index 0000000..2226bf1 --- /dev/null +++ b/third_party/packages/mustache_template/lib/src/renderer.dart @@ -0,0 +1,316 @@ +// TODO(stuartmorgan): Remove this. See https://github.com/flutter/flutter/issues/174722. +// ignore_for_file: public_member_api_docs + +import '../mustache.dart' as m; +import 'lambda_context.dart'; +import 'node.dart'; +import 'template.dart'; +import 'template_exception.dart'; + +const Object noSuchProperty = Object(); +final RegExp _integerTag = RegExp(r'^[0-9]+$'); + +class Renderer extends Visitor { + Renderer( + this.sink, + List stack, + this.lenient, + this.htmlEscapeValues, + this.partialResolver, + this.templateName, + this.indent, + this.source, + ) : _stack = List.from(stack); + + Renderer.partial(Renderer ctx, Template partial, String indent) + : this( + ctx.sink, + ctx._stack, + ctx.lenient, + ctx.htmlEscapeValues, + ctx.partialResolver, + ctx.templateName, + ctx.indent + indent, + partial.source, + ); + + Renderer.subtree(Renderer ctx, StringSink sink) + : this( + sink, + ctx._stack, + ctx.lenient, + ctx.htmlEscapeValues, + ctx.partialResolver, + ctx.templateName, + ctx.indent, + ctx.source, + ); + + Renderer.lambda(Renderer ctx, String source, String indent, StringSink sink) + : this( + sink, + ctx._stack, + ctx.lenient, + ctx.htmlEscapeValues, + ctx.partialResolver, + ctx.templateName, + ctx.indent + indent, + source, + ); + + final StringSink sink; + final List _stack; + final bool lenient; + final bool htmlEscapeValues; + final m.PartialResolver? partialResolver; + final String? templateName; + final String indent; + final String source; + + void push(Object? value) => _stack.add(value); + + Object? pop() => _stack.removeLast(); + + void write(Object output) => sink.write(output.toString()); + + void render(List nodes) { + if (indent == '') { + for (final n in nodes) { + n.accept(this); + } + } else if (nodes.isNotEmpty) { + // Special case to make sure there is not an extra indent after the last + // line in the partial file. + write(indent); + + nodes.take(nodes.length - 1).forEach((Node n) => n.accept(this)); + + final Node node = nodes.last; + if (node is TextNode) { + visitText(node, lastNode: true); + } else { + node.accept(this); + } + } + } + + @override + void visitText(TextNode node, {bool lastNode = false}) { + if (node.text == '') { + return; + } + if (indent == '') { + write(node.text); + } else if (lastNode && node.text.runes.last == _NEWLINE) { + // Don't indent after the last line in a template. + final String s = node.text.substring(0, node.text.length - 1); + write(s.replaceAll('\n', '\n$indent')); + write('\n'); + } else { + write(node.text.replaceAll('\n', '\n$indent')); + } + } + + @override + void visitVariable(VariableNode node) { + Object? value = resolveValue(node.name); + + if (value is Function) { + final context = LambdaContext(node, this); + final Function valueFunction = value; + // TODO(stuartmorgan): Add function typing in a way that doesn't break + // backward compatibility. + // ignore: avoid_dynamic_calls + value = valueFunction(context); + context.close(); + } + + if (value == noSuchProperty) { + if (!lenient) { + throw error('Value was missing for variable tag: ${node.name}.', node); + } + } else { + final valueString = (value == null) ? '' : value.toString(); + final String output = !node.escape || !htmlEscapeValues + ? valueString + : _htmlEscape(valueString); + write(output); + } + } + + @override + void visitSection(SectionNode node) { + if (node.inverse) { + _renderInvSection(node); + } else { + _renderSection(node); + } + } + + void _renderSection(SectionNode node) { + final Object? value = resolveValue(node.name); + + if (value == null) { + // Do nothing. + } else if (value is Iterable) { + for (final Object? v in value) { + _renderWithValue(node, v); + } + } else if (value is Map) { + _renderWithValue(node, value); + } else if (value == true) { + _renderWithValue(node, value); + } else if (value == false) { + // Do nothing. + } else if (value == noSuchProperty) { + if (!lenient) { + throw error('Value was missing for section tag: ${node.name}.', node); + } + } else if (value is Function) { + final context = LambdaContext(node, this); + // TODO(stuartmorgan): Add function typing in a way that doesn't break + // backward compatibility. + // ignore: avoid_dynamic_calls + final Object? output = value(context); + context.close(); + if (output != null) { + write(output); + } + } else { + // Assume the value might have accessible member values via mirrors. + _renderWithValue(node, value); + } + } + + void _renderInvSection(SectionNode node) { + final Object? value = resolveValue(node.name); + + if (value == null) { + _renderWithValue(node, null); + } else if ((value is Iterable && value.isEmpty) || value == false) { + _renderWithValue(node, node.name); + } else if (value == true || value is Map || value is Iterable) { + // Do nothing. + } else if (value == noSuchProperty) { + if (lenient) { + _renderWithValue(node, null); + } else { + throw error('Value was missing for inverse section: ${node.name}.', node); + } + } else if (value is Function) { + // Do nothing. + // TODO(stuartmorgan): Determine whether this should be an error in + // strict mode (per comment in initial source import). + } else if (lenient) { + // We consider all other values as 'true' in lenient mode. Since this + // is an inverted section, we do nothing. + } else { + throw error( + 'Invalid value type for inverse section, ' + 'section: ${node.name}, ' + 'type: ${value.runtimeType}.', + node, + ); + } + } + + void _renderWithValue(SectionNode node, Object? value) { + push(value); + node.visitChildren(this); + pop(); + } + + @override + void visitPartial(PartialNode node) { + final String partialName = node.name; + final Template? template = partialResolver == null + ? null + : (partialResolver!(partialName) as Template?); + if (template != null) { + final renderer = Renderer.partial(this, template, node.indent); + final List nodes = getTemplateNodes(template); + renderer.render(nodes); + } else if (lenient) { + // do nothing + } else { + throw error('Partial not found: $partialName.', node); + } + } + + // Walks up the stack looking for the variable. + // Handles dotted names of the form "a.b.c". + Object? resolveValue(String name) { + if (name == '.') { + return _stack.last; + } + final List parts = name.split('.'); + Object? object = noSuchProperty; + for (final Object? o in _stack.reversed) { + object = _getNamedProperty(o, parts[0]); + if (object != noSuchProperty) { + break; + } + } + for (var i = 1; i < parts.length; i++) { + if (object == noSuchProperty) { + return noSuchProperty; + } + object = _getNamedProperty(object, parts[i]); + } + return object; + } + + // Returns the property of the given object by name. For a map, + // which contains the key name, this is object[name]. For other + // objects, this is object.name or object.name(). If no property + // by the given name exists, this method returns noSuchProperty. + Object? _getNamedProperty(dynamic object, String name) { + if (object is Map && object.containsKey(name)) { + return object[name]; + } + + if (object is List && _integerTag.hasMatch(name)) { + final int index = int.parse(name); + if (object.length > index) { + return object[index]; + } + } + return noSuchProperty; + } + + m.TemplateException error(String message, Node node) => + TemplateException(message, templateName, source, node.start); + + static const Map _htmlEscapeMap = { + _AMP: '&', + _LT: '<', + _GT: '>', + _QUOTE: '"', + _APOS: ''', + _FORWARD_SLASH: '/', + }; + + String _htmlEscape(String s) { + final buffer = StringBuffer(); + var startIndex = 0; + var i = 0; + for (final int c in s.runes) { + if (c == _AMP || c == _LT || c == _GT || c == _QUOTE || c == _APOS || c == _FORWARD_SLASH) { + buffer.write(s.substring(startIndex, i)); + buffer.write(_htmlEscapeMap[c]); + startIndex = i + 1; + } + i++; + } + buffer.write(s.substring(startIndex)); + return buffer.toString(); + } +} + +const int _AMP = 38; +const int _LT = 60; +const int _GT = 62; +const int _QUOTE = 34; +const int _APOS = 39; +const int _FORWARD_SLASH = 47; +const int _NEWLINE = 10; diff --git a/third_party/packages/mustache_template/lib/src/scanner.dart b/third_party/packages/mustache_template/lib/src/scanner.dart new file mode 100644 index 0000000..00eabe7 --- /dev/null +++ b/third_party/packages/mustache_template/lib/src/scanner.dart @@ -0,0 +1,379 @@ +// TODO(stuartmorgan): Remove this. See https://github.com/flutter/flutter/issues/174722. +// ignore_for_file: public_member_api_docs + +import 'template_exception.dart'; +import 'token.dart'; + +class Scanner { + Scanner(String source, this._templateName, String? delimiters) + : _source = source, + _itr = source.runes.iterator { + if (source == '') { + _c = _EOF; + } else { + _itr.moveNext(); + _c = _itr.current; + } + + if (delimiters == null) { + _openDelimiter = _openDelimiterInner = _OPEN_MUSTACHE; + _closeDelimiter = _closeDelimiterInner = _CLOSE_MUSTACHE; + } else if (delimiters.length == 3) { + _openDelimiter = delimiters.codeUnits[0]; + _closeDelimiter = delimiters.codeUnits[2]; + } else if (delimiters.length == 5) { + _openDelimiter = delimiters.codeUnits[0]; + _openDelimiterInner = delimiters.codeUnits[1]; + _closeDelimiterInner = delimiters.codeUnits[3]; + _closeDelimiter = delimiters.codeUnits[4]; + } else { + throw TemplateException('Invalid delimiter string $delimiters', null, null, null); + } + } + + final String? _templateName; + final String _source; + + final Iterator _itr; + int _offset = 0; + int _c = 0; + + final List _tokens = []; + + // These can be changed by the change delimiter tag. + int? _openDelimiter; + int? _openDelimiterInner; + int? _closeDelimiterInner; + int? _closeDelimiter; + + List scan() { + for (int c = _peek(); c != _EOF; c = _peek()) { + // Scan text tokens. + if (c != _openDelimiter) { + _scanText(); + continue; + } + + final int start = _offset; + + // Read first open delimiter character. + _read(); + + // If only a single delimiter character then create a text token. + if (_openDelimiterInner != null && _peek() != _openDelimiterInner) { + final value = String.fromCharCode(_openDelimiter!); + _append(TokenType.text, value, start, _offset); + continue; + } + + if (_openDelimiterInner != null) { + _expect(_openDelimiterInner!); + } + + // Handle triple mustache. + if (_openDelimiterInner == _OPEN_MUSTACHE && + _openDelimiter == _OPEN_MUSTACHE && + _peek() == _OPEN_MUSTACHE) { + _read(); + _append(TokenType.openDelimiter, '{{{', start, _offset); + _scanTagContent(); + _scanCloseTripleMustache(); + } else { + // Check to see if this is a change delimiter tag. {{= | | =}} + // Need to skip whitespace and check for "=". + final int wsStart = _offset; + final String ws = _readWhile(_isWhitespace); + + if (_peek() == _EQUAL) { + _parseChangeDelimiterTag(start); + } else { + // Scan standard mustache tag. + final value = String.fromCharCodes( + _openDelimiterInner == null + ? [_openDelimiter!] + : [_openDelimiter!, _openDelimiterInner!], + ); + + _append(TokenType.openDelimiter, value, start, wsStart); + + if (ws != '') { + _append(TokenType.whitespace, ws, wsStart, _offset); + } + + _scanTagContent(); + _scanCloseDelimiter(); + } + } + } + return _tokens; + } + + int _peek() => _c; + + int _read() { + final int c = _c; + _offset++; + _c = _itr.moveNext() ? _itr.current : _EOF; + return c; + } + + String _readWhile(bool Function(int charCode) test) { + if (_c == _EOF) { + return ''; + } + + final buffer = StringBuffer(); + while (_peek() != _EOF && test(_peek())) { + buffer.writeCharCode(_read()); + } + return buffer.toString(); + } + + void _expect(int expectedCharCode) { + final int c = _read(); + + if (c == _EOF) { + throw TemplateException('Unexpected end of input', _templateName, _source, _offset - 1); + } + if (c != expectedCharCode) { + throw TemplateException( + 'Unexpected character, ' + 'expected: ${String.fromCharCode(expectedCharCode)}, ' + 'was: ${String.fromCharCode(c)}', + _templateName, + _source, + _offset - 1, + ); + } + } + + void _append(TokenType type, String value, int start, int end) => + _tokens.add(Token(type, value, start, end)); + + bool _isWhitespace(int c) => const [_SPACE, _TAB, _NEWLINE, _RETURN].contains(c); + + // Scan text. This adds text tokens, line end tokens, and whitespace + // tokens for whitespace at the beginning of a line. This is because the + // mustache spec requires special handing of whitespace. + void _scanText() { + var start = 0; + TokenType token; + String value; + + for (int c = _peek(); c != _EOF && c != _openDelimiter; c = _peek()) { + start = _offset; + + switch (c) { + case _SPACE: + case _TAB: + value = _readWhile((int c) => c == _SPACE || c == _TAB); + token = TokenType.whitespace; + + case _NEWLINE: + _read(); + token = TokenType.lineEnd; + value = '\n'; + + case _RETURN: + _read(); + if (_peek() == _NEWLINE) { + _read(); + token = TokenType.lineEnd; + value = '\r\n'; + } else { + token = TokenType.text; + value = '\r'; + } + + default: + value = _readWhile((int c) => c != _openDelimiter && c != _NEWLINE); + token = TokenType.text; + } + + _append(token, value, start, _offset); + } + } + + // Scan contents of a tag and the end delimiter token. + void _scanTagContent() { + int start; + TokenType token; + String value; + + bool isCloseDelimiter(int c) => + (_closeDelimiterInner == null && c == _closeDelimiter) || + (_closeDelimiterInner != null && c == _closeDelimiterInner); + + for (int c = _peek(); c != _EOF && !isCloseDelimiter(c); c = _peek()) { + start = _offset; + + switch (c) { + case _HASH: + case _CARET: + case _FORWARD_SLASH: + case _GT: + case _AMP: + case _EXCLAIM: + _read(); + token = TokenType.sigil; + value = String.fromCharCode(c); + + case _SPACE: + case _TAB: + case _NEWLINE: + case _RETURN: + token = TokenType.whitespace; + value = _readWhile(_isWhitespace); + + case _PERIOD: + _read(); + token = TokenType.dot; + value = '.'; + + default: + // Identifier can be any other character in lenient mode. + token = TokenType.identifier; + value = _readWhile( + (int c) => + !(const [ + _HASH, + _CARET, + _FORWARD_SLASH, + _GT, + _AMP, + _EXCLAIM, + _SPACE, + _TAB, + _NEWLINE, + _RETURN, + _PERIOD, + ].contains(c)) && + c != _closeDelimiterInner && + c != _closeDelimiter, + ); + } + _append(token, value, start, _offset); + } + } + + // Scan close delimiter token. + void _scanCloseDelimiter() { + if (_peek() != _EOF) { + final int start = _offset; + + if (_closeDelimiterInner != null) { + _expect(_closeDelimiterInner!); + } + _expect(_closeDelimiter!); + + final value = String.fromCharCodes( + _closeDelimiterInner == null + ? [_closeDelimiter!] + : [_closeDelimiterInner!, _closeDelimiter!], + ); + + _append(TokenType.closeDelimiter, value, start, _offset); + } + } + + // Scan close triple mustache delimiter token. + void _scanCloseTripleMustache() { + if (_peek() != _EOF) { + final int start = _offset; + + _expect(_CLOSE_MUSTACHE); + _expect(_CLOSE_MUSTACHE); + _expect(_CLOSE_MUSTACHE); + + _append(TokenType.closeDelimiter, '}}}', start, _offset); + } + } + + // Open delimiter characters have already been read. + void _parseChangeDelimiterTag(int start) { + _expect(_EQUAL); + + final int? delimiterInner = _closeDelimiterInner; + final int? delimiter = _closeDelimiter; + + _readWhile(_isWhitespace); + + int c; + c = _read(); + + if (c == _EQUAL) { + throw _error('Incorrect change delimiter tag.'); + } + _openDelimiter = c; + + c = _read(); + if (_isWhitespace(c)) { + _openDelimiterInner = null; + } else { + _openDelimiterInner = c; + } + + _readWhile(_isWhitespace); + + c = _read(); + + if (_isWhitespace(c) || c == _EQUAL) { + throw _error('Incorrect change delimiter tag.'); + } + + if (_isWhitespace(_peek()) || _peek() == _EQUAL) { + _closeDelimiterInner = null; + _closeDelimiter = c; + } else { + _closeDelimiterInner = c; + _closeDelimiter = _read(); + } + + _readWhile(_isWhitespace); + + _expect(_EQUAL); + + _readWhile(_isWhitespace); + + if (delimiterInner != null) { + _expect(delimiterInner); + } + _expect(delimiter!); + + // Create delimiter string. + final buffer = StringBuffer(); + buffer.writeCharCode(_openDelimiter!); + if (_openDelimiterInner != null) { + buffer.writeCharCode(_openDelimiterInner!); + } + buffer.write(' '); + if (_closeDelimiterInner != null) { + buffer.writeCharCode(_closeDelimiterInner!); + } + buffer.writeCharCode(_closeDelimiter!); + final value = buffer.toString(); + + _append(TokenType.changeDelimiter, value, start, _offset); + } + + TemplateException _error(String message) { + return TemplateException(message, _templateName, _source, _offset); + } +} + +const int _EOF = -1; +const int _TAB = 9; +const int _NEWLINE = 10; +const int _RETURN = 13; +const int _SPACE = 32; +const int _EXCLAIM = 33; +const int _HASH = 35; +const int _AMP = 38; +const int _PERIOD = 46; +const int _FORWARD_SLASH = 47; +const int _EQUAL = 61; +const int _GT = 62; +const int _CARET = 94; + +const int _OPEN_MUSTACHE = 123; +const int _CLOSE_MUSTACHE = 125; diff --git a/third_party/packages/mustache_template/lib/src/template.dart b/third_party/packages/mustache_template/lib/src/template.dart new file mode 100644 index 0000000..4da713a --- /dev/null +++ b/third_party/packages/mustache_template/lib/src/template.dart @@ -0,0 +1,58 @@ +// TODO(stuartmorgan): Remove this. See https://github.com/flutter/flutter/issues/174722. +// ignore_for_file: public_member_api_docs + +import '../mustache.dart' as m; +import 'node.dart'; +import 'parser.dart' as parser; +import 'renderer.dart'; + +class Template implements m.Template { + Template.fromSource( + this.source, { + bool lenient = false, + bool htmlEscapeValues = true, + String? name, + m.PartialResolver? partialResolver, + String delimiters = '{{ }}', + }) : _nodes = parser.parse(source, lenient, name, delimiters), + _lenient = lenient, + _htmlEscapeValues = htmlEscapeValues, + _name = name, + _partialResolver = partialResolver; + + @override + final String source; + final List _nodes; + final bool _lenient; + final bool _htmlEscapeValues; + final String? _name; + final m.PartialResolver? _partialResolver; + + @override + String? get name => _name; + + @override + String renderString(Object? values) { + final buf = StringBuffer(); + render(values, buf); + return buf.toString(); + } + + @override + void render(Object? values, StringSink sink) { + final renderer = Renderer( + sink, + [values], + _lenient, + _htmlEscapeValues, + _partialResolver, + _name, + '', + source, + ); + renderer.render(_nodes); + } +} + +// Expose getter for nodes internally within this package. +List getTemplateNodes(Template template) => template._nodes; diff --git a/third_party/packages/mustache_template/lib/src/template_exception.dart b/third_party/packages/mustache_template/lib/src/template_exception.dart new file mode 100644 index 0000000..35f16dd --- /dev/null +++ b/third_party/packages/mustache_template/lib/src/template_exception.dart @@ -0,0 +1,122 @@ +// TODO(stuartmorgan): Remove this. See https://github.com/flutter/flutter/issues/174722. +// ignore_for_file: public_member_api_docs + +import '../mustache.dart' as m; + +class TemplateException implements m.TemplateException { + TemplateException(this.message, this.templateName, this.source, this.offset); + + @override + final String message; + @override + final String? templateName; + @override + final String? source; + @override + final int? offset; + + bool _isUpdated = false; + late int _line; + late int _column; + late String _context; + + @override + int get line { + _update(); + return _line; + } + + @override + int get column { + _update(); + return _column; + } + + @override + String get context { + _update(); + return _context; + } + + @override + String toString() { + final list = []; + if (templateName != null) { + list.add(templateName); + } + list.add(line); + list.add(column); + final location = list.isEmpty ? '' : ' (${list.join(':')})'; + return '$message$location\n$context'; + } + + // This source code is a modified version of FormatException.toString(). + void _update() { + if (_isUpdated) { + return; + } + _isUpdated = true; + + if (source == null || offset == null || (offset! < 0 || offset! > source!.length)) { + return; + } + + // Find line and character column. + var lineNum = 1; + var lineStart = 0; + var lastWasCR = false; + for (var i = 0; i < offset!; i++) { + final int char = source!.codeUnitAt(i); + if (char == 0x0a) { + if (lineStart != i || !lastWasCR) { + lineNum += 1; + } + lineStart = i + 1; + lastWasCR = false; + } else if (char == 0x0d) { + lineNum++; + lineStart = i + 1; + lastWasCR = true; + } + } + + _line = lineNum; + _column = offset! - lineStart + 1; + + // Find context. + int lineEnd = source!.length; + for (int i = offset!; i < source!.length; i++) { + final int char = source!.codeUnitAt(i); + if (char == 0x0a || char == 0x0d) { + lineEnd = i; + break; + } + } + final int length = lineEnd - lineStart; + var start = lineStart; + var end = lineEnd; + var prefix = ''; + var postfix = ''; + if (length > 78) { + // Can't show entire line. Try to anchor at the nearest end, if + // one is within reach. + final int index = offset! - lineStart; + if (index < 75) { + end = start + 75; + postfix = '...'; + } else if (end - offset! < 75) { + start = end - 75; + prefix = '...'; + } else { + // Neither end is near, just pick an area around the offset. + start = offset! - 36; + end = offset! + 36; + prefix = postfix = '...'; + } + } + final String slice = source!.substring(start, end); + final int markOffset = offset! - start + prefix.length; + + _context = "$prefix$slice$postfix\n${" " * markOffset}^\n"; + } +} diff --git a/third_party/packages/mustache_template/lib/src/token.dart b/third_party/packages/mustache_template/lib/src/token.dart new file mode 100644 index 0000000..b93dd33 --- /dev/null +++ b/third_party/packages/mustache_template/lib/src/token.dart @@ -0,0 +1,38 @@ +// TODO(stuartmorgan): Remove this. See https://github.com/flutter/flutter/issues/174722. +// ignore_for_file: public_member_api_docs + +class TokenType { + const TokenType(this.name); + + final String name; + + @override + String toString() => '(TokenType $name)'; + + static const TokenType text = TokenType('text'); + static const TokenType openDelimiter = TokenType('openDelimiter'); + static const TokenType closeDelimiter = TokenType('closeDelimiter'); + + // A sigil is the word commonly used to describe the special character at the + // start of mustache tag i.e. #, ^ or /. + static const TokenType sigil = TokenType('sigil'); + static const TokenType identifier = TokenType('identifier'); + static const TokenType dot = TokenType('dot'); + + static const TokenType changeDelimiter = TokenType('changeDelimiter'); + static const TokenType whitespace = TokenType('whitespace'); + static const TokenType lineEnd = TokenType('lineEnd'); +} + +class Token { + Token(this.type, this.value, this.start, this.end); + + final TokenType type; + final String value; + + final int start; + final int end; + + @override + String toString() => '(Token ${type.name} "$value" $start $end)'; +} diff --git a/third_party/packages/mustache_template/pubspec.yaml b/third_party/packages/mustache_template/pubspec.yaml new file mode 100644 index 0000000..4acd0e7 --- /dev/null +++ b/third_party/packages/mustache_template/pubspec.yaml @@ -0,0 +1,14 @@ +name: mustache_template +description: A templating library that implements the Mustache template specification +repository: https://github.com/flutter/core-packages/tree/main/third_party/packages/mustache_template +issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+mustache_template%22 +version: 2.0.4 + +environment: + sdk: ^3.10.0 + +dev_dependencies: + test: ^1.16.5 + +topics: + - template diff --git a/third_party/packages/mustache_template/test/README.md b/third_party/packages/mustache_template/test/README.md new file mode 100644 index 0000000..2ceca82 --- /dev/null +++ b/third_party/packages/mustache_template/test/README.md @@ -0,0 +1,23 @@ +# Test Structure + +## Specifications + +`mustache_test.dart` and `mustache_specs.dart` generate tests for all mustache specifications, +except for those disabled by `mustache_test.dart`'s `unsupportedSpecs` constant. +`dart test mustache_test.dart` runs all of the generated tests. + +Each generated specification file contains the mustache commit hash from which it was generated, +and the date when it was generated. + +### Updating specifications + +From the package root, run `dart run tool/download_spec.dart` to pull new definitions from the +mustache repository into `test/specs/`. + +## Features + +Standalone or handwritten tests regarding template output go in `feature_test.dart`. + +## Parser + +Tests for the template language parser go in `parser_test.dart`. diff --git a/third_party/packages/mustache_template/test/feature_test.dart b/third_party/packages/mustache_template/test/feature_test.dart new file mode 100644 index 0000000..3ba8937 --- /dev/null +++ b/third_party/packages/mustache_template/test/feature_test.dart @@ -0,0 +1,839 @@ +import 'package:mustache_template/mustache.dart'; +import 'package:test/test.dart'; + +const String UNEXPECTED_EOF = 'Unexpected end of input'; +const String BAD_VALUE_INV_SECTION = 'Invalid value type for inverse section'; +const String BAD_TAG_NAME = 'Unless in lenient mode, tags may only contain'; +const String VALUE_MISSING = 'Value was missing'; +const String UNCLOSED_TAG = 'Unclosed tag'; + +Template parse(String source, {bool lenient = false}) => Template(source, lenient: lenient); + +void main() { + group('Basic', () { + test('Variable', () { + final String output = parse('_{{var}}_').renderString({'var': 'bob'}); + expect(output, equals('_bob_')); + }); + test('Comment', () { + final String output = parse('_{{! i am a\n comment ! }}_').renderString({}); + expect(output, equals('__')); + }); + test('Emoji', () { + final String output = parse('Hello! 🖖👍🏽\nBye! 🏳️‍🌈').renderString({}); + expect(output, equals('Hello! 🖖👍🏽\nBye! 🏳️‍🌈')); + }); + }); + group('Section', () { + test('Map', () { + final String output = parse('{{#section}}_{{var}}_{{/section}}').renderString( + >{ + 'section': {'var': 'bob'}, + }, + ); + expect(output, equals('_bob_')); + }); + test('List', () { + final String output = parse('{{#section}}_{{var}}_{{/section}}').renderString( + >>{ + 'section': >[ + {'var': 'bob'}, + {'var': 'jim'}, + ], + }, + ); + expect(output, equals('_bob__jim_')); + }); + test('Empty List', () { + final String output = parse( + '{{#section}}_{{var}}_{{/section}}', + ).renderString(>{'section': []}); + expect(output, equals('')); + }); + test('False', () { + final String output = parse( + '{{#section}}_{{var}}_{{/section}}', + ).renderString({'section': false}); + expect(output, equals('')); + }); + test('Invalid value', () { + final Exception? ex = renderFail('{{#section}}_{{var}}_{{/section}}', { + 'section': 42, + }); + if (ex is TemplateException) { + expect(ex.message, startsWith(VALUE_MISSING)); + } else { + fail('Unexpected type: $ex'); + } + }); + test('Invalid value - lenient mode', () { + final String output = parse( + '{{#var}}_{{var}}_{{/var}}', + lenient: true, + ).renderString({'var': 42}); + expect(output, equals('_42_')); + }); + + test('True', () { + final String output = parse( + '{{#section}}_ok_{{/section}}', + ).renderString({'section': true}); + expect(output, equals('_ok_')); + }); + + test('Nested', () { + final String output = + parse( + '{{#section}}.{{var}}.{{#nested}}_{{nestedvar}}_{{/nested}}.{{/section}}', + ).renderString(>{ + 'section': { + 'var': 'bob', + 'nested': >[ + {'nestedvar': 'jim'}, + {'nestedvar': 'sally'}, + ], + }, + }); + expect(output, equals('.bob._jim__sally_.')); + }); + + test('Whitespace in section tags', () { + expect( + parse('{{#foo.bar}}oi{{/foo.bar}}').renderString(>{ + 'foo': {'bar': true}, + }), + equals('oi'), + ); + expect( + parse('{{# foo.bar}}oi{{/foo.bar}}').renderString(>{ + 'foo': {'bar': true}, + }), + equals('oi'), + ); + expect( + parse('{{#foo.bar }}oi{{/foo.bar}}').renderString(>{ + 'foo': {'bar': true}, + }), + equals('oi'), + ); + expect( + parse('{{# foo.bar }}oi{{/foo.bar}}').renderString(>{ + 'foo': {'bar': true}, + }), + equals('oi'), + ); + expect( + parse('{{#foo.bar}}oi{{/ foo.bar}}').renderString(>{ + 'foo': {'bar': true}, + }), + equals('oi'), + ); + expect( + parse('{{#foo.bar}}oi{{/foo.bar }}').renderString(>{ + 'foo': {'bar': true}, + }), + equals('oi'), + ); + expect( + parse('{{#foo.bar}}oi{{/ foo.bar }}').renderString(>{ + 'foo': {'bar': true}, + }), + equals('oi'), + ); + expect( + parse('{{# foo.bar }}oi{{/ foo.bar }}').renderString(>{ + 'foo': {'bar': true}, + }), + equals('oi'), + ); + }); + + test('Whitespace in variable tags', () { + expect( + parse('{{foo.bar}}').renderString(>{ + 'foo': {'bar': true}, + }), + equals('true'), + ); + expect( + parse('{{ foo.bar}}').renderString(>{ + 'foo': {'bar': true}, + }), + equals('true'), + ); + expect( + parse('{{foo.bar }}').renderString(>{ + 'foo': {'bar': true}, + }), + equals('true'), + ); + expect( + parse('{{ foo.bar }}').renderString(>{ + 'foo': {'bar': true}, + }), + equals('true'), + ); + }); + + test('Odd whitespace in tags', () { + void render(String source, dynamic values, dynamic output) => + expect(parse(source, lenient: true).renderString(values), equals(output)); + + render('{{\t# foo}}oi{{\n/foo}}', {'foo': true}, 'oi'); + + render('{{ # # foo }} {{ oi }} {{ / # foo }}', >>{ + '# foo': >[ + {'oi': 'OI!'}, + ], + }, ' OI! '); + + render('{{ #foo }} {{ oi }} {{ /foo }}', >>{ + 'foo': >[ + {'oi': 'OI!'}, + ], + }, ' OI! '); + + render('{{\t#foo }} {{ oi }} {{ /foo }}', >>{ + 'foo': >[ + {'oi': 'OI!'}, + ], + }, ' OI! '); + + render('{{{ #foo }}} {{{ /foo }}}', {'#foo': 1, '/foo': 2}, '1 2'); + + // Invalid - I'm ok with that for now. + // render( + // "{{{ { }}}", + // {'{': 1}, + // '1'); + + render('{{\nfoo}}', {'foo': 'bar'}, 'bar'); + + render('{{\tfoo}}', {'foo': 'bar'}, 'bar'); + + render('{{\t# foo}}oi{{\n/foo}}', {'foo': true}, 'oi'); + + render('{{{\tfoo\t}}}', {'foo': true}, 'true'); + }); + + test('Sigils in tag names in lenient mode', () { + void render(String source, dynamic values, dynamic output) => + expect(parse(source, lenient: true).renderString(values), equals(output)); + + // Even in lenient mode, tag names may not be a single sigil + // character. + expect(() => parse('{{#}}', lenient: true), throwsA(isA())); + expect(() => parse('{{>}}', lenient: true), throwsA(isA())); + expect(() => parse('{{&}}', lenient: true), throwsA(isA())); + expect(() => parse('{{/}}', lenient: true), throwsA(isA())); + expect(() => parse('{{^}}', lenient: true), throwsA(isA())); + + // >a means 'a partial named "a"', not a variable named ">a", + // and in lenient mode the missing partial should fail silently + // and yield an empty string. + render('{{ >a }}', {'>a': 'oi'}, ''); + + // sigils are valid in variable tag names as long as they aren't + // in the initial position. + render('{{ a> }}', {'a>': 'oi'}, 'oi'); + render('{{ a# }}', {'a#': 'oi'}, 'oi'); + }); + + test('Empty source', () { + final t = Template(''); + expect(t.renderString({}), equals('')); + }); + + test('Template name', () { + final t = Template('', name: 'foo'); + expect(t.name, equals('foo')); + }); + + test('Bad tag', () { + expect(() => Template('{{{ foo }|'), throwsException); + }); + }); + + group('Inverse Section', () { + test('Map', () { + final String output = parse('{{^section}}_{{var}}_{{/section}}').renderString( + >{ + 'section': {'var': 'bob'}, + }, + ); + expect(output, equals('')); + }); + test('List', () { + final String output = parse('{{^section}}_{{var}}_{{/section}}').renderString( + >>{ + 'section': >[ + {'var': 'bob'}, + {'var': 'jim'}, + ], + }, + ); + expect(output, equals('')); + }); + test('Empty List', () { + final String output = parse( + '{{^section}}_ok_{{/section}}', + ).renderString(>{'section': []}); + expect(output, equals('_ok_')); + }); + test('False', () { + final String output = parse( + '{{^section}}_ok_{{/section}}', + ).renderString({'section': false}); + expect(output, equals('_ok_')); + }); + test('Invalid value', () { + final Exception? ex = renderFail('{{^section}}_{{var}}_{{/section}}', { + 'section': 42, + }); + expect(ex is TemplateException, isTrue); + expect((ex! as TemplateException).message, startsWith(BAD_VALUE_INV_SECTION)); + }); + test('Invalid value - lenient mode', () { + final String output = parse( + '{{^var}}_ok_{{/var}}', + lenient: true, + ).renderString({'var': 42}); + expect(output, equals('')); + }); + test('True', () { + final String output = parse( + '{{^section}}_ok_{{/section}}', + ).renderString({'section': true}); + expect(output, equals('')); + }); + }); + + group('Html escape', () { + test('Escape at start', () { + final String output = parse('_{{var}}_').renderString({'var': '&.'}); + expect(output, equals('_&._')); + }); + + test('Escape at end', () { + final String output = parse('_{{var}}_').renderString({'var': '.&'}); + expect(output, equals('_.&_')); + }); + + test('&', () { + final String output = parse('_{{var}}_').renderString({'var': '&'}); + expect(output, equals('_&_')); + }); + + test('<', () { + final String output = parse('_{{var}}_').renderString({'var': '<'}); + expect(output, equals('_<_')); + }); + + test('>', () { + final String output = parse('_{{var}}_').renderString({'var': '>'}); + expect(output, equals('_>_')); + }); + + test('"', () { + final String output = parse('_{{var}}_').renderString({'var': '"'}); + expect(output, equals('_"_')); + }); + + test("'", () { + final String output = parse('_{{var}}_').renderString({'var': "'"}); + expect(output, equals('_'_')); + }); + + test('/', () { + final String output = parse('_{{var}}_').renderString({'var': '/'}); + expect(output, equals('_/_')); + }); + }); + + group('Invalid format', () { + test('Mismatched tag', () { + const source = '{{#section}}_{{var}}_{{/notsection}}'; + final Exception? ex = renderFail(source, >{ + 'section': {'var': 'bob'}, + }); + expectFail(ex, 1, 22, 'Mismatched tag'); + }); + + test('Unexpected EOF', () { + const source = '{{#section}}_{{var}}_{{/section'; + final Exception? ex = renderFail(source, >{ + 'section': {'var': 'bob'}, + }); + expectFail(ex, 1, 31, UNEXPECTED_EOF); + }); + + test('Bad tag name, open section', () { + const source = r'{{#section$%$^%}}_{{var}}_{{/section}}'; + final Exception? ex = renderFail(source, >{ + 'section': {'var': 'bob'}, + }); + expectFail(ex, null, null, BAD_TAG_NAME); + }); + + test('Bad tag name, close section', () { + const source = r'{{#section}}_{{var}}_{{/section$%$^%}}'; + final Exception? ex = renderFail(source, >{ + 'section': {'var': 'bob'}, + }); + expectFail(ex, null, null, BAD_TAG_NAME); + }); + + test('Bad tag name, variable', () { + const source = r'{{#section}}_{{var$%$^%}}_{{/section}}'; + final Exception? ex = renderFail(source, >{ + 'section': {'var': 'bob'}, + }); + expectFail(ex, null, null, BAD_TAG_NAME); + }); + + test('Missing variable', () { + const source = r'{{#section}}_{{var}}_{{/section}}'; + final Exception? ex = renderFail(source, >{ + 'section': {}, + }); + expectFail(ex, null, null, VALUE_MISSING); + }); + + // Null variables shouldn't be a problem. + test('Null variable', () { + final t = Template('{{#section}}_{{var}}_{{/section}}'); + final String output = t.renderString(>{ + 'section': {'var': null}, + }); + expect(output, equals('__')); + }); + + test('Unclosed section', () { + const source = r'{{#section}}foo'; + final Exception? ex = renderFail(source, >{ + 'section': {}, + }); + expectFail(ex, null, null, UNCLOSED_TAG); + }); + }); + + group('Lenient', () { + test('Odd section name', () { + final String output = parse(r'{{#section$%$^%}}_{{var}}_{{/section$%$^%}}', lenient: true) + .renderString(>{ + r'section$%$^%': {'var': 'bob'}, + }); + expect(output, equals('_bob_')); + }); + + test('Odd variable name', () { + final String output = parse(r'{{#section}}_{{var$%$^%}}_{{/section}}', lenient: true) + .renderString(>{ + 'section': {r'var$%$^%': 'bob'}, + }); + expect(output, equals('_bob_')); + }); + + test('Null variable', () { + final String output = parse(r'{{#section}}_{{var}}_{{/section}}', lenient: true).renderString( + >{ + 'section': {'var': null}, + }, + ); + expect(output, equals('__')); + }); + + test('Null section', () { + final String output = parse( + '{{#section}}_{{var}}_{{/section}}', + lenient: true, + ).renderString({'section': null}); + expect(output, equals('')); + }); + }); + + group('Escape tags', () { + test('{{{ ... }}}', () { + final String output = parse('{{{blah}}}').renderString({'blah': '&'}); + expect(output, equals('&')); + }); + test('{{& ... }}', () { + final String output = parse('{{{blah}}}').renderString({'blah': '&'}); + expect(output, equals('&')); + }); + }); + + group('Partial tag', () { + String partialTest( + Map values, + Map sources, + String renderTemplate, { + bool lenient = false, + }) { + final templates = {}; + Template? resolver(String name) => templates[name]; + for (final String k in sources.keys) { + templates[k] = Template( + sources[k]! as String, + name: k, + lenient: lenient, + partialResolver: resolver, + ); + } + final Template? t = resolver(renderTemplate); + return t!.renderString(values); + } + + test('basic', () { + final String output = partialTest( + {'foo': 'bar'}, + {'root': '{{>partial}}', 'partial': '{{foo}}'}, + 'root', + ); + expect(output, 'bar'); + }); + + test('missing partial strict', () { + var threw = false; + try { + partialTest( + {'foo': 'bar'}, + {'root': '{{>partial}}'}, + 'root', + ); + } on Exception catch (e) { + expect(e is TemplateException, isTrue); + threw = true; + } + expect(threw, isTrue); + }); + + test('missing partial lenient', () { + final String output = partialTest( + {'foo': 'bar'}, + {'root': '{{>partial}}'}, + 'root', + lenient: true, + ); + expect(output, equals('')); + }); + + test('context', () { + final String output = partialTest( + {'text': 'content'}, + {'root': '"{{>partial}}"', 'partial': '*{{text}}*'}, + 'root', + lenient: true, + ); + expect(output, equals('"*content*"')); + }); + + test('recursion', () { + final String output = partialTest( + { + 'content': 'X', + 'nodes': >[ + {'content': 'Y', 'nodes': []}, + ], + }, + {'root': '{{>node}}', 'node': '{{content}}<{{#nodes}}{{>node}}{{/nodes}}>'}, + 'root', + lenient: true, + ); + expect(output, equals('X>')); + }); + + test('standalone without previous', () { + final String output = partialTest( + {}, + {'root': ' {{>partial}}\n>', 'partial': '>\n>'}, + 'root', + lenient: true, + ); + expect(output, equals(' >\n >>')); + }); + + test('standalone indentation', () { + final String output = partialTest( + {'content': '<\n->'}, + {'root': '\\\n {{>partial}}\n/\n', 'partial': '|\n{{{content}}}\n|\n'}, + 'root', + lenient: true, + ); + expect(output, equals('\\\n |\n <\n->\n |\n/\n')); + }); + }); + + group('Lambdas', () { + void lambdaTest({required String template, dynamic lambda, dynamic output}) => + expect(parse(template).renderString({'lambda': lambda}), equals(output)); + + test('basic', () { + lambdaTest(template: 'Hello, {{lambda}}!', lambda: (_) => 'world', output: 'Hello, world!'); + }); + + test('escaping', () { + lambdaTest(template: '<{{lambda}}{{{lambda}}}', lambda: (_) => '>', output: '<>>'); + }); + + test('sections', () { + lambdaTest( + template: '{{#lambda}}FILE{{/lambda}} != {{#lambda}}LINE{{/lambda}}', + lambda: (LambdaContext ctx) => '__${ctx.renderString()}__', + output: '__FILE__ != __LINE__', + ); + }); + + test('inverted sections truthy', () { + const template = '<{{^lambda}}{{static}}{{/lambda}}>'; + final values = {'lambda': (_) => false, 'static': 'static'}; + const output = '<>'; + expect(parse(template).renderString(values), equals(output)); + }); + + test("seth's use case", () { + const template = '<{{#markdown}}{{content}}{{/markdown}}>'; + final values = { + 'markdown': (LambdaContext ctx) => ctx.renderString().toLowerCase(), + 'content': 'OI YOU!', + }; + const output = ''; + expect(parse(template).renderString(values), equals(output)); + }); + + test('Lambda v2', () { + const template = '<{{#markdown}}{{content}}{{/markdown}}>'; + final values = { + 'markdown': (LambdaContext ctx) => ctx.source, + 'content': 'OI YOU!', + }; + const output = '<{{content}}>'; + expect(parse(template).renderString(values), equals(output)); + }); + + test('Lambda v2...', () { + const template = '<{{#markdown}}dsfsf dsfsdf dfsdfsd{{/markdown}}>'; + final values = { + // ignore: avoid_dynamic_calls + 'markdown': (dynamic ctx) => ctx.source, + }; + const output = ''; + expect(parse(template).renderString(values), equals(output)); + }); + + test('Alternate Delimiters', () { + // A lambda's return value should parse with the default delimiters. + + const template = '{{= | | =}}\nHello, (|&lambda|)!'; + + //function() { return "|planet| => {{planet}}" } + final values = { + 'planet': 'world', + 'lambda': (LambdaContext ctx) => ctx.renderSource('|planet| => {{planet}}'), + }; + + const output = 'Hello, (|planet| => world)!'; + + expect(parse(template).renderString(values), equals(output)); + }); + + test('Alternate Delimiters 2', () { + // Lambdas used for sections should parse with the current delimiters. + + const template = '{{= | | =}}<|#lambda|-|/lambda|>'; + + //function() { return "|planet| => {{planet}}" } + final values = { + 'planet': 'Earth', + 'lambda': (LambdaContext ctx) { + final String txt = ctx.source; + return ctx.renderSource('$txt{{planet}} => |planet|$txt'); + }, + }; + + const output = '<-{{planet}} => Earth->'; + + expect(parse(template).renderString(values), equals(output)); + }); + + test('LambdaContext.lookup', () { + final t = Template('{{ foo }}'); + final String s = t.renderString({ + 'foo': (LambdaContext lc) => lc.lookup('bar'), + 'bar': 'jim', + }); + expect(s, equals('jim')); + }); + + test('LambdaContext.lookup closed', () { + final t = Template('{{ foo }}'); + LambdaContext? lc2; + t.renderString({'foo': (LambdaContext lc) => lc2 = lc, 'bar': 'jim'}); + expect(lc2, isNotNull); + expect(() => lc2?.lookup('foo'), throwsException); + }); + }); + + group('Other', () { + test('Standalone line', () { + final String val = parse( + '|\n{{#bob}}\n{{/bob}}\n|', + ).renderString(>{'bob': []}); + expect(val, equals('|\n|')); + }); + }); + + group('Array indexing', () { + test('Basic', () { + final String val = parse('{{array.1}}').renderString(>{ + 'array': [1, 2, 3], + }); + expect(val, equals('2')); + }); + test('RangeError', () { + final Exception? error = renderFail('{{array.5}}', >{ + 'array': [1, 2, 3], + }); + expect(error, isA()); + }); + }); + + group('Delimiters', () { + test('Basic', () { + final String val = parse( + '{{=<% %>=}}(<%text%>)', + ).renderString({'text': 'Hey!'}); + expect(val, equals('(Hey!)')); + }); + + test('Single delimiters', () { + final String val = parse( + '({{=[ ]=}}[text])', + ).renderString({'text': 'It worked!'}); + expect(val, equals('(It worked!)')); + }); + }); + + group('Template with custom delimiters', () { + test('Basic', () { + final t = Template('(<%text%>)', delimiters: '<% %>'); + final String val = t.renderString({'text': 'Hey!'}); + expect(val, equals('(Hey!)')); + }); + }); + + group('Lambda context', () { + test('LambdaContext write', () { + const template = '<{{#markdown}}{{content}}{{/markdown}}>'; + final values = { + 'markdown': (LambdaContext ctx) { + ctx.write('foo'); + }, + }; + const output = ''; + expect(parse(template).renderString(values), equals(output)); + }); + + test('LambdaContext render', () { + const template = '<{{#markdown}}{{content}}{{/markdown}}>'; + final values = { + 'content': 'bar', + 'markdown': (LambdaContext ctx) { + ctx.render(); + }, + }; + const output = ''; + expect(parse(template).renderString(values), equals(output)); + }); + + test('LambdaContext render with value', () { + const template = '<{{#markdown}}{{content}}{{/markdown}}>'; + final values = { + 'markdown': (LambdaContext ctx) { + ctx.render(value: {'content': 'oi!'}); + }, + }; + const output = ''; + expect(parse(template).renderString(values), equals(output)); + }); + + test('LambdaContext renderString with value', () { + const template = '<{{#markdown}}{{content}}{{/markdown}}>'; + final values = { + 'markdown': (LambdaContext ctx) { + return ctx.renderString(value: {'content': 'oi!'}); + }, + }; + const output = ''; + expect(parse(template).renderString(values), equals(output)); + }); + + test('LambdaContext write and return', () { + const template = '<{{#markdown}}{{content}}{{/markdown}}>'; + final values = { + 'markdown': (LambdaContext ctx) { + ctx.write('foo'); + return 'bar'; + }, + }; + const output = ''; + expect(parse(template).renderString(values), equals(output)); + }); + + test('LambdaContext renderSource with value', () { + const template = '<{{#markdown}}{{content}}{{/markdown}}>'; + final values = { + 'markdown': (LambdaContext ctx) { + return ctx.renderSource(ctx.source, value: {'content': 'oi!'}); + }, + }; + const output = ''; + expect(parse(template).renderString(values), equals(output)); + }); + + test('LambdaContext renderString on non-section throws', () { + final t = Template('{{ foo }}'); + expect( + () => t.renderString({'foo': (LambdaContext lc) => lc.renderString()}), + throwsA(isA()), + ); + }); + + test('LambdaContext render on non-section throws', () { + final t = Template('{{ foo }}'); + expect( + () => t.renderString({ + 'foo': (LambdaContext lc) { + lc.render(); + return ''; + }, + }), + throwsA(isA()), + ); + }); + }); +} + +Exception? renderFail(String source, Object values) { + try { + parse(source).renderString(values); + return null; + } on Exception catch (e) { + return e; + } +} + +void expectFail(Exception? ex, int? line, int? column, [String? msgStartsWith]) { + if (ex is! TemplateException) { + fail('Unexpected type: $ex'); + } + if (line != null) { + expect(ex.line, equals(line)); + } + if (column != null) { + expect(ex.column, equals(column)); + } + if (msgStartsWith != null) { + expect(ex.message, startsWith(msgStartsWith)); + } +} diff --git a/third_party/packages/mustache_template/test/mustache_specs.dart b/third_party/packages/mustache_template/test/mustache_specs.dart new file mode 100644 index 0000000..4d7bc5c --- /dev/null +++ b/third_party/packages/mustache_template/test/mustache_specs.dart @@ -0,0 +1,102 @@ +// Specification files can be downloaded here https://github.com/mustache/spec + +// Test implemented by Georgios Valotasios. +// See: https://github.com/valotas/mustache4dart + +import 'dart:convert'; + +import 'package:mustache_template/mustache.dart'; +import 'package:test/test.dart'; + +import 'specs/specs.dart'; + +String render(String source, dynamic values, {required String? Function(String) partial}) { + late Template? Function(String) resolver; + resolver = (String name) { + final String? source = partial(name); + if (source == null) { + return null; + } + return Template(source, partialResolver: resolver, lenient: true); + }; + final t = Template(source, partialResolver: resolver, lenient: true); + return t.renderString(values); +} + +void defineTests(List unsupportedSpecs) { + for (final MapEntry(key: specName, value: text) in SPECS.entries) { + if (shouldRun(specName, unsupportedSpecs)) { + _defineGroupFromFile(specName, text); + } + } +} + +void _defineGroupFromFile(String filename, String text) { + final Map jsondata = (json.decode(text) as Map) + .cast(); + final List> tests = (jsondata['tests']! as List) + .cast>(); + filename = filename.substring(filename.lastIndexOf('/') + 1); + group('Specs of $filename', () { + for (final t in tests) { + final testDescription = StringBuffer(t['name']! as String); + testDescription.write(': '); + testDescription.write(t['desc']); + final template = t['template']! as String; + final Object? data = t['data']; + final String templateOneline = template.replaceAll('\n', r'\n').replaceAll('\r', r'\r'); + final reason = StringBuffer("Could not render right '''$templateOneline'''"); + final Object? expected = t['expected']; + final partials = t['partials'] as Map?; + String? partial(String name) { + if (partials == null) { + return null; + } + return partials[name] as String?; + } + + //swap the data.lambda with a dart real function + if (data is Map && data['lambda'] != null) { + data['lambda'] = lambdas[t['name']]; + } + reason.write(" with '$data'"); + if (partials != null) { + reason.write(' and partial: $partials'); + } + test( + testDescription.toString(), + () => expect(render(template, data, partial: partial), expected, reason: reason.toString()), + ); + } + }); +} + +bool shouldRun(String specName, List unsupportedSpecs) { + return !unsupportedSpecs.contains(specName); +} + +String Function(Object?) _dummyCallableWithState() { + var callCounter = 0; + return (Object? arg) { + callCounter++; + return callCounter.toString(); + }; +} + +String Function(LambdaContext) wrapLambda(Object? Function(Object?) f) => + (LambdaContext ctx) => ctx.renderSource(f(ctx.source).toString()); + +Map lambdas = { + 'Interpolation': wrapLambda((Object? t) => 'world'), + 'Interpolation - Expansion': wrapLambda((Object? t) => '{{planet}}'), + 'Interpolation - Alternate Delimiters': wrapLambda((Object? t) => '|planet| => {{planet}}'), + 'Interpolation - Multiple Calls': wrapLambda( + _dummyCallableWithState(), + ), //function() { return (g=(function(){return this})()).calls=(g.calls||0)+1 } + 'Escaping': wrapLambda((Object? t) => '>'), + 'Section': wrapLambda((Object? txt) => txt == '{{x}}' ? 'yes' : 'no'), + 'Section - Expansion': wrapLambda((Object? txt) => '$txt{{planet}}$txt'), + 'Section - Alternate Delimiters': wrapLambda((Object? txt) => '$txt{{planet}} => |planet|$txt'), + 'Section - Multiple Calls': wrapLambda((Object? t) => '__${t}__'), + 'Inverted Section': wrapLambda((Object? txt) => false), +}; diff --git a/third_party/packages/mustache_template/test/mustache_test.dart b/third_party/packages/mustache_template/test/mustache_test.dart new file mode 100644 index 0000000..fb2ddf2 --- /dev/null +++ b/third_party/packages/mustache_template/test/mustache_test.dart @@ -0,0 +1,9 @@ +import 'mustache_specs.dart' as specs; + +/// Optional specifications that are not currently supported +/// by this library, in the format of the keys in the SPECS map in specs/specs.dart +const List unsupportedSpecs = ['inheritance', 'dynamic_names']; + +void main() { + specs.defineTests(unsupportedSpecs); +} diff --git a/third_party/packages/mustache_template/test/no_spec/whitespace.js b/third_party/packages/mustache_template/test/no_spec/whitespace.js new file mode 100644 index 0000000..598ca1a --- /dev/null +++ b/third_party/packages/mustache_template/test/no_spec/whitespace.js @@ -0,0 +1,52 @@ +var sys = require('sys'); +var mustache = require('mustache'); + +function render(source, values) { + var output = mustache.to_html(source, values); + sys.puts(output); +} + +render( + "{{ # # foo }} {{ oi }} {{ / # foo }}", + {'# foo': [{oi: 'OI!'}]}); // OI! + +render( + "{{ #foo }} {{ oi }} {{ /foo }}", + {'foo': [{oi: 'OI!'}]}); // OI! + +render( + "{{{ #foo }}} {{{ /foo }}}", + {'#foo': 1, '/foo': 2}); // 1 2 + +render( + "{{{ { }}}", + {'{': 1}); // 1 + +render( + "{{ > }}", + {'>': 'oi'}); // '' + +render( + "{{\nfoo}}", + {'foo': 'bar'}); // bar + +render( + "{{\tfoo}}", + {'foo': 'bar'}); // bar + +render( + "{{\t# foo}}oi{{\n/foo}}", + {foo: true}); // oi + +render( + "{{{\tfoo\t}}}", + {foo: true}); // oi + + +//render( +// "{{ { }}", +// {'{': 1}); // ERROR unclosed tag + +//render( +// "{{ { foo } }}", +// {'foo': 1}); // ERROR unclosed tag diff --git a/third_party/packages/mustache_template/test/no_spec/whitespace.py b/third_party/packages/mustache_template/test/no_spec/whitespace.py new file mode 100644 index 0000000..7d40b31 --- /dev/null +++ b/third_party/packages/mustache_template/test/no_spec/whitespace.py @@ -0,0 +1,49 @@ +import pystache + +def render(source, values): + print pystache.render(source, values) + +render( + "{{ # # foo }} {{ oi }} {{ / # foo }}", + {'# foo': [{'oi': 'OI!'}]}) # OI! + +render( + "{{ #foo }} {{ oi }} {{ /foo }}", + {'foo': [{'oi': 'OI!'}]}) # OI! + +render( + "{{{ #foo }}} {{{ /foo }}}", + {'#foo': 1, '/foo': 2}) # 1 2 + +render( + "{{{ { }}}", + {'{': 1}) # 1 + +render( + "{{ > }}}", + {'>': 'oi'}) # "}" bug?? + +render( + "{{\nfoo}}", + {'foo': 'bar'}) # // bar + +render( + "{{\tfoo}}", + {'foo': 'bar'}) # bar + +render( + "{{\t# foo}}oi{{\n/foo}}", + {'foo': True}) # oi + +render( + "{{{\tfoo\t}}}", + {'foo': True}) # oi + +# Don't work in mustache.js +# render( +# "{{ { }}", +# {'{': 1}) # ERROR unclosed tag + +# render( +# "{{ { foo } }}", +# {'foo': 1}) # ERROR unclosed tag diff --git a/third_party/packages/mustache_template/test/parser_test.dart b/third_party/packages/mustache_template/test/parser_test.dart new file mode 100644 index 0000000..6e1ab0b --- /dev/null +++ b/third_party/packages/mustache_template/test/parser_test.dart @@ -0,0 +1,402 @@ +// ignore_for_file: avoid_print + +import 'package:mustache_template/src/node.dart'; +import 'package:mustache_template/src/parser.dart'; +import 'package:mustache_template/src/scanner.dart'; +import 'package:mustache_template/src/template_exception.dart'; +import 'package:mustache_template/src/token.dart'; +import 'package:test/test.dart'; + +void main() { + group('Scanner', () { + test('scan text', () { + const source = 'abc'; + final scanner = Scanner(source, 'foo', '{{ }}'); + final List tokens = scanner.scan(); + expectTokens(tokens, [Token(TokenType.text, 'abc', 0, 3)]); + }); + + test('scan tag', () { + const source = 'abc{{foo}}def'; + final scanner = Scanner(source, 'foo', '{{ }}'); + final List tokens = scanner.scan(); + expectTokens(tokens, [ + Token(TokenType.text, 'abc', 0, 3), + Token(TokenType.openDelimiter, '{{', 3, 5), + Token(TokenType.identifier, 'foo', 5, 8), + Token(TokenType.closeDelimiter, '}}', 8, 10), + Token(TokenType.text, 'def', 10, 13), + ]); + }); + + test('scan tag whitespace', () { + const source = 'abc{{ foo }}def'; + final scanner = Scanner(source, 'foo', '{{ }}'); + final List tokens = scanner.scan(); + expectTokens(tokens, [ + Token(TokenType.text, 'abc', 0, 3), + Token(TokenType.openDelimiter, '{{', 3, 5), + Token(TokenType.whitespace, ' ', 5, 6), + Token(TokenType.identifier, 'foo', 6, 9), + Token(TokenType.whitespace, ' ', 9, 10), + Token(TokenType.closeDelimiter, '}}', 10, 12), + Token(TokenType.text, 'def', 12, 15), + ]); + }); + + test('scan tag sigil', () { + const source = 'abc{{ # foo }}def'; + final scanner = Scanner(source, 'foo', '{{ }}'); + final List tokens = scanner.scan(); + expectTokens(tokens, [ + Token(TokenType.text, 'abc', 0, 3), + Token(TokenType.openDelimiter, '{{', 3, 5), + Token(TokenType.whitespace, ' ', 5, 6), + Token(TokenType.sigil, '#', 6, 7), + Token(TokenType.whitespace, ' ', 7, 8), + Token(TokenType.identifier, 'foo', 8, 11), + Token(TokenType.whitespace, ' ', 11, 12), + Token(TokenType.closeDelimiter, '}}', 12, 14), + Token(TokenType.text, 'def', 14, 17), + ]); + }); + + test('scan tag dot', () { + const source = 'abc{{ foo.bar }}def'; + final scanner = Scanner(source, 'foo', '{{ }}'); + final List tokens = scanner.scan(); + expectTokens(tokens, [ + Token(TokenType.text, 'abc', 0, 3), + Token(TokenType.openDelimiter, '{{', 3, 5), + Token(TokenType.whitespace, ' ', 5, 6), + Token(TokenType.identifier, 'foo', 6, 9), + Token(TokenType.dot, '.', 9, 10), + Token(TokenType.identifier, 'bar', 10, 13), + Token(TokenType.whitespace, ' ', 13, 14), + Token(TokenType.closeDelimiter, '}}', 14, 16), + Token(TokenType.text, 'def', 16, 19), + ]); + }); + + test('scan triple mustache', () { + const source = 'abc{{{foo}}}def'; + final scanner = Scanner(source, 'foo', '{{ }}'); + final List tokens = scanner.scan(); + expectTokens(tokens, [ + Token(TokenType.text, 'abc', 0, 3), + Token(TokenType.openDelimiter, '{{{', 3, 6), + Token(TokenType.identifier, 'foo', 6, 9), + Token(TokenType.closeDelimiter, '}}}', 9, 12), + Token(TokenType.text, 'def', 12, 15), + ]); + }); + + test('scan triple mustache whitespace', () { + const source = 'abc{{{ foo }}}def'; + final scanner = Scanner(source, 'foo', '{{ }}'); + final List tokens = scanner.scan(); + expectTokens(tokens, [ + Token(TokenType.text, 'abc', 0, 3), + Token(TokenType.openDelimiter, '{{{', 3, 6), + Token(TokenType.whitespace, ' ', 6, 7), + Token(TokenType.identifier, 'foo', 7, 10), + Token(TokenType.whitespace, ' ', 10, 11), + Token(TokenType.closeDelimiter, '}}}', 11, 14), + Token(TokenType.text, 'def', 14, 17), + ]); + }); + + test('scan tag with equals', () { + const source = '{{foo=bar}}'; + final scanner = Scanner(source, 'foo', '{{ }}'); + final List tokens = scanner.scan(); + expectTokens(tokens, [ + Token(TokenType.openDelimiter, '{{', 0, 2), + Token(TokenType.identifier, 'foo=bar', 2, 9), + Token(TokenType.closeDelimiter, '}}', 9, 11), + ]); + }); + + test('scan comment with equals', () { + const source = '{{!foo=bar}}'; + final scanner = Scanner(source, 'foo', '{{ }}'); + final List tokens = scanner.scan(); + expectTokens(tokens, [ + Token(TokenType.openDelimiter, '{{', 0, 2), + Token(TokenType.sigil, '!', 2, 3), + Token(TokenType.identifier, 'foo=bar', 3, 10), + Token(TokenType.closeDelimiter, '}}', 10, 12), + ]); + }); + }); + + group('Parser', () { + test('parse variable', () { + const source = 'abc{{foo}}def'; + final parser = Parser(source, 'foo', '{{ }}'); + final List nodes = parser.parse(); + expectNodes(nodes, [ + TextNode('abc', 0, 3), + VariableNode('foo', 3, 10), + TextNode('def', 10, 13), + ]); + }); + + test('parse variable whitespace', () { + const source = 'abc{{ foo }}def'; + final parser = Parser(source, 'foo', '{{ }}'); + final List nodes = parser.parse(); + expectNodes(nodes, [ + TextNode('abc', 0, 3), + VariableNode('foo', 3, 12), + TextNode('def', 12, 15), + ]); + }); + + test('parse section', () { + const source = 'abc{{#foo}}def{{/foo}}ghi'; + final parser = Parser(source, 'foo', '{{ }}'); + final List nodes = parser.parse(); + expectNodes(nodes, [ + TextNode('abc', 0, 3), + SectionNode('foo', 3, 11, '{{ }}'), + TextNode('ghi', 22, 25), + ]); + expectNodes((nodes[1] as SectionNode).children, [TextNode('def', 11, 14)]); + }); + + test('parse section standalone tag whitespace', () { + const source = 'abc\n{{#foo}}\ndef\n{{/foo}}\nghi'; + final parser = Parser(source, 'foo', '{{ }}'); + final List nodes = parser.parse(); + expectNodes(nodes, [ + TextNode('abc\n', 0, 4), + SectionNode('foo', 4, 12, '{{ }}'), + TextNode('ghi', 26, 29), + ]); + expectNodes((nodes[1] as SectionNode).children, [TextNode('def\n', 13, 17)]); + }); + + test('parse section standalone tag whitespace consecutive', () { + const source = 'abc\n{{#foo}}\ndef\n{{/foo}}\n{{#foo}}\ndef\n{{/foo}}\nghi'; + final parser = Parser(source, 'foo', '{{ }}'); + final List nodes = parser.parse(); + expectNodes(nodes, [ + TextNode('abc\n', 0, 4), + SectionNode('foo', 4, 12, '{{ }}'), + SectionNode('foo', 26, 34, '{{ }}'), + TextNode('ghi', 48, 51), + ]); + expectNodes((nodes[1] as SectionNode).children, [TextNode('def\n', 13, 17)]); + }); + + test('parse section standalone tag whitespace on first line', () { + const source = ' {{#foo}} \ndef\n{{/foo}}\nghi'; + final parser = Parser(source, 'foo', '{{ }}'); + final List nodes = parser.parse(); + expectNodes(nodes, [SectionNode('foo', 2, 10, '{{ }}'), TextNode('ghi', 26, 29)]); + expectNodes((nodes[0] as SectionNode).children, [TextNode('def\n', 13, 17)]); + }); + + test('parse section standalone tag whitespace on last line', () { + const source = '{{#foo}}def\n {{/foo}} '; + final parser = Parser(source, 'foo', '{{ }}'); + final List nodes = parser.parse(); + expectNodes(nodes, [SectionNode('foo', 0, 8, '{{ }}')]); + expectNodes((nodes[0] as SectionNode).children, [TextNode('def\n', 8, 12)]); + }); + + test('parse variable newline', () { + const source = 'abc\n\n{{foo}}def'; + final parser = Parser(source, 'foo', '{{ }}'); + final List nodes = parser.parse(); + expectNodes(nodes, [ + TextNode('abc\n\n', 0, 5), + VariableNode('foo', 5, 12), + TextNode('def', 12, 15), + ]); + }); + + test('parse section standalone tag whitespace v2', () { + const source = 'abc\n\n{{#foo}}\ndef\n{{/foo}}\nghi'; + final parser = Parser(source, 'foo', '{{ }}'); + final List nodes = parser.parse(); + expectNodes(nodes, [ + TextNode('abc\n\n', 0, 5), + SectionNode('foo', 5, 13, '{{ }}'), + TextNode('ghi', 27, 30), + ]); + expectNodes((nodes[1] as SectionNode).children, [TextNode('def\n', 14, 18)]); + }); + + test('parse whitespace', () { + const source = 'abc\n '; + final parser = Parser(source, 'foo', '{{ }}'); + final List nodes = parser.parse(); + expectNodes(nodes, [TextNode('abc\n ', 0, 7)]); + }); + + test('parse partial', () { + const source = 'abc\n {{>foo}}def'; + final parser = Parser(source, 'foo', '{{ }}'); + final List nodes = parser.parse(); + expectNodes(nodes, [ + TextNode('abc\n ', 0, 7), + PartialNode('foo', 7, 15, ' '), + TextNode('def', 15, 18), + ]); + }); + + test('parse change delimiters', () { + const source = '{{= | | =}}<|#lambda|-|/lambda|>'; + final parser = Parser(source, 'foo', '{{ }}'); + final List nodes = parser.parse(); + expectNodes(nodes, [ + TextNode('<', 11, 12), + SectionNode('lambda', 12, 21, '| |'), + TextNode('>', 31, 32), + ]); + expect((nodes[1] as SectionNode).delimiters, equals('| |')); + expectNodes((nodes[1] as SectionNode).children, [TextNode('-', 21, 22)]); + }); + + test('corner case strict', () { + const source = '{{{ #foo }}} {{{ /foo }}}'; + final parser = Parser(source, 'foo', '{{ }}'); + expect(() => parser.parse(), throwsA(isA())); + }); + + test('corner case lenient', () { + const source = '{{{ #foo }}} {{{ /foo }}}'; + final parser = Parser(source, 'foo', '{{ }}', lenient: true); + final List nodes = parser.parse(); + expectNodes(nodes, [ + VariableNode('#foo', 0, 12, escape: false), + TextNode(' ', 12, 13), + VariableNode('/foo', 13, 25, escape: false), + ]); + }); + + test('emoji', () { + const source = 'Hello! 🖖👍🏽🏳️‍🌈\nEmoji'; + final parser = Parser(source, 'foo', '{{ }}'); + final List nodes = parser.parse(); + // End offset includes emoji sizes + expectNodes(nodes, [TextNode('Hello! 🖖👍🏽🏳️‍🌈\nEmoji', 0, 20)]); + }); + + test('toString', () { + TextNode('foo', 1, 3).toString(); + VariableNode('foo', 1, 3).toString(); + PartialNode('foo', 1, 3, ' ').toString(); + SectionNode('foo', 1, 3, '{{ }}').toString(); + Token(TokenType.closeDelimiter, 'foo', 1, 3).toString(); + TokenType.closeDelimiter.toString(); + }); + + test('exception', () { + const source = + "'{{ foo }} sdfffffffffffffffffffffffffffffffffffffffffffff " + 'dsfsdf sdfdsa fdsfads fsdfdsfadsf dsfasdfsdf sdfdsfsadf sdfadsfsdf '; + final ex = TemplateException('boom!', 'foo.mustache', source, 2); + ex.toString(); + }); + + void Function() parseFail(String source) { + return () { + final parser = Parser(source, 'foo', '{{ }}'); + parser.parse(); + }; + } + + test('parse eof', () { + void expectTemplateEx(void Function() shouldThrow) => + expect(shouldThrow, throwsA(isA())); + + expectTemplateEx(parseFail('{{#foo}}{{bar}}{{/foo}')); + expectTemplateEx(parseFail('{{#foo}}{{bar}}{{/foo')); + expectTemplateEx(parseFail('{{#foo}}{{bar}}{{/')); + expectTemplateEx(parseFail('{{#foo}}{{bar}}{{')); + expectTemplateEx(parseFail('{{#foo}}{{bar}}{')); + expectTemplateEx(parseFail('{{#foo}}{{bar}}')); + expectTemplateEx(parseFail('{{#foo}}{{bar}')); + expectTemplateEx(parseFail('{{#foo}}{{bar')); + expectTemplateEx(parseFail('{{#foo}}{{')); + expectTemplateEx(parseFail('{{#foo}}{')); + expectTemplateEx(parseFail('{{#foo}}')); + expectTemplateEx(parseFail('{{#foo}')); + expectTemplateEx(parseFail('{{#')); + expectTemplateEx(parseFail('{{')); + expectTemplateEx(parseFail('{{ # foo }}{{ bar }}{{ / foo }')); + expectTemplateEx(parseFail('{{ # foo }}{{ bar }}{{ / foo ')); + expectTemplateEx(parseFail('{{ # foo }}{{ bar }}{{ / foo')); + expectTemplateEx(parseFail('{{ # foo }}{{ bar }}{{ / ')); + expectTemplateEx(parseFail('{{ # foo }}{{ bar }}{{ /')); + expectTemplateEx(parseFail('{{ # foo }}{{ bar }}{{ ')); + expectTemplateEx(parseFail('{{ # foo }}{{ bar }}{{')); + expectTemplateEx(parseFail('{{ # foo }}{{ bar }}{')); + expectTemplateEx(parseFail('{{ # foo }}{{ bar }}')); + expectTemplateEx(parseFail('{{ # foo }}{{ bar }')); + expectTemplateEx(parseFail('{{ # foo }}{{ bar ')); + expectTemplateEx(parseFail('{{ # foo }}{{ bar')); + expectTemplateEx(parseFail('{{ # foo }}{{ ')); + expectTemplateEx(parseFail('{{ # foo }}{{')); + expectTemplateEx(parseFail('{{ # foo }}{')); + expectTemplateEx(parseFail('{{ # foo }}')); + expectTemplateEx(parseFail('{{ # foo }')); + expectTemplateEx(parseFail('{{ # foo ')); + expectTemplateEx(parseFail('{{ # foo')); + expectTemplateEx(parseFail('{{ # ')); + expectTemplateEx(parseFail('{{ #')); + expectTemplateEx(parseFail('{{ ')); + expectTemplateEx(parseFail('{{')); + + expectTemplateEx(parseFail('{{= || || =}')); + expectTemplateEx(parseFail('{{= || || =')); + expectTemplateEx(parseFail('{{= || || ')); + expectTemplateEx(parseFail('{{= || ||')); + expectTemplateEx(parseFail('{{= || |')); + expectTemplateEx(parseFail('{{= || ')); + expectTemplateEx(parseFail('{{= ||')); + expectTemplateEx(parseFail('{{= |')); + expectTemplateEx(parseFail('{{= ')); + expectTemplateEx(parseFail('{{=')); + }); + }); +} + +bool nodeEqual(Node a, Node b) { + if (a is TextNode) { + return b is TextNode && a.text == b.text && a.start == b.start && a.end == b.end; + } else if (a is VariableNode && b is VariableNode) { + return a.name == b.name && a.escape == b.escape && a.start == b.start && a.end == b.end; + } else if (a is SectionNode && b is SectionNode) { + return a.name == b.name && + a.delimiters == b.delimiters && + a.inverse == b.inverse && + a.start == b.start && + a.end == b.end; + } else if (a is PartialNode && b is PartialNode) { + return a.name == b.name && a.indent == b.indent; + } else { + return false; + } +} + +bool tokenEqual(Token a, Token b) { + return a.type == b.type && a.value == b.value && a.start == b.start && a.end == b.end; +} + +void expectTokens(List a, List b) { + expect(a.length, equals(b.length), reason: '$a != $b'); + for (var i = 0; i < a.length; i++) { + expect(tokenEqual(a[i], b[i]), isTrue, reason: '$a != $b'); + } +} + +void expectNodes(List a, List b) { + expect(a.length, equals(b.length), reason: '$a != $b'); + for (var i = 0; i < a.length; i++) { + expect(nodeEqual(a[i], b[i]), isTrue, reason: '$a != $b'); + } +} diff --git a/third_party/packages/mustache_template/test/specs/comments.dart b/third_party/packages/mustache_template/test/specs/comments.dart new file mode 100644 index 0000000..9dd41f6 --- /dev/null +++ b/third_party/packages/mustache_template/test/specs/comments.dart @@ -0,0 +1,109 @@ +// Generated from comments.json@97c05b0652f06ef4cbe12016b8d0e41df31c4b05 at 2026-03-09T19:49:45Z +const String COMMENTS = r''' +{ + "__ATTN__": "Do not edit this file; changes belong in the appropriate YAML file.", + "overview": "Comment tags represent content that should never appear in the resulting\noutput.\n\nThe tag's content may contain any substring (including newlines) EXCEPT the\nclosing delimiter.\n\nComment tags SHOULD be treated as standalone when appropriate.\n", + "tests": [ + { + "name": "Inline", + "desc": "Comment blocks should be removed from the template.", + "data": { + }, + "template": "12345{{! Comment Block! }}67890", + "expected": "1234567890" + }, + { + "name": "Multiline", + "desc": "Multiline comments should be permitted.", + "data": { + }, + "template": "12345{{!\n This is a\n multi-line comment...\n}}67890\n", + "expected": "1234567890\n" + }, + { + "name": "Standalone", + "desc": "All standalone comment lines should be removed.", + "data": { + }, + "template": "Begin.\n{{! Comment Block! }}\nEnd.\n", + "expected": "Begin.\nEnd.\n" + }, + { + "name": "Indented Standalone", + "desc": "All standalone comment lines should be removed.", + "data": { + }, + "template": "Begin.\n {{! Indented Comment Block! }}\nEnd.\n", + "expected": "Begin.\nEnd.\n" + }, + { + "name": "Standalone Line Endings", + "desc": "\"\\r\\n\" should be considered a newline for standalone tags.", + "data": { + }, + "template": "|\r\n{{! Standalone Comment }}\r\n|", + "expected": "|\r\n|" + }, + { + "name": "Standalone Without Previous Line", + "desc": "Standalone tags should not require a newline to precede them.", + "data": { + }, + "template": " {{! I'm Still Standalone }}\n!", + "expected": "!" + }, + { + "name": "Standalone Without Newline", + "desc": "Standalone tags should not require a newline to follow them.", + "data": { + }, + "template": "!\n {{! I'm Still Standalone }}", + "expected": "!\n" + }, + { + "name": "Multiline Standalone", + "desc": "All standalone comment lines should be removed.", + "data": { + }, + "template": "Begin.\n{{!\nSomething's going on here...\n}}\nEnd.\n", + "expected": "Begin.\nEnd.\n" + }, + { + "name": "Indented Multiline Standalone", + "desc": "All standalone comment lines should be removed.", + "data": { + }, + "template": "Begin.\n {{!\n Something's going on here...\n }}\nEnd.\n", + "expected": "Begin.\nEnd.\n" + }, + { + "name": "Indented Inline", + "desc": "Inline comments should not strip whitespace", + "data": { + }, + "template": " 12 {{! 34 }}\n", + "expected": " 12 \n" + }, + { + "name": "Surrounding Whitespace", + "desc": "Comment removal should preserve surrounding whitespace.", + "data": { + }, + "template": "12345 {{! Comment Block! }} 67890", + "expected": "12345 67890" + }, + { + "name": "Variable Name Collision", + "desc": "Comments must never render, even if variable with same name exists.", + "data": { + "! comment": 1, + "! comment ": 2, + "!comment": 3, + "comment": 4 + }, + "template": "comments never show: >{{! comment }}<", + "expected": "comments never show: ><" + } + ] +} +'''; diff --git a/third_party/packages/mustache_template/test/specs/delimiters.dart b/third_party/packages/mustache_template/test/specs/delimiters.dart new file mode 100644 index 0000000..fe776e9 --- /dev/null +++ b/third_party/packages/mustache_template/test/specs/delimiters.dart @@ -0,0 +1,135 @@ +// Generated from delimiters.json@97c05b0652f06ef4cbe12016b8d0e41df31c4b05 at 2026-03-09T19:49:45Z +const String DELIMITERS = r''' +{ + "__ATTN__": "Do not edit this file; changes belong in the appropriate YAML file.", + "overview": "Set Delimiter tags are used to change the tag delimiters for all content\nfollowing the tag in the current compilation unit.\n\nThe tag's content MUST be any two non-whitespace sequences (separated by\nwhitespace) EXCEPT an equals sign ('=') followed by the current closing\ndelimiter.\n\nSet Delimiter tags SHOULD be treated as standalone when appropriate.\n", + "tests": [ + { + "name": "Pair Behavior", + "desc": "The equals sign (used on both sides) should permit delimiter changes.", + "data": { + "text": "Hey!" + }, + "template": "{{=<% %>=}}(<%text%>)", + "expected": "(Hey!)" + }, + { + "name": "Special Characters", + "desc": "Characters with special meaning regexen should be valid delimiters.", + "data": { + "text": "It worked!" + }, + "template": "({{=[ ]=}}[text])", + "expected": "(It worked!)" + }, + { + "name": "Sections", + "desc": "Delimiters set outside sections should persist.", + "data": { + "section": true, + "data": "I got interpolated." + }, + "template": "[\n{{#section}}\n {{data}}\n |data|\n{{/section}}\n\n{{= | | =}}\n|#section|\n {{data}}\n |data|\n|/section|\n]\n", + "expected": "[\n I got interpolated.\n |data|\n\n {{data}}\n I got interpolated.\n]\n" + }, + { + "name": "Inverted Sections", + "desc": "Delimiters set outside inverted sections should persist.", + "data": { + "section": false, + "data": "I got interpolated." + }, + "template": "[\n{{^section}}\n {{data}}\n |data|\n{{/section}}\n\n{{= | | =}}\n|^section|\n {{data}}\n |data|\n|/section|\n]\n", + "expected": "[\n I got interpolated.\n |data|\n\n {{data}}\n I got interpolated.\n]\n" + }, + { + "name": "Partial Inheritence", + "desc": "Delimiters set in a parent template should not affect a partial.", + "data": { + "value": "yes" + }, + "partials": { + "include": ".{{value}}." + }, + "template": "[ {{>include}} ]\n{{= | | =}}\n[ |>include| ]\n", + "expected": "[ .yes. ]\n[ .yes. ]\n" + }, + { + "name": "Post-Partial Behavior", + "desc": "Delimiters set in a partial should not affect the parent template.", + "data": { + "value": "yes" + }, + "partials": { + "include": ".{{value}}. {{= | | =}} .|value|." + }, + "template": "[ {{>include}} ]\n[ .{{value}}. .|value|. ]\n", + "expected": "[ .yes. .yes. ]\n[ .yes. .|value|. ]\n" + }, + { + "name": "Surrounding Whitespace", + "desc": "Surrounding whitespace should be left untouched.", + "data": { + }, + "template": "| {{=@ @=}} |", + "expected": "| |" + }, + { + "name": "Outlying Whitespace (Inline)", + "desc": "Whitespace should be left untouched.", + "data": { + }, + "template": " | {{=@ @=}}\n", + "expected": " | \n" + }, + { + "name": "Standalone Tag", + "desc": "Standalone lines should be removed from the template.", + "data": { + }, + "template": "Begin.\n{{=@ @=}}\nEnd.\n", + "expected": "Begin.\nEnd.\n" + }, + { + "name": "Indented Standalone Tag", + "desc": "Indented standalone lines should be removed from the template.", + "data": { + }, + "template": "Begin.\n {{=@ @=}}\nEnd.\n", + "expected": "Begin.\nEnd.\n" + }, + { + "name": "Standalone Line Endings", + "desc": "\"\\r\\n\" should be considered a newline for standalone tags.", + "data": { + }, + "template": "|\r\n{{= @ @ =}}\r\n|", + "expected": "|\r\n|" + }, + { + "name": "Standalone Without Previous Line", + "desc": "Standalone tags should not require a newline to precede them.", + "data": { + }, + "template": " {{=@ @=}}\n=", + "expected": "=" + }, + { + "name": "Standalone Without Newline", + "desc": "Standalone tags should not require a newline to follow them.", + "data": { + }, + "template": "=\n {{=@ @=}}", + "expected": "=\n" + }, + { + "name": "Pair with Padding", + "desc": "Superfluous in-tag whitespace should be ignored.", + "data": { + }, + "template": "|{{= @ @ =}}|", + "expected": "||" + } + ] +} +'''; diff --git a/third_party/packages/mustache_template/test/specs/dynamic_names.dart b/third_party/packages/mustache_template/test/specs/dynamic_names.dart new file mode 100644 index 0000000..9365ebb --- /dev/null +++ b/third_party/packages/mustache_template/test/specs/dynamic_names.dart @@ -0,0 +1,319 @@ +// Generated from ~dynamic-names.json@97c05b0652f06ef4cbe12016b8d0e41df31c4b05 at 2026-03-09T19:49:45Z +const String DYNAMIC_NAMES = r''' +{ + "__ATTN__": "Do not edit this file; changes belong in the appropriate YAML file.", + "overview": "Rationale: this special notation was introduced primarily to allow the dynamic\nloading of partials. The main advantage that this notation offers is to allow\ndynamic loading of partials, which is particularly useful in cases where\npolymorphic data needs to be rendered in different ways. Such cases would\notherwise be possible to render only with solutions that are convoluted,\ninefficient, or both.\n\nExample.\nLet's consider the following data:\n\n items: [\n { content: 'Hello, World!' },\n { url: 'http://example.com/foo.jpg' },\n { content: 'Some text' },\n { content: 'Some other text' },\n { url: 'http://example.com/bar.jpg' },\n { url: 'http://example.com/baz.jpg' },\n { content: 'Last text here' }\n ]\n\nThe goal is to render the different types of items in different ways. The\nitems having a key named `content` should be rendered with the template\n`text.mustache`:\n\n {{!text.mustache}}\n {{content}}\n\nAnd the items having a key named `url` should be rendered with the template\n`image.mustache`:\n\n {{!image.mustache}}\n \n\nThere are already several ways to achieve this goal, here below are\nillustrated and discussed the most significant solutions to this problem.\n\nUsing Pre-Processing\n\nThe idea is to use a secondary templating mechanism to dynamically generate\nthe template that will be rendered.\nThe template that our secondary templating mechanism generates might look\nlike this:\n\n {{!template.mustache}}\n {{items.1.content}}\n \n {{items.3.content}}\n {{items.4.content}}\n \n \n {{items.7.content}}\n\nThis solutions offers the advantages of having more control over the template\nand minimizing the template blocks to the essential ones.\nThe drawbacks are the rendering speed and the complexity that the secondary\ntemplating mechanism requires.\n\nUsing Lambdas\n\nThe idea is to inject functions into the data that will be later called from\nthe template.\nThis way the data will look like this:\n\n items: [\n {\n content: 'Hello, World!',\n html: function() { return '{{>text}}'; }\n },\n {\n url: 'http://example.com/foo.jpg',\n html: function() { return '{{>image}}'; }\n },\n {\n content: 'Some text',\n html: function() { return '{{>text}}'; }\n },\n {\n content: 'Some other text',\n html: function() { return '{{>text}}'; }\n },\n {\n url: 'http://example.com/bar.jpg',\n html: function() { return '{{>image}}'; }\n },\n {\n url: 'http://example.com/baz.jpg',\n html: function() { return '{{>image}}'; }\n },\n {\n content: 'Last text here',\n html: function() { return '{{>text}}'; }\n }\n ]\n\nAnd the template will look like this:\n\n {{!template.mustache}}\n {{#items}}\n {{{html}}}\n {{/items}}\n\nThe advantage this solution offers is to have a light main template.\nThe drawback is that the data needs to embed logic and template tags in\nit.\n\nUsing If-Else Blocks\n\nThe idea is to put some logic into the main template so it can select the\ntemplates at rendering time:\n\n {{!template.mustache}}\n {{#items}}\n {{#url}}\n {{>image}}\n {{/url}}\n {{#content}}\n {{>text}}\n {{/content}}\n {{/items}}\n\nThe main advantage of this solution is that it works without adding any\noverhead fields to the data. It also documents which external templates are\nappropriate for expansion in this position.\nThe drawback is that this solution isn't optimal for heterogeneous data sets\nas the main template grows linearly with the number of polymorphic variants.\n\nUsing Dynamic Names\n\nThis is the solution proposed by this spec.\nThe idea is to load partials dynamically.\nThis way the data items have to be tagged with the corresponding partial name:\n\n items: [\n { content: 'Hello, World!', dynamic: 'text' },\n { url: 'http://example.com/foo.jpg', dynamic: 'image' },\n { content: 'Some text', dynamic: 'text' },\n { content: 'Some other text', dynamic: 'text' },\n { url: 'http://example.com/bar.jpg', dynamic: 'image' },\n { url: 'http://example.com/baz.jpg', dynamic: 'image' },\n { content: 'Last text here', dynamic: 'text' }\n ]\n\nAnd the template would simple look like this:\n\n {{!template.mustache}}\n {{#items}}\n {{>*dynamic}}\n {{/items}}\n\nSummary:\n\n +----------------+---------------------+-----------------------------------+\n | Approach | Pros | Cons |\n +----------------+---------------------+-----------------------------------+\n | Pre-Processing | Essential template, | Secondary templating system |\n | | more control | needed, slower rendering |\n | Lambdas | Slim template | Data tagging, logic in data |\n | If Blocks | No data overhead, | Template linear growth |\n | | self-documenting | |\n | Dynamic Names | Slim template | Data tagging |\n +----------------+---------------------+-----------------------------------+\n\nDynamic Names are a special notation to dynamically determine a tag's content.\n\nDynamic Names MUST be a non-whitespace character sequence NOT containing\nthe current closing delimiter. A Dynamic Name consists of an asterisk,\nfollowed by a dotted name. The dotted name follows the same notation as in an\nInterpolation tag.\n\nThis tag's dotted name, which is the Dynamic Name excluding the\nleading asterisk, references a key in the context whose value will be used in\nplace of the Dynamic Name itself as content of the tag. The dotted name\nresolution produces the same value as an Interpolation tag and does not affect\nthe context for further processing.\n\nSet Delimiter tags MUST NOT affect the resolution of a Dynamic Name. The\nDynamic Names MUST be resolved against the context stack local to the tag.\nFailed resolution of the dynamic name SHOULD result in nothing being rendered.\n\nEngines that implement Dynamic Names MUST support their use in Partial tags.\nIn engines that also implement the optional inheritance spec, Dynamic Names\ninside Parent tags SHOULD be supported as well. Dynamic Names cannot be\nresolved more than once (Dynamic Names cannot be nested).\n", + "tests": [ + { + "name": "Basic Behavior - Partial", + "desc": "The asterisk operator is used for dynamic partials.", + "data": { + "dynamic": "content" + }, + "template": "\"{{>*dynamic}}\"", + "partials": { + "content": "Hello, world!" + }, + "expected": "\"Hello, world!\"" + }, + { + "name": "Basic Behavior - Name Resolution", + "desc": "The asterisk is not part of the name that will be resolved in the context.\n", + "data": { + "dynamic": "content", + "*dynamic": "wrong" + }, + "template": "\"{{>*dynamic}}\"", + "partials": { + "content": "Hello, world!", + "wrong": "Invisible" + }, + "expected": "\"Hello, world!\"" + }, + { + "name": "Context Misses - Partial", + "desc": "Failed context lookups should be considered falsey.", + "data": { + }, + "template": "\"{{>*missing}}\"", + "partials": { + "missing": "Hello, world!" + }, + "expected": "\"\"" + }, + { + "name": "Failed Lookup - Partial", + "desc": "The empty string should be used when the named partial is not found.", + "data": { + "dynamic": "content" + }, + "template": "\"{{>*dynamic}}\"", + "partials": { + "foobar": "Hello, world!" + }, + "expected": "\"\"" + }, + { + "name": "Context", + "desc": "The dynamic partial should operate within the current context.", + "data": { + "text": "Hello, world!", + "example": "partial" + }, + "template": "\"{{>*example}}\"", + "partials": { + "partial": "*{{text}}*" + }, + "expected": "\"*Hello, world!*\"" + }, + { + "name": "Dotted Names", + "desc": "The dynamic partial should operate within the current context.", + "data": { + "text": "Hello, world!", + "foo": { + "bar": { + "baz": "partial" + } + } + }, + "template": "\"{{>*foo.bar.baz}}\"", + "partials": { + "partial": "*{{text}}*" + }, + "expected": "\"*Hello, world!*\"" + }, + { + "name": "Dotted Names - Operator Precedence", + "desc": "The dotted name should be resolved entirely before being dereferenced.", + "data": { + "text": "Hello, world!", + "foo": "test", + "test": { + "bar": { + "baz": "partial" + } + } + }, + "template": "\"{{>*foo.bar.baz}}\"", + "partials": { + "partial": "*{{text}}*" + }, + "expected": "\"\"" + }, + { + "name": "Dotted Names - Failed Lookup", + "desc": "The dynamic partial should operate within the current context.", + "data": { + "foo": { + "text": "Hello, world!", + "bar": { + "baz": "partial" + } + } + }, + "template": "\"{{>*foo.bar.baz}}\"", + "partials": { + "partial": "*{{text}}*" + }, + "expected": "\"**\"" + }, + { + "name": "Dotted names - Context Stacking", + "desc": "Dotted names should not push a new frame on the context stack.", + "data": { + "section1": { + "value": "section1" + }, + "section2": { + "dynamic": "partial", + "value": "section2" + } + }, + "template": "{{#section1}}{{>*section2.dynamic}}{{/section1}}", + "partials": { + "partial": "\"{{value}}\"" + }, + "expected": "\"section1\"" + }, + { + "name": "Dotted names - Context Stacking Under Repetition", + "desc": "Dotted names should not push a new frame on the context stack.", + "data": { + "value": "test", + "section1": [ + 1, + 2 + ], + "section2": { + "dynamic": "partial", + "value": "section2" + } + }, + "template": "{{#section1}}{{>*section2.dynamic}}{{/section1}}", + "partials": { + "partial": "{{value}}" + }, + "expected": "testtest" + }, + { + "name": "Dotted names - Context Stacking Failed Lookup", + "desc": "Dotted names should resolve against the proper context stack.", + "data": { + "section1": [ + 1, + 2 + ], + "section2": { + "dynamic": "partial", + "value": "section2" + } + }, + "template": "{{#section1}}{{>*section2.dynamic}}{{/section1}}", + "partials": { + "partial": "\"{{value}}\"" + }, + "expected": "\"\"\"\"" + }, + { + "name": "Recursion", + "desc": "Dynamic partials should properly recurse.", + "data": { + "template": "node", + "content": "X", + "nodes": [ + { + "content": "Y", + "nodes": [ + + ] + } + ] + }, + "template": "{{>*template}}", + "partials": { + "node": "{{content}}<{{#nodes}}{{>*template}}{{/nodes}}>" + }, + "expected": "X>" + }, + { + "name": "Dynamic Names - Double Dereferencing", + "desc": "Dynamic Names can't be dereferenced more than once.", + "data": { + "dynamic": "test", + "test": "content" + }, + "template": "\"{{>**dynamic}}\"", + "partials": { + "content": "Hello, world!" + }, + "expected": "\"\"" + }, + { + "name": "Dynamic Names - Composed Dereferencing", + "desc": "Dotted Names are resolved entirely before dereferencing begins.", + "data": { + "foo": "fizz", + "bar": "buzz", + "fizz": { + "buzz": { + "content": null + } + } + }, + "template": "\"{{>*foo.*bar}}\"", + "partials": { + "content": "Hello, world!" + }, + "expected": "\"\"" + }, + { + "name": "Surrounding Whitespace", + "desc": "A dynamic partial should not alter surrounding whitespace; any\nwhitespace preceding the tag should be treated as indentation while any\nwhitespace succeding the tag should be left untouched.\n", + "data": { + "partial": "foobar" + }, + "template": "| {{>*partial}} |", + "partials": { + "foobar": "\t|\t" + }, + "expected": "| \t|\t |" + }, + { + "name": "Inline Indentation", + "desc": "Whitespace should be left untouched: whitespaces preceding the tag\nshould be treated as indentation.\n", + "data": { + "dynamic": "partial", + "data": "|" + }, + "template": " {{data}} {{>*dynamic}}\n", + "partials": { + "partial": ">\n>" + }, + "expected": " | >\n>\n" + }, + { + "name": "Standalone Line Endings", + "desc": "\"\\r\\n\" should be considered a newline for standalone tags.", + "data": { + "dynamic": "partial" + }, + "template": "|\r\n{{>*dynamic}}\r\n|", + "partials": { + "partial": ">" + }, + "expected": "|\r\n>|" + }, + { + "name": "Standalone Without Previous Line", + "desc": "Standalone tags should not require a newline to precede them.", + "data": { + "dynamic": "partial" + }, + "template": " {{>*dynamic}}\n>", + "partials": { + "partial": ">\n>" + }, + "expected": " >\n >>" + }, + { + "name": "Standalone Without Newline", + "desc": "Standalone tags should not require a newline to follow them.", + "data": { + "dynamic": "partial" + }, + "template": ">\n {{>*dynamic}}", + "partials": { + "partial": ">\n>" + }, + "expected": ">\n >\n >" + }, + { + "name": "Standalone Indentation", + "desc": "Each line of the partial should be indented before rendering.", + "data": { + "dynamic": "partial", + "content": "<\n->" + }, + "template": "\\\n {{>*dynamic}}\n/\n", + "partials": { + "partial": "|\n{{{content}}}\n|\n" + }, + "expected": "\\\n |\n <\n->\n |\n/\n" + }, + { + "name": "Padding Whitespace", + "desc": "Superfluous in-tag whitespace should be ignored.", + "data": { + "dynamic": "partial", + "boolean": true + }, + "template": "|{{> * dynamic }}|", + "partials": { + "partial": "[]" + }, + "expected": "|[]|" + } + ] +} +'''; diff --git a/third_party/packages/mustache_template/test/specs/inheritance.dart b/third_party/packages/mustache_template/test/specs/inheritance.dart new file mode 100644 index 0000000..eb9e8fa --- /dev/null +++ b/third_party/packages/mustache_template/test/specs/inheritance.dart @@ -0,0 +1,309 @@ +// Generated from ~inheritance.json@97c05b0652f06ef4cbe12016b8d0e41df31c4b05 at 2026-03-09T19:49:45Z +const String INHERITANCE = r''' +{ + "__ATTN__": "Do not edit this file; changes belong in the appropriate YAML file.", + "overview": "Like partials, Parent tags are used to expand an external template into the\ncurrent template. Unlike partials, Parent tags may contain optional\narguments delimited by Block tags. For this reason, Parent tags may also be\nreferred to as Parametric Partials.\n\nThe Parent tags' content MUST be a non-whitespace character sequence NOT\ncontaining the current closing delimiter; each Parent tag MUST be followed by\nan End Section tag with the same content within the matching Parent tag.\n\nThis tag's content names the Parent template to inject. Set Delimiter tags\nPreceding a Parent tag MUST NOT affect the parsing of the injected external\ntemplate. The Parent MUST be rendered against the context stack local to the\ntag. If the named Parent cannot be found, the empty string SHOULD be used\ninstead, as in interpolations.\n\nParent tags SHOULD be treated as standalone when appropriate. If this tag is\nused standalone, any whitespace preceding the tag should be treated as\nindentation, and prepended to each line of the Parent before rendering.\n\nThe Block tags' content MUST be a non-whitespace character sequence NOT\ncontaining the current closing delimiter. Each Block tag MUST be followed by\nan End Section tag with the same content within the matching Block tag. This\ntag's content determines the parameter or argument name.\n\nBlock tags may appear both inside and outside of Parent tags. In both cases,\nthey specify a position within the template that can be overridden; it is a\nparameter of the containing template. The template text between the Block tag\nand its matching End Section tag defines the default content to render when\nthe parameter is not overridden from outside.\n\nIn addition, when used inside of a Parent tag, the template text between a\nBlock tag and its matching End Section tag defines content that replaces the\ndefault defined in the Parent template. This content is the argument passed\nto the Parent template.\n\nThe practice of injecting an external template using a Parent tag is referred\nto as inheritance. If the Parent tag includes a Block tag that overrides a\nparameter of the Parent template, this may also be referred to as\nsubstitution.\n\nParent templates are taken from the same namespace as regular Partial\ntemplates and in fact, injecting a regular Partial is exactly equivalent to\ninjecting a Parent without making any substitutions. Parameter and arguments\nnames live in a namespace that is distinct from both Partials and the context.\n", + "tests": [ + { + "name": "Default", + "desc": "Default content should be rendered if the block isn't overridden", + "data": { + }, + "template": "{{$title}}Default title{{/title}}\n", + "expected": "Default title\n" + }, + { + "name": "Variable", + "desc": "Default content renders variables", + "data": { + "bar": "baz" + }, + "template": "{{$foo}}default {{bar}} content{{/foo}}\n", + "expected": "default baz content\n" + }, + { + "name": "Triple Mustache", + "desc": "Default content renders triple mustache variables", + "data": { + "bar": "" + }, + "template": "{{$foo}}default {{{bar}}} content{{/foo}}\n", + "expected": "default content\n" + }, + { + "name": "Sections", + "desc": "Default content renders sections", + "data": { + "bar": { + "baz": "qux" + } + }, + "template": "{{$foo}}default {{#bar}}{{baz}}{{/bar}} content{{/foo}}\n", + "expected": "default qux content\n" + }, + { + "name": "Negative Sections", + "desc": "Default content renders negative sections", + "data": { + "baz": "three" + }, + "template": "{{$foo}}default {{^bar}}{{baz}}{{/bar}} content{{/foo}}\n", + "expected": "default three content\n" + }, + { + "name": "Mustache Injection", + "desc": "Mustache injection in default content", + "data": { + "bar": { + "baz": "{{qux}}" + } + }, + "template": "{{$foo}}default {{#bar}}{{baz}}{{/bar}} content{{/foo}}\n", + "expected": "default {{qux}} content\n" + }, + { + "name": "Inherit", + "desc": "Default content rendered inside inherited templates", + "data": { + }, + "template": "{{parent}}|{{" + }, + "template": "These characters should be HTML escaped: {{forbidden}}\n", + "expected": "These characters should be HTML escaped: & " < >\n" + }, + { + "name": "Triple Mustache", + "desc": "Triple mustaches should interpolate without HTML escaping.", + "data": { + "forbidden": "& \" < >" + }, + "template": "These characters should not be HTML escaped: {{{forbidden}}}\n", + "expected": "These characters should not be HTML escaped: & \" < >\n" + }, + { + "name": "Ampersand", + "desc": "Ampersand should interpolate without HTML escaping.", + "data": { + "forbidden": "& \" < >" + }, + "template": "These characters should not be HTML escaped: {{&forbidden}}\n", + "expected": "These characters should not be HTML escaped: & \" < >\n" + }, + { + "name": "Basic Integer Interpolation", + "desc": "Integers should interpolate seamlessly.", + "data": { + "mph": 85 + }, + "template": "\"{{mph}} miles an hour!\"", + "expected": "\"85 miles an hour!\"" + }, + { + "name": "Triple Mustache Integer Interpolation", + "desc": "Integers should interpolate seamlessly.", + "data": { + "mph": 85 + }, + "template": "\"{{{mph}}} miles an hour!\"", + "expected": "\"85 miles an hour!\"" + }, + { + "name": "Ampersand Integer Interpolation", + "desc": "Integers should interpolate seamlessly.", + "data": { + "mph": 85 + }, + "template": "\"{{&mph}} miles an hour!\"", + "expected": "\"85 miles an hour!\"" + }, + { + "name": "Basic Decimal Interpolation", + "desc": "Decimals should interpolate seamlessly with proper significance.", + "data": { + "power": 1.21 + }, + "template": "\"{{power}} jiggawatts!\"", + "expected": "\"1.21 jiggawatts!\"" + }, + { + "name": "Triple Mustache Decimal Interpolation", + "desc": "Decimals should interpolate seamlessly with proper significance.", + "data": { + "power": 1.21 + }, + "template": "\"{{{power}}} jiggawatts!\"", + "expected": "\"1.21 jiggawatts!\"" + }, + { + "name": "Ampersand Decimal Interpolation", + "desc": "Decimals should interpolate seamlessly with proper significance.", + "data": { + "power": 1.21 + }, + "template": "\"{{&power}} jiggawatts!\"", + "expected": "\"1.21 jiggawatts!\"" + }, + { + "name": "Basic Null Interpolation", + "desc": "Nulls should interpolate as the empty string.", + "data": { + "cannot": null + }, + "template": "I ({{cannot}}) be seen!", + "expected": "I () be seen!" + }, + { + "name": "Triple Mustache Null Interpolation", + "desc": "Nulls should interpolate as the empty string.", + "data": { + "cannot": null + }, + "template": "I ({{{cannot}}}) be seen!", + "expected": "I () be seen!" + }, + { + "name": "Ampersand Null Interpolation", + "desc": "Nulls should interpolate as the empty string.", + "data": { + "cannot": null + }, + "template": "I ({{&cannot}}) be seen!", + "expected": "I () be seen!" + }, + { + "name": "Basic Context Miss Interpolation", + "desc": "Failed context lookups should default to empty strings.", + "data": { + }, + "template": "I ({{cannot}}) be seen!", + "expected": "I () be seen!" + }, + { + "name": "Triple Mustache Context Miss Interpolation", + "desc": "Failed context lookups should default to empty strings.", + "data": { + }, + "template": "I ({{{cannot}}}) be seen!", + "expected": "I () be seen!" + }, + { + "name": "Ampersand Context Miss Interpolation", + "desc": "Failed context lookups should default to empty strings.", + "data": { + }, + "template": "I ({{&cannot}}) be seen!", + "expected": "I () be seen!" + }, + { + "name": "Dotted Names - Basic Interpolation", + "desc": "Dotted names should be considered a form of shorthand for sections.", + "data": { + "person": { + "name": "Joe" + } + }, + "template": "\"{{person.name}}\" == \"{{#person}}{{name}}{{/person}}\"", + "expected": "\"Joe\" == \"Joe\"" + }, + { + "name": "Dotted Names - Triple Mustache Interpolation", + "desc": "Dotted names should be considered a form of shorthand for sections.", + "data": { + "person": { + "name": "Joe" + } + }, + "template": "\"{{{person.name}}}\" == \"{{#person}}{{{name}}}{{/person}}\"", + "expected": "\"Joe\" == \"Joe\"" + }, + { + "name": "Dotted Names - Ampersand Interpolation", + "desc": "Dotted names should be considered a form of shorthand for sections.", + "data": { + "person": { + "name": "Joe" + } + }, + "template": "\"{{&person.name}}\" == \"{{#person}}{{&name}}{{/person}}\"", + "expected": "\"Joe\" == \"Joe\"" + }, + { + "name": "Dotted Names - Arbitrary Depth", + "desc": "Dotted names should be functional to any level of nesting.", + "data": { + "a": { + "b": { + "c": { + "d": { + "e": { + "name": "Phil" + } + } + } + } + } + }, + "template": "\"{{a.b.c.d.e.name}}\" == \"Phil\"", + "expected": "\"Phil\" == \"Phil\"" + }, + { + "name": "Dotted Names - Broken Chains", + "desc": "Any falsey value prior to the last part of the name should yield ''.", + "data": { + "a": { + } + }, + "template": "\"{{a.b.c}}\" == \"\"", + "expected": "\"\" == \"\"" + }, + { + "name": "Dotted Names - Broken Chain Resolution", + "desc": "Each part of a dotted name should resolve only against its parent.", + "data": { + "a": { + "b": { + } + }, + "c": { + "name": "Jim" + } + }, + "template": "\"{{a.b.c.name}}\" == \"\"", + "expected": "\"\" == \"\"" + }, + { + "name": "Dotted Names - Initial Resolution", + "desc": "The first part of a dotted name should resolve as any other name.", + "data": { + "a": { + "b": { + "c": { + "d": { + "e": { + "name": "Phil" + } + } + } + } + }, + "b": { + "c": { + "d": { + "e": { + "name": "Wrong" + } + } + } + } + }, + "template": "\"{{#a}}{{b.c.d.e.name}}{{/a}}\" == \"Phil\"", + "expected": "\"Phil\" == \"Phil\"" + }, + { + "name": "Dotted Names - Context Precedence", + "desc": "Dotted names should be resolved against former resolutions.", + "data": { + "a": { + "b": { + } + }, + "b": { + "c": "ERROR" + } + }, + "template": "{{#a}}{{b.c}}{{/a}}", + "expected": "" + }, + { + "name": "Dotted Names are never single keys", + "desc": "Dotted names shall not be parsed as single, atomic keys", + "data": { + "a.b": "c" + }, + "template": "{{a.b}}", + "expected": "" + }, + { + "name": "Dotted Names - No Masking", + "desc": "Dotted Names in a given context are unvavailable due to dot splitting", + "data": { + "a.b": "c", + "a": { + "b": "d" + } + }, + "template": "{{a.b}}", + "expected": "d" + }, + { + "name": "Implicit Iterators - Basic Interpolation", + "desc": "Unadorned tags should interpolate content into the template.", + "data": "world", + "template": "Hello, {{.}}!\n", + "expected": "Hello, world!\n" + }, + { + "name": "Implicit Iterators - HTML Escaping", + "desc": "Basic interpolation should be HTML escaped.", + "data": "& \" < >", + "template": "These characters should be HTML escaped: {{.}}\n", + "expected": "These characters should be HTML escaped: & " < >\n" + }, + { + "name": "Implicit Iterators - Triple Mustache", + "desc": "Triple mustaches should interpolate without HTML escaping.", + "data": "& \" < >", + "template": "These characters should not be HTML escaped: {{{.}}}\n", + "expected": "These characters should not be HTML escaped: & \" < >\n" + }, + { + "name": "Implicit Iterators - Ampersand", + "desc": "Ampersand should interpolate without HTML escaping.", + "data": "& \" < >", + "template": "These characters should not be HTML escaped: {{&.}}\n", + "expected": "These characters should not be HTML escaped: & \" < >\n" + }, + { + "name": "Implicit Iterators - Basic Integer Interpolation", + "desc": "Integers should interpolate seamlessly.", + "data": 85, + "template": "\"{{.}} miles an hour!\"", + "expected": "\"85 miles an hour!\"" + }, + { + "name": "Interpolation - Surrounding Whitespace", + "desc": "Interpolation should not alter surrounding whitespace.", + "data": { + "string": "---" + }, + "template": "| {{string}} |", + "expected": "| --- |" + }, + { + "name": "Triple Mustache - Surrounding Whitespace", + "desc": "Interpolation should not alter surrounding whitespace.", + "data": { + "string": "---" + }, + "template": "| {{{string}}} |", + "expected": "| --- |" + }, + { + "name": "Ampersand - Surrounding Whitespace", + "desc": "Interpolation should not alter surrounding whitespace.", + "data": { + "string": "---" + }, + "template": "| {{&string}} |", + "expected": "| --- |" + }, + { + "name": "Interpolation - Standalone", + "desc": "Standalone interpolation should not alter surrounding whitespace.", + "data": { + "string": "---" + }, + "template": " {{string}}\n", + "expected": " ---\n" + }, + { + "name": "Triple Mustache - Standalone", + "desc": "Standalone interpolation should not alter surrounding whitespace.", + "data": { + "string": "---" + }, + "template": " {{{string}}}\n", + "expected": " ---\n" + }, + { + "name": "Ampersand - Standalone", + "desc": "Standalone interpolation should not alter surrounding whitespace.", + "data": { + "string": "---" + }, + "template": " {{&string}}\n", + "expected": " ---\n" + }, + { + "name": "Interpolation With Padding", + "desc": "Superfluous in-tag whitespace should be ignored.", + "data": { + "string": "---" + }, + "template": "|{{ string }}|", + "expected": "|---|" + }, + { + "name": "Triple Mustache With Padding", + "desc": "Superfluous in-tag whitespace should be ignored.", + "data": { + "string": "---" + }, + "template": "|{{{ string }}}|", + "expected": "|---|" + }, + { + "name": "Ampersand With Padding", + "desc": "Superfluous in-tag whitespace should be ignored.", + "data": { + "string": "---" + }, + "template": "|{{& string }}|", + "expected": "|---|" + } + ] +} +'''; diff --git a/third_party/packages/mustache_template/test/specs/inverted.dart b/third_party/packages/mustache_template/test/specs/inverted.dart new file mode 100644 index 0000000..2480914 --- /dev/null +++ b/third_party/packages/mustache_template/test/specs/inverted.dart @@ -0,0 +1,230 @@ +// Generated from inverted.json@97c05b0652f06ef4cbe12016b8d0e41df31c4b05 at 2026-03-09T19:49:45Z +const String INVERTED = r''' +{ + "__ATTN__": "Do not edit this file; changes belong in the appropriate YAML file.", + "overview": "Inverted Section tags and End Section tags are used in combination to wrap a\nsection of the template.\n\nThese tags' content MUST be a non-whitespace character sequence NOT\ncontaining the current closing delimiter; each Inverted Section tag MUST be\nfollowed by an End Section tag with the same content within the same\nsection.\n\nThis tag's content names the data to replace the tag. Name resolution is as\nfollows:\n 1) Split the name on periods; the first part is the name to resolve, any\n remaining parts should be retained.\n 2) Walk the context stack from top to bottom, finding the first context\n that is a) a hash containing the name as a key OR b) an object responding\n to a method with the given name.\n 3) If the context is a hash, the data is the value associated with the\n name.\n 4) If the context is an object and the method with the given name has an\n arity of 1, the method SHOULD be called with a String containing the\n unprocessed contents of the sections; the data is the value returned.\n 5) Otherwise, the data is the value returned by calling the method with\n the given name.\n 6) If any name parts were retained in step 1, each should be resolved\n against a context stack containing only the result from the former\n resolution. If any part fails resolution, the result should be considered\n falsey, and should interpolate as the empty string.\nIf the data is not of a list type, it is coerced into a list as follows: if\nthe data is truthy (e.g. `!!data == true`), use a single-element list\ncontaining the data, otherwise use an empty list.\n\nThis section MUST NOT be rendered unless the data list is empty.\n\nInverted Section and End Section tags SHOULD be treated as standalone when\nappropriate.\n", + "tests": [ + { + "name": "Falsey", + "desc": "Falsey sections should have their contents rendered.", + "data": { + "boolean": false + }, + "template": "\"{{^boolean}}This should be rendered.{{/boolean}}\"", + "expected": "\"This should be rendered.\"" + }, + { + "name": "Truthy", + "desc": "Truthy sections should have their contents omitted.", + "data": { + "boolean": true + }, + "template": "\"{{^boolean}}This should not be rendered.{{/boolean}}\"", + "expected": "\"\"" + }, + { + "name": "Null is falsey", + "desc": "Null is falsey.", + "data": { + "null": null + }, + "template": "\"{{^null}}This should be rendered.{{/null}}\"", + "expected": "\"This should be rendered.\"" + }, + { + "name": "Context", + "desc": "Objects and hashes should behave like truthy values.", + "data": { + "context": { + "name": "Joe" + } + }, + "template": "\"{{^context}}Hi {{name}}.{{/context}}\"", + "expected": "\"\"" + }, + { + "name": "List", + "desc": "Lists should behave like truthy values.", + "data": { + "list": [ + { + "n": 1 + }, + { + "n": 2 + }, + { + "n": 3 + } + ] + }, + "template": "\"{{^list}}{{n}}{{/list}}\"", + "expected": "\"\"" + }, + { + "name": "Empty List", + "desc": "Empty lists should behave like falsey values.", + "data": { + "list": [ + + ] + }, + "template": "\"{{^list}}Yay lists!{{/list}}\"", + "expected": "\"Yay lists!\"" + }, + { + "name": "Doubled", + "desc": "Multiple inverted sections per template should be permitted.", + "data": { + "bool": false, + "two": "second" + }, + "template": "{{^bool}}\n* first\n{{/bool}}\n* {{two}}\n{{^bool}}\n* third\n{{/bool}}\n", + "expected": "* first\n* second\n* third\n" + }, + { + "name": "Nested (Falsey)", + "desc": "Nested falsey sections should have their contents rendered.", + "data": { + "bool": false + }, + "template": "| A {{^bool}}B {{^bool}}C{{/bool}} D{{/bool}} E |", + "expected": "| A B C D E |" + }, + { + "name": "Nested (Truthy)", + "desc": "Nested truthy sections should be omitted.", + "data": { + "bool": true + }, + "template": "| A {{^bool}}B {{^bool}}C{{/bool}} D{{/bool}} E |", + "expected": "| A E |" + }, + { + "name": "Context Misses", + "desc": "Failed context lookups should be considered falsey.", + "data": { + }, + "template": "[{{^missing}}Cannot find key 'missing'!{{/missing}}]", + "expected": "[Cannot find key 'missing'!]" + }, + { + "name": "Dotted Names - Truthy", + "desc": "Dotted names should be valid for Inverted Section tags.", + "data": { + "a": { + "b": { + "c": true + } + } + }, + "template": "\"{{^a.b.c}}Not Here{{/a.b.c}}\" == \"\"", + "expected": "\"\" == \"\"" + }, + { + "name": "Dotted Names - Falsey", + "desc": "Dotted names should be valid for Inverted Section tags.", + "data": { + "a": { + "b": { + "c": false + } + } + }, + "template": "\"{{^a.b.c}}Not Here{{/a.b.c}}\" == \"Not Here\"", + "expected": "\"Not Here\" == \"Not Here\"" + }, + { + "name": "Dotted Names - Broken Chains", + "desc": "Dotted names that cannot be resolved should be considered falsey.", + "data": { + "a": { + } + }, + "template": "\"{{^a.b.c}}Not Here{{/a.b.c}}\" == \"Not Here\"", + "expected": "\"Not Here\" == \"Not Here\"" + }, + { + "name": "Surrounding Whitespace", + "desc": "Inverted sections should not alter surrounding whitespace.", + "data": { + "boolean": false + }, + "template": " | {{^boolean}}\t|\t{{/boolean}} | \n", + "expected": " | \t|\t | \n" + }, + { + "name": "Internal Whitespace", + "desc": "Inverted should not alter internal whitespace.", + "data": { + "boolean": false + }, + "template": " | {{^boolean}} {{! Important Whitespace }}\n {{/boolean}} | \n", + "expected": " | \n | \n" + }, + { + "name": "Indented Inline Sections", + "desc": "Single-line sections should not alter surrounding whitespace.", + "data": { + "boolean": false + }, + "template": " {{^boolean}}NO{{/boolean}}\n {{^boolean}}WAY{{/boolean}}\n", + "expected": " NO\n WAY\n" + }, + { + "name": "Standalone Lines", + "desc": "Standalone lines should be removed from the template.", + "data": { + "boolean": false + }, + "template": "| This Is\n{{^boolean}}\n|\n{{/boolean}}\n| A Line\n", + "expected": "| This Is\n|\n| A Line\n" + }, + { + "name": "Standalone Indented Lines", + "desc": "Standalone indented lines should be removed from the template.", + "data": { + "boolean": false + }, + "template": "| This Is\n {{^boolean}}\n|\n {{/boolean}}\n| A Line\n", + "expected": "| This Is\n|\n| A Line\n" + }, + { + "name": "Standalone Line Endings", + "desc": "\"\\r\\n\" should be considered a newline for standalone tags.", + "data": { + "boolean": false + }, + "template": "|\r\n{{^boolean}}\r\n{{/boolean}}\r\n|", + "expected": "|\r\n|" + }, + { + "name": "Standalone Without Previous Line", + "desc": "Standalone tags should not require a newline to precede them.", + "data": { + "boolean": false + }, + "template": " {{^boolean}}\n^{{/boolean}}\n/", + "expected": "^\n/" + }, + { + "name": "Standalone Without Newline", + "desc": "Standalone tags should not require a newline to follow them.", + "data": { + "boolean": false + }, + "template": "^{{^boolean}}\n/\n {{/boolean}}", + "expected": "^\n/\n" + }, + { + "name": "Padding", + "desc": "Superfluous in-tag whitespace should be ignored.", + "data": { + "boolean": false + }, + "template": "|{{^ boolean }}={{/ boolean }}|", + "expected": "|=|" + } + ] +} +'''; diff --git a/third_party/packages/mustache_template/test/specs/lambdas.dart b/third_party/packages/mustache_template/test/specs/lambdas.dart new file mode 100644 index 0000000..319b65c --- /dev/null +++ b/third_party/packages/mustache_template/test/specs/lambdas.dart @@ -0,0 +1,235 @@ +// Generated from ~lambdas.json@97c05b0652f06ef4cbe12016b8d0e41df31c4b05 at 2026-03-09T19:49:45Z +const String LAMBDAS = r''' +{ + "__ATTN__": "Do not edit this file; changes belong in the appropriate YAML file.", + "overview": "Lambdas are a special-cased data type for use in interpolations and\nsections.\n\nWhen used as the data value for an Interpolation tag, the lambda MUST be\ntreatable as an arity 0 function, and invoked as such. The returned value\nMUST be rendered against the default delimiters, then interpolated in place\nof the lambda.\n\nWhen used as the data value for a Section tag, the lambda MUST be treatable\nas an arity 1 function, and invoked as such (passing a String containing the\nunprocessed section contents). The returned value MUST be rendered against\nthe current delimiters, then interpolated in place of the section.\n", + "tests": [ + { + "name": "Interpolation", + "desc": "A lambda's return value should be interpolated.", + "data": { + "lambda": { + "__tag__": "code", + "ruby": "proc { \"world\" }", + "raku": "sub { \"world\" }", + "perl": "sub { \"world\" }", + "js": "function() { return \"world\" }", + "php": "return \"world\";", + "python": "lambda: \"world\"", + "clojure": "(fn [] \"world\")", + "lisp": "(lambda () \"world\")", + "pwsh": "\"world\"", + "go": "func() string { return \"world\" }", + "erlang": "fun() -> \"world\" end." + } + }, + "template": "Hello, {{lambda}}!", + "expected": "Hello, world!" + }, + { + "name": "Interpolation - Expansion", + "desc": "A lambda's return value should be parsed.", + "data": { + "planet": "world", + "lambda": { + "__tag__": "code", + "ruby": "proc { \"{{planet}}\" }", + "raku": "sub { q+{{planet}}+ }", + "perl": "sub { \"{{planet}}\" }", + "js": "function() { return \"{{planet}}\" }", + "php": "return \"{{planet}}\";", + "python": "lambda: \"{{planet}}\"", + "clojure": "(fn [] \"{{planet}}\")", + "lisp": "(lambda () \"{{planet}}\")", + "pwsh": "\"{{planet}}\"", + "go": "func() string { return \"{{planet}}\" }", + "erlang": "fun() -> \"{{planet}}\" end." + } + }, + "template": "Hello, {{lambda}}!", + "expected": "Hello, world!" + }, + { + "name": "Interpolation - Alternate Delimiters", + "desc": "A lambda's return value should parse with the default delimiters.", + "data": { + "planet": "world", + "lambda": { + "__tag__": "code", + "ruby": "proc { \"|planet| => {{planet}}\" }", + "raku": "sub { q+|planet| => {{planet}}+ }", + "perl": "sub { \"|planet| => {{planet}}\" }", + "js": "function() { return \"|planet| => {{planet}}\" }", + "php": "return \"|planet| => {{planet}}\";", + "python": "lambda: \"|planet| => {{planet}}\"", + "clojure": "(fn [] \"|planet| => {{planet}}\")", + "lisp": "(lambda () \"|planet| => {{planet}}\")", + "pwsh": "\"|planet| => {{planet}}\"", + "go": "func() string { return \"|planet| => {{planet}}\" }", + "erlang": "fun() -> \"|planet| => {{planet}}\" end." + } + }, + "template": "{{= | | =}}\nHello, (|&lambda|)!", + "expected": "Hello, (|planet| => world)!" + }, + { + "name": "Interpolation - Multiple Calls", + "desc": "Interpolated lambdas should not be cached.", + "data": { + "lambda": { + "__tag__": "code", + "ruby": "proc { $calls ||= 0; $calls += 1 }", + "raku": "sub { state $calls += 1 }", + "perl": "sub { no strict; $calls += 1 }", + "js": "function() { return (g=(function(){return this})()).calls=(g.calls||0)+1 }", + "php": "global $calls; return ++$calls;", + "python": "lambda: globals().update(calls=globals().get(\"calls\",0)+1) or calls", + "clojure": "(def g (atom 0)) (fn [] (swap! g inc))", + "lisp": "(let ((g 0)) (lambda () (incf g)))", + "pwsh": "if (($null -eq $script:calls) -or ($script:calls -ge 3)){$script:calls=0}; ++$script:calls; $script:calls", + "go": "func() func() int { g := 0; return func() int { g++; return g } }()", + "erlang": "fun() -> G=case get(g) of undefined -> 1; _G -> _G end, put(g, G+1), G end." + } + }, + "template": "{{lambda}} == {{{lambda}}} == {{lambda}}", + "expected": "1 == 2 == 3" + }, + { + "name": "Escaping", + "desc": "Lambda results should be appropriately escaped.", + "data": { + "lambda": { + "__tag__": "code", + "ruby": "proc { \">\" }", + "raku": "sub { \">\" }", + "perl": "sub { \">\" }", + "js": "function() { return \">\" }", + "php": "return \">\";", + "python": "lambda: \">\"", + "clojure": "(fn [] \">\")", + "lisp": "(lambda () \">\")", + "pwsh": "\">\"", + "go": "func() string { return \">\" }", + "erlang": "fun() -> \">\" end." + } + }, + "template": "<{{lambda}}{{{lambda}}}", + "expected": "<>>" + }, + { + "name": "Section", + "desc": "Lambdas used for sections should receive the raw section string.", + "data": { + "x": "Error!", + "lambda": { + "__tag__": "code", + "ruby": "proc { |text| text == \"{{x}}\" ? \"yes\" : \"no\" }", + "raku": "sub { $^section eq q+{{x}}+ ?? \"yes\" !! \"no\" }", + "perl": "sub { $_[0] eq \"{{x}}\" ? \"yes\" : \"no\" }", + "js": "function(txt) { return (txt == \"{{x}}\" ? \"yes\" : \"no\") }", + "php": "return ($text == \"{{x}}\") ? \"yes\" : \"no\";", + "python": "lambda text: text == \"{{x}}\" and \"yes\" or \"no\"", + "clojure": "(fn [text] (if (= text \"{{x}}\") \"yes\" \"no\"))", + "lisp": "(lambda (text) (if (string= text \"{{x}}\") \"yes\" \"no\"))", + "pwsh": "if ($args[0] -eq \"{{x}}\") {\"yes\"} else {\"no\"}", + "go": "func(text string) string { if text == \"{{x}}\" { return \"yes\" } else { return \"no\" } }", + "erlang": "fun(\"{{x}}\") -> \"yes\"; (_) -> \"no\" end." + } + }, + "template": "<{{#lambda}}{{x}}{{/lambda}}>", + "expected": "" + }, + { + "name": "Section - Expansion", + "desc": "Lambdas used for sections should have their results parsed.", + "data": { + "planet": "Earth", + "lambda": { + "__tag__": "code", + "ruby": "proc { |text| \"#{text}{{planet}}#{text}\" }", + "raku": "sub { $^section ~ q+{{planet}}+ ~ $^section }", + "perl": "sub { $_[0] . \"{{planet}}\" . $_[0] }", + "js": "function(txt) { return txt + \"{{planet}}\" + txt }", + "php": "return $text . \"{{planet}}\" . $text;", + "python": "lambda text: \"%s{{planet}}%s\" % (text, text)", + "clojure": "(fn [text] (str text \"{{planet}}\" text))", + "lisp": "(lambda (text) (format nil \"~a{{planet}}~a\" text text))", + "pwsh": "\"$($args[0]){{planet}}$($args[0])\"", + "go": "func(text string) string { return text + \"{{planet}}\" + text }", + "erlang": "fun(Text) -> Text ++ \"{{planet}}\" ++ Text end." + } + }, + "template": "<{{#lambda}}-{{/lambda}}>", + "expected": "<-Earth->" + }, + { + "name": "Section - Alternate Delimiters", + "desc": "Lambdas used for sections should parse with the current delimiters.", + "data": { + "planet": "Earth", + "lambda": { + "__tag__": "code", + "ruby": "proc { |text| \"#{text}{{planet}} => |planet|#{text}\" }", + "raku": "sub { $^section ~ q+{{planet}} => |planet|+ ~ $^section }", + "perl": "sub { $_[0] . \"{{planet}} => |planet|\" . $_[0] }", + "js": "function(txt) { return txt + \"{{planet}} => |planet|\" + txt }", + "php": "return $text . \"{{planet}} => |planet|\" . $text;", + "python": "lambda text: \"%s{{planet}} => |planet|%s\" % (text, text)", + "clojure": "(fn [text] (str text \"{{planet}} => |planet|\" text))", + "lisp": "(lambda (text) (format nil \"~a{{planet}} => |planet|~a\" text text))", + "pwsh": "\"$($args[0]){{planet}} => |planet|$($args[0])\"", + "go": "func(text string) string { return text + \"{{planet}} => |planet|\" + text }", + "erlang": "fun(Text) -> Text ++ \"{{planet}} => |planet|\" ++ Text end." + } + }, + "template": "{{= | | =}}<|#lambda|-|/lambda|>", + "expected": "<-{{planet}} => Earth->" + }, + { + "name": "Section - Multiple Calls", + "desc": "Lambdas used for sections should not be cached.", + "data": { + "lambda": { + "__tag__": "code", + "ruby": "proc { |text| \"__#{text}__\" }", + "raku": "sub { \"__\" ~ $^section ~ \"__\" }", + "perl": "sub { \"__\" . $_[0] . \"__\" }", + "js": "function(txt) { return \"__\" + txt + \"__\" }", + "php": "return \"__\" . $text . \"__\";", + "python": "lambda text: \"__%s__\" % (text)", + "clojure": "(fn [text] (str \"__\" text \"__\"))", + "lisp": "(lambda (text) (format nil \"__~a__\" text))", + "pwsh": "\"__$($args[0])__\"", + "go": "func(text string) string { return \"__\" + text + \"__\" }", + "erlang": "fun(Text) -> \"__\" ++ Text ++ \"__\" end." + } + }, + "template": "{{#lambda}}FILE{{/lambda}} != {{#lambda}}LINE{{/lambda}}", + "expected": "__FILE__ != __LINE__" + }, + { + "name": "Inverted Section", + "desc": "Lambdas used for inverted sections should be considered truthy.", + "data": { + "static": "static", + "lambda": { + "__tag__": "code", + "ruby": "proc { |text| false }", + "raku": "sub { 0 }", + "perl": "sub { 0 }", + "js": "function(txt) { return false }", + "php": "return false;", + "python": "lambda text: 0", + "clojure": "(fn [text] false)", + "lisp": "(lambda (text) (declare (ignore text)) nil)", + "pwsh": "$false", + "go": "func(text string) bool { return false }", + "erlang": "fun(_) -> false end." + } + }, + "template": "<{{^lambda}}{{static}}{{/lambda}}>", + "expected": "<>" + } + ] +} +'''; diff --git a/third_party/packages/mustache_template/test/specs/partials.dart b/third_party/packages/mustache_template/test/specs/partials.dart new file mode 100644 index 0000000..062f435 --- /dev/null +++ b/third_party/packages/mustache_template/test/specs/partials.dart @@ -0,0 +1,156 @@ +// Generated from partials.json@97c05b0652f06ef4cbe12016b8d0e41df31c4b05 at 2026-03-09T19:49:45Z +const String PARTIALS = r''' +{ + "__ATTN__": "Do not edit this file; changes belong in the appropriate YAML file.", + "overview": "Partial tags are used to expand an external template into the current\ntemplate.\n\nThe tag's content MUST be a non-whitespace character sequence NOT containing\nthe current closing delimiter.\n\nThis tag's content names the partial to inject. Set Delimiter tags MUST NOT\naffect the parsing of a partial. The partial MUST be rendered against the\ncontext stack local to the tag. If the named partial cannot be found, the\nempty string SHOULD be used instead, as in interpolations.\n\nPartial tags SHOULD be treated as standalone when appropriate. If this tag\nis used standalone, any whitespace preceding the tag should treated as\nindentation, and prepended to each line of the partial before rendering.\n", + "tests": [ + { + "name": "Basic Behavior", + "desc": "The greater-than operator should expand to the named partial.", + "data": { + }, + "template": "\"{{>text}}\"", + "partials": { + "text": "from partial" + }, + "expected": "\"from partial\"" + }, + { + "name": "Failed Lookup", + "desc": "The empty string should be used when the named partial is not found.", + "data": { + }, + "template": "\"{{>text}}\"", + "partials": { + }, + "expected": "\"\"" + }, + { + "name": "Context", + "desc": "The greater-than operator should operate within the current context.", + "data": { + "text": "content" + }, + "template": "\"{{>partial}}\"", + "partials": { + "partial": "*{{text}}*" + }, + "expected": "\"*content*\"" + }, + { + "name": "Recursion", + "desc": "The greater-than operator should properly recurse.", + "data": { + "content": "X", + "nodes": [ + { + "content": "Y", + "nodes": [ + + ] + } + ] + }, + "template": "{{>node}}", + "partials": { + "node": "{{content}}<{{#nodes}}{{>node}}{{/nodes}}>" + }, + "expected": "X>" + }, + { + "name": "Nested", + "desc": "The greater-than operator should work from within partials.", + "data": { + "a": "hello", + "b": "world" + }, + "template": "{{>outer}}", + "partials": { + "outer": "*{{a}} {{>inner}}*", + "inner": "{{b}}!" + }, + "expected": "*hello world!*" + }, + { + "name": "Surrounding Whitespace", + "desc": "The greater-than operator should not alter surrounding whitespace.", + "data": { + }, + "template": "| {{>partial}} |", + "partials": { + "partial": "\t|\t" + }, + "expected": "| \t|\t |" + }, + { + "name": "Inline Indentation", + "desc": "Whitespace should be left untouched.", + "data": { + "data": "|" + }, + "template": " {{data}} {{> partial}}\n", + "partials": { + "partial": ">\n>" + }, + "expected": " | >\n>\n" + }, + { + "name": "Standalone Line Endings", + "desc": "\"\\r\\n\" should be considered a newline for standalone tags.", + "data": { + }, + "template": "|\r\n{{>partial}}\r\n|", + "partials": { + "partial": ">" + }, + "expected": "|\r\n>|" + }, + { + "name": "Standalone Without Previous Line", + "desc": "Standalone tags should not require a newline to precede them.", + "data": { + }, + "template": " {{>partial}}\n>", + "partials": { + "partial": ">\n>" + }, + "expected": " >\n >>" + }, + { + "name": "Standalone Without Newline", + "desc": "Standalone tags should not require a newline to follow them.", + "data": { + }, + "template": ">\n {{>partial}}", + "partials": { + "partial": ">\n>" + }, + "expected": ">\n >\n >" + }, + { + "name": "Standalone Indentation", + "desc": "Each line of the partial should be indented before rendering.", + "data": { + "content": "<\n->" + }, + "template": "\\\n {{>partial}}\n/\n", + "partials": { + "partial": "|\n{{{content}}}\n|\n" + }, + "expected": "\\\n |\n <\n->\n |\n/\n" + }, + { + "name": "Padding Whitespace", + "desc": "Superfluous in-tag whitespace should be ignored.", + "data": { + "boolean": true + }, + "template": "|{{> partial }}|", + "partials": { + "partial": "[]" + }, + "expected": "|[]|" + } + ] +} +'''; diff --git a/third_party/packages/mustache_template/test/specs/sections.dart b/third_party/packages/mustache_template/test/specs/sections.dart new file mode 100644 index 0000000..4da83ca --- /dev/null +++ b/third_party/packages/mustache_template/test/specs/sections.dart @@ -0,0 +1,426 @@ +// Generated from sections.json@97c05b0652f06ef4cbe12016b8d0e41df31c4b05 at 2026-03-09T19:49:45Z +const String SECTIONS = r''' +{ + "__ATTN__": "Do not edit this file; changes belong in the appropriate YAML file.", + "overview": "Section tags and End Section tags are used in combination to wrap a section\nof the template for iteration.\n\nThese tags' content MUST be a non-whitespace character sequence NOT\ncontaining the current closing delimiter; each Section tag MUST be followed\nby an End Section tag with the same content within the same section.\n\nThis tag's content names the data to replace the tag. Name resolution is as\nfollows:\n 1) If the name is a single period (.), the data is the item currently\n sitting atop the context stack. Skip the rest of these steps.\n 2) Split the name on periods; the first part is the name to resolve, any\n remaining parts should be retained.\n 3) Walk the context stack from top to bottom, finding the first context\n that is a) a hash containing the name as a key OR b) an object responding\n to a method with the given name.\n 4) If the context is a hash, the data is the value associated with the\n name.\n 5) If the context is an object and the method with the given name has an\n arity of 1, the method SHOULD be called with a String containing the\n unprocessed contents of the sections; the data is the value returned.\n 6) Otherwise, the data is the value returned by calling the method with\n the given name.\n 7) If any name parts were retained in step 1, each should be resolved\n against a context stack containing only the result from the former\n resolution. If any part fails resolution, the result should be considered\n falsey, and should interpolate as the empty string.\n\nIf the data is not of a list type, it is coerced into a list as follows: if\nthe data is truthy (e.g. `!!data == true`), use a single-element list\ncontaining the data, otherwise use an empty list.\n\nFor each element in the data list, the element MUST be pushed onto the\ncontext stack, the section MUST be rendered, and the element MUST be popped\noff the context stack.\n\nSection and End Section tags SHOULD be treated as standalone when\nappropriate.\n", + "tests": [ + { + "name": "Truthy", + "desc": "Truthy sections should have their contents rendered.", + "data": { + "boolean": true + }, + "template": "\"{{#boolean}}This should be rendered.{{/boolean}}\"", + "expected": "\"This should be rendered.\"" + }, + { + "name": "Falsey", + "desc": "Falsey sections should have their contents omitted.", + "data": { + "boolean": false + }, + "template": "\"{{#boolean}}This should not be rendered.{{/boolean}}\"", + "expected": "\"\"" + }, + { + "name": "Null is falsey", + "desc": "Null is falsey.", + "data": { + "null": null + }, + "template": "\"{{#null}}This should not be rendered.{{/null}}\"", + "expected": "\"\"" + }, + { + "name": "Context", + "desc": "Objects and hashes should be pushed onto the context stack.", + "data": { + "context": { + "name": "Joe" + } + }, + "template": "\"{{#context}}Hi {{name}}.{{/context}}\"", + "expected": "\"Hi Joe.\"" + }, + { + "name": "Parent contexts", + "desc": "Names missing in the current context are looked up in the stack.", + "data": { + "a": "foo", + "b": "wrong", + "sec": { + "b": "bar" + }, + "c": { + "d": "baz" + } + }, + "template": "\"{{#sec}}{{a}}, {{b}}, {{c.d}}{{/sec}}\"", + "expected": "\"foo, bar, baz\"" + }, + { + "name": "Variable test", + "desc": "Non-false sections have their value at the top of context,\naccessible as {{.}} or through the parent context. This gives\na simple way to display content conditionally if a variable exists.\n", + "data": { + "foo": "bar" + }, + "template": "\"{{#foo}}{{.}} is {{foo}}{{/foo}}\"", + "expected": "\"bar is bar\"" + }, + { + "name": "List Contexts", + "desc": "All elements on the context stack should be accessible within lists.", + "data": { + "tops": [ + { + "tname": { + "upper": "A", + "lower": "a" + }, + "middles": [ + { + "mname": "1", + "bottoms": [ + { + "bname": "x" + }, + { + "bname": "y" + } + ] + } + ] + } + ] + }, + "template": "{{#tops}}{{#middles}}{{tname.lower}}{{mname}}.{{#bottoms}}{{tname.upper}}{{mname}}{{bname}}.{{/bottoms}}{{/middles}}{{/tops}}", + "expected": "a1.A1x.A1y." + }, + { + "name": "Deeply Nested Contexts", + "desc": "All elements on the context stack should be accessible.", + "data": { + "a": { + "one": 1 + }, + "b": { + "two": 2 + }, + "c": { + "three": 3, + "d": { + "four": 4, + "five": 5 + } + } + }, + "template": "{{#a}}\n{{one}}\n{{#b}}\n{{one}}{{two}}{{one}}\n{{#c}}\n{{one}}{{two}}{{three}}{{two}}{{one}}\n{{#d}}\n{{one}}{{two}}{{three}}{{four}}{{three}}{{two}}{{one}}\n{{#five}}\n{{one}}{{two}}{{three}}{{four}}{{five}}{{four}}{{three}}{{two}}{{one}}\n{{one}}{{two}}{{three}}{{four}}{{.}}6{{.}}{{four}}{{three}}{{two}}{{one}}\n{{one}}{{two}}{{three}}{{four}}{{five}}{{four}}{{three}}{{two}}{{one}}\n{{/five}}\n{{one}}{{two}}{{three}}{{four}}{{three}}{{two}}{{one}}\n{{/d}}\n{{one}}{{two}}{{three}}{{two}}{{one}}\n{{/c}}\n{{one}}{{two}}{{one}}\n{{/b}}\n{{one}}\n{{/a}}\n", + "expected": "1\n121\n12321\n1234321\n123454321\n12345654321\n123454321\n1234321\n12321\n121\n1\n" + }, + { + "name": "List", + "desc": "Lists should be iterated; list items should visit the context stack.", + "data": { + "list": [ + { + "item": 1 + }, + { + "item": 2 + }, + { + "item": 3 + } + ] + }, + "template": "\"{{#list}}{{item}}{{/list}}\"", + "expected": "\"123\"" + }, + { + "name": "Empty List", + "desc": "Empty lists should behave like falsey values.", + "data": { + "list": [ + + ] + }, + "template": "\"{{#list}}Yay lists!{{/list}}\"", + "expected": "\"\"" + }, + { + "name": "Doubled", + "desc": "Multiple sections per template should be permitted.", + "data": { + "bool": true, + "two": "second" + }, + "template": "{{#bool}}\n* first\n{{/bool}}\n* {{two}}\n{{#bool}}\n* third\n{{/bool}}\n", + "expected": "* first\n* second\n* third\n" + }, + { + "name": "Nested (Truthy)", + "desc": "Nested truthy sections should have their contents rendered.", + "data": { + "bool": true + }, + "template": "| A {{#bool}}B {{#bool}}C{{/bool}} D{{/bool}} E |", + "expected": "| A B C D E |" + }, + { + "name": "Nested (Falsey)", + "desc": "Nested falsey sections should be omitted.", + "data": { + "bool": false + }, + "template": "| A {{#bool}}B {{#bool}}C{{/bool}} D{{/bool}} E |", + "expected": "| A E |" + }, + { + "name": "Context Misses", + "desc": "Failed context lookups should be considered falsey.", + "data": { + }, + "template": "[{{#missing}}Found key 'missing'!{{/missing}}]", + "expected": "[]" + }, + { + "name": "Implicit Iterator - String", + "desc": "Implicit iterators should directly interpolate strings.", + "data": { + "list": [ + "a", + "b", + "c", + "d", + "e" + ] + }, + "template": "\"{{#list}}({{.}}){{/list}}\"", + "expected": "\"(a)(b)(c)(d)(e)\"" + }, + { + "name": "Implicit Iterator - Integer", + "desc": "Implicit iterators should cast integers to strings and interpolate.", + "data": { + "list": [ + 1, + 2, + 3, + 4, + 5 + ] + }, + "template": "\"{{#list}}({{.}}){{/list}}\"", + "expected": "\"(1)(2)(3)(4)(5)\"" + }, + { + "name": "Implicit Iterator - Decimal", + "desc": "Implicit iterators should cast decimals to strings and interpolate.", + "data": { + "list": [ + 1.1, + 2.2, + 3.3, + 4.4, + 5.5 + ] + }, + "template": "\"{{#list}}({{.}}){{/list}}\"", + "expected": "\"(1.1)(2.2)(3.3)(4.4)(5.5)\"" + }, + { + "name": "Implicit Iterator - Array", + "desc": "Implicit iterators should allow iterating over nested arrays.", + "data": { + "list": [ + [ + 1, + 2, + 3 + ], + [ + "a", + "b", + "c" + ] + ] + }, + "template": "\"{{#list}}({{#.}}{{.}}{{/.}}){{/list}}\"", + "expected": "\"(123)(abc)\"" + }, + { + "name": "Implicit Iterator - HTML Escaping", + "desc": "Implicit iterators with basic interpolation should be HTML escaped.", + "data": { + "list": [ + "&", + "\"", + "<", + ">" + ] + }, + "template": "\"{{#list}}({{.}}){{/list}}\"", + "expected": "\"(&)(")(<)(>)\"" + }, + { + "name": "Implicit Iterator - Triple mustache", + "desc": "Implicit iterators in triple mustache should interpolate without HTML escaping.", + "data": { + "list": [ + "&", + "\"", + "<", + ">" + ] + }, + "template": "\"{{#list}}({{{.}}}){{/list}}\"", + "expected": "\"(&)(\")(<)(>)\"" + }, + { + "name": "Implicit Iterator - Ampersand", + "desc": "Implicit iterators in an Ampersand tag should interpolate without HTML escaping.", + "data": { + "list": [ + "&", + "\"", + "<", + ">" + ] + }, + "template": "\"{{#list}}({{&.}}){{/list}}\"", + "expected": "\"(&)(\")(<)(>)\"" + }, + { + "name": "Implicit Iterator - Root-level", + "desc": "Implicit iterators should work on root-level lists.", + "data": [ + { + "value": "a" + }, + { + "value": "b" + } + ], + "template": "\"{{#.}}({{value}}){{/.}}\"", + "expected": "\"(a)(b)\"" + }, + { + "name": "Dotted Names - Truthy", + "desc": "Dotted names should be valid for Section tags.", + "data": { + "a": { + "b": { + "c": true + } + } + }, + "template": "\"{{#a.b.c}}Here{{/a.b.c}}\" == \"Here\"", + "expected": "\"Here\" == \"Here\"" + }, + { + "name": "Dotted Names - Falsey", + "desc": "Dotted names should be valid for Section tags.", + "data": { + "a": { + "b": { + "c": false + } + } + }, + "template": "\"{{#a.b.c}}Here{{/a.b.c}}\" == \"\"", + "expected": "\"\" == \"\"" + }, + { + "name": "Dotted Names - Broken Chains", + "desc": "Dotted names that cannot be resolved should be considered falsey.", + "data": { + "a": { + } + }, + "template": "\"{{#a.b.c}}Here{{/a.b.c}}\" == \"\"", + "expected": "\"\" == \"\"" + }, + { + "name": "Surrounding Whitespace", + "desc": "Sections should not alter surrounding whitespace.", + "data": { + "boolean": true + }, + "template": " | {{#boolean}}\t|\t{{/boolean}} | \n", + "expected": " | \t|\t | \n" + }, + { + "name": "Internal Whitespace", + "desc": "Sections should not alter internal whitespace.", + "data": { + "boolean": true + }, + "template": " | {{#boolean}} {{! Important Whitespace }}\n {{/boolean}} | \n", + "expected": " | \n | \n" + }, + { + "name": "Indented Inline Sections", + "desc": "Single-line sections should not alter surrounding whitespace.", + "data": { + "boolean": true + }, + "template": " {{#boolean}}YES{{/boolean}}\n {{#boolean}}GOOD{{/boolean}}\n", + "expected": " YES\n GOOD\n" + }, + { + "name": "Standalone Lines", + "desc": "Standalone lines should be removed from the template.", + "data": { + "boolean": true + }, + "template": "| This Is\n{{#boolean}}\n|\n{{/boolean}}\n| A Line\n", + "expected": "| This Is\n|\n| A Line\n" + }, + { + "name": "Indented Standalone Lines", + "desc": "Indented standalone lines should be removed from the template.", + "data": { + "boolean": true + }, + "template": "| This Is\n {{#boolean}}\n|\n {{/boolean}}\n| A Line\n", + "expected": "| This Is\n|\n| A Line\n" + }, + { + "name": "Standalone Line Endings", + "desc": "\"\\r\\n\" should be considered a newline for standalone tags.", + "data": { + "boolean": true + }, + "template": "|\r\n{{#boolean}}\r\n{{/boolean}}\r\n|", + "expected": "|\r\n|" + }, + { + "name": "Standalone Without Previous Line", + "desc": "Standalone tags should not require a newline to precede them.", + "data": { + "boolean": true + }, + "template": " {{#boolean}}\n#{{/boolean}}\n/", + "expected": "#\n/" + }, + { + "name": "Standalone Without Newline", + "desc": "Standalone tags should not require a newline to follow them.", + "data": { + "boolean": true + }, + "template": "#{{#boolean}}\n/\n {{/boolean}}", + "expected": "#\n/\n" + }, + { + "name": "Padding", + "desc": "Superfluous in-tag whitespace should be ignored.", + "data": { + "boolean": true + }, + "template": "|{{# boolean }}={{/ boolean }}|", + "expected": "|=|" + } + ] +} +'''; diff --git a/third_party/packages/mustache_template/test/specs/specs.dart b/third_party/packages/mustache_template/test/specs/specs.dart new file mode 100644 index 0000000..a6a7828 --- /dev/null +++ b/third_party/packages/mustache_template/test/specs/specs.dart @@ -0,0 +1,22 @@ +// Generated from mustache/spec@97c05b0652f06ef4cbe12016b8d0e41df31c4b05 at 2026-03-09T19:49:45Z +import 'comments.dart'; +import 'delimiters.dart'; +import 'dynamic_names.dart'; +import 'inheritance.dart'; +import 'interpolation.dart'; +import 'inverted.dart'; +import 'lambdas.dart'; +import 'partials.dart'; +import 'sections.dart'; + +const Map SPECS = { + 'comments': COMMENTS, + 'delimiters': DELIMITERS, + 'dynamic_names': DYNAMIC_NAMES, + 'inheritance': INHERITANCE, + 'interpolation': INTERPOLATION, + 'inverted': INVERTED, + 'lambdas': LAMBDAS, + 'partials': PARTIALS, + 'sections': SECTIONS, +}; diff --git a/third_party/packages/mustache_template/tool/download_spec.dart b/third_party/packages/mustache_template/tool/download_spec.dart new file mode 100644 index 0000000..15ff4af --- /dev/null +++ b/third_party/packages/mustache_template/tool/download_spec.dart @@ -0,0 +1,113 @@ +import 'dart:io'; + +/// Clones the mustache/spec repository, embeds each JSON spec as a Dart raw +/// string under `test/specs/`, and writes `test/specs/specs.dart` with a +/// `SPECS` map. +/// +/// Run from the package root: +/// `dart run tool/download_spec.dart` +Future main(List args) async { + final Directory packageRoot = _packageRootDirectory(); + final testSpecsDir = Directory(_join(packageRoot.path, 'test', 'specs')); + final String tmpSpecPath = _join(packageRoot.path, 'tmp_spec'); + final tmpSpecDir = Directory(tmpSpecPath); + + if (tmpSpecDir.existsSync()) { + await tmpSpecDir.delete(recursive: true); + } + + await _runGit(packageRoot.path, [ + 'clone', + 'https://github.com/mustache/spec.git', + tmpSpecPath, + ]); + + final String headHash = + ((await _runGit(packageRoot.path, ['-C', tmpSpecPath, 'rev-parse', 'HEAD'])).stdout + as String) + .trim(); + + final String utcNow = _formatUtcSecond(DateTime.now().toUtc()); + + if (testSpecsDir.existsSync()) { + await testSpecsDir.delete(recursive: true); + } + await testSpecsDir.create(recursive: true); + + final clonedSpecs = Directory(_join(tmpSpecPath, 'specs')); + final List jsonFiles = + clonedSpecs.listSync().whereType().where((File f) => f.path.endsWith('.json')).toList() + ..sort((File a, File b) => a.path.compareTo(b.path)); + + final exports = StringBuffer(); + final mapEntries = StringBuffer(); + + for (final jsonFile in jsonFiles) { + final String original = jsonFile.uri.pathSegments.last; + var base = original; + if (base.endsWith('.json')) { + base = base.substring(0, base.length - '.json'.length); + } + if (base.startsWith('~')) { + base = base.substring(1); + } + final String dartName = base.replaceAll('-', '_'); + final String constName = dartName.toUpperCase(); + final String jsonText = await jsonFile.readAsString(); + + final outFile = File(_join(testSpecsDir.path, '$dartName.dart')); + await outFile.writeAsString( + '// Generated from $original@$headHash at $utcNow\n' + "const String $constName = r'''\n" + '$jsonText' + "''';\n", + ); + + exports.write("import '"); + exports.write(dartName); + exports.writeln(".dart';"); + + mapEntries.write(" '"); + mapEntries.write(dartName); + mapEntries.writeln("': $constName,"); + } + + await File(_join(testSpecsDir.path, 'specs.dart')).writeAsString( + '// Generated from mustache/spec@$headHash at $utcNow\n' + '$exports\n' + 'const Map SPECS = {\n' + '$mapEntries' + '};\n', + ); + + await Directory(tmpSpecPath).delete(recursive: true); +} + +Directory _packageRootDirectory() { + return File.fromUri(Platform.script).parent.parent; +} + +String _formatUtcSecond(DateTime dt) { + assert(dt.isUtc); + String two(int n) => n.toString().padLeft(2, '0'); + final String y = dt.year.toString().padLeft(4, '0'); + return '$y-${two(dt.month)}-${two(dt.day)}T${two(dt.hour)}:${two(dt.minute)}:${two(dt.second)}Z'; +} + +String _join(String a, String b, [String? c]) { + final String sep = Platform.pathSeparator; + return [a, b, if (c != null) c].join(sep); +} + +Future _runGit(String workingDirectory, List arguments) async { + final ProcessResult result = await Process.run( + 'git', + arguments, + workingDirectory: workingDirectory, + ); + if (result.exitCode != 0) { + stderr.writeln(result.stderr); + exit(result.exitCode); + } + return result; +}