diff --git a/.github/ISSUE_TEMPLATE/yaml_edit.md b/.github/ISSUE_TEMPLATE/yaml_edit.md new file mode 100644 index 000000000..d1122a9b6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/yaml_edit.md @@ -0,0 +1,5 @@ +--- +name: "package:yaml_edit" +about: "Create a bug or file a feature request against package:yaml_edit." +labels: "package:yaml_edit" +--- \ No newline at end of file diff --git a/.github/labeler.yml b/.github/labeler.yml index 84926222a..248532b0c 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -146,4 +146,8 @@ 'package:yaml': - changed-files: - - any-glob-to-any-file: 'pkgs/yaml/**' \ No newline at end of file + - any-glob-to-any-file: 'pkgs/yaml/**' + +'package:yaml_edit': + - changed-files: + - any-glob-to-any-file: 'pkgs/yaml_edit/**' diff --git a/.github/workflows/yaml_edit.yaml b/.github/workflows/yaml_edit.yaml new file mode 100644 index 000000000..ffea62cb3 --- /dev/null +++ b/.github/workflows/yaml_edit.yaml @@ -0,0 +1,91 @@ +name: package:yaml_edit + +on: + # Run on PRs and pushes to the default branch. + push: + branches: [ main ] + paths: + - '.github/workflows/yaml_edit.yaml' + - 'pkgs/yaml_edit/**' + pull_request: + branches: [ main ] + paths: + - '.github/workflows/yaml_edit.yaml' + - 'pkgs/yaml_edit/**' + schedule: + - cron: "0 0 * * 0" + +env: + PUB_ENVIRONMENT: bot.github + + +defaults: + run: + working-directory: pkgs/yaml_edit/ + +jobs: + # Check code formatting and static analysis on a single OS (linux) + # against Dart dev. + analyze: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: [dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Check formatting + run: dart format --output=none --set-exit-if-changed . + if: always() && steps.install.outcome == 'success' + - name: Analyze code + run: dart analyze --fatal-infos + if: always() && steps.install.outcome == 'success' + + # Run tests on a matrix consisting of two dimensions: + # 1. OS: ubuntu-latest, (macos-latest, windows-latest) + # 2. release channel: dev + test: + needs: analyze + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Add macos-latest and/or windows-latest if relevant for this package. + os: [ubuntu-latest] + sdk: ['3.1', stable, dev] + platform: [vm, chrome] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Run tests on ${{ matrix.platform }} + run: dart test --platform ${{ matrix.platform }} --coverage=./coverage + if: always() && steps.install.outcome == 'success' + # We don't collect code coverage from 2.12.0, because it doesn't work + - name: Convert coverage to lcov + run: dart run coverage:format_coverage -i ./coverage -o ./coverage/lcov.info --lcov --report-on lib/ + if: always() && steps.install.outcome == 'success' && matrix.sdk != '2.12.0' + - uses: coverallsapp/github-action@cfd0633edbd2411b532b808ba7a8b5e04f76d2c8 + if: always() && steps.install.outcome == 'success' && matrix.sdk != '2.12.0' + with: + flag-name: os:${{ matrix.os }}/dart:${{ matrix.sdk }}/platform:${{ matrix.platform }} + parallel: true + + report-coverage: + needs: test + if: ${{ always() }} + runs-on: ubuntu-latest + steps: + - uses: coverallsapp/github-action@cfd0633edbd2411b532b808ba7a8b5e04f76d2c8 + with: + parallel-finished: true diff --git a/README.md b/README.md index a9608b7fa..01702e89d 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ don't naturally belong to other topic monorepos (like | [unified_analytics](pkgs/unified_analytics/) | A package for logging analytics for all Dart and Flutter related tooling to Google Analytics. | [![package issues](https://img.shields.io/badge/package:unified_analytics-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Aunified_analytics) | [![pub package](https://img.shields.io/pub/v/unified_analytics.svg)](https://pub.dev/packages/unified_analytics) | | [watcher](pkgs/watcher/) | Monitor directories and send notifications when the contents change. | [![package issues](https://img.shields.io/badge/package:watcher-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Awatcher) | [![pub package](https://img.shields.io/pub/v/watcher.svg)](https://pub.dev/packages/watcher) | | [yaml](pkgs/yaml/) | A parser for YAML, a human-friendly data serialization standard | [![package issues](https://img.shields.io/badge/package:yaml-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Ayaml) | [![pub package](https://img.shields.io/pub/v/yaml.svg)](https://pub.dev/packages/yaml) | +| [yaml_edit](pkgs/yaml_edit/) | A library for YAML manipulation with comment and whitespace preservation. | [![package issues](https://img.shields.io/badge/package:yaml_edit-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Ayaml_edit) | [![pub package](https://img.shields.io/pub/v/yaml_edit.svg)](https://pub.dev/packages/yaml_edit) | ## Publishing automation diff --git a/pkgs/yaml_edit/.gitignore b/pkgs/yaml_edit/.gitignore new file mode 100644 index 000000000..7886c3d1a --- /dev/null +++ b/pkgs/yaml_edit/.gitignore @@ -0,0 +1,3 @@ +/.dart_tool/ +/.packages +/pubspec.lock diff --git a/pkgs/yaml_edit/CHANGELOG.md b/pkgs/yaml_edit/CHANGELOG.md new file mode 100644 index 000000000..9342e9f0e --- /dev/null +++ b/pkgs/yaml_edit/CHANGELOG.md @@ -0,0 +1,95 @@ +## 2.2.2 + +- Suppress warnings previously printed to `stdout` when parsing YAML internally. +- Fix error thrown when inserting duplicate keys to different maps in the same + list. + ([#69](https://github.com/dart-lang/yaml_edit/issues/69)) + +- Fix error thrown when inserting in nested list using `spliceList` method + ([#83](https://github.com/dart-lang/yaml_edit/issues/83)) + +- Fix error thrown when string has spaces when applying `ScalarStyle.FOLDED`. + ([#41](https://github.com/dart-lang/yaml_edit/issues/41)). Resolves + ([[#86](https://github.com/dart-lang/yaml_edit/issues/86)]). + +- Require Dart 3.1 + +- Move to `dart-lang/tools` monorepo. + +## 2.2.1 + +- Require Dart 3.0 +- Fix removal of last key in blockmap when key has no value + ([#55](https://github.com/dart-lang/yaml_edit/issues/55)). + +## 2.2.0 + +- Fix inconsistent line endings when inserting maps into a document using `\r\n`. + ([#65](https://github.com/dart-lang/yaml_edit/issues/65)) + +- `AliasError` is changed to `AliasException` and exposed in the public API. + + All node-mutating methods on `YamlEditor`, i.e. `update()`, `appendToList()`, + `prependToList()`, `insertIntoList()`, `spliceList()`, `remove()` will now + throw an exception instead of an error when encountering an alias on the path + to modify. + + This allows catching and handling when this is happening. + +## 2.1.1 + +- Require Dart 2.19 + +## 2.1.0 + +- **Breaking** `wrapAsYamlNode(value, collectionStyle, scalarStyle)` will apply + `collectionStyle` and `scalarStyle` recursively when wrapping a children of + `Map` and `List`. + While this may change the style of the YAML documents written by applications + that rely on the old behavior, such YAML documents should still be valid. + Hence, we hope it is reasonable to make this change in a minor release. +- Fix for cases that can't be encoded correctly with + `scalarStyle: ScalarStyle.SINGLE_QUOTED`. +- Fix YamlEditor `appendToList` and `insertIntoList` functions inserts new item into next yaml item + rather than at end of list. + ([#23](https://github.com/dart-lang/yaml_edit/issues/23)) + +## 2.0.3 + +- Updated the value of the pubspec `repository` field. + +## 2.0.2 + +- Fix trailing whitespace after adding new key with block-value to map + ([#15](https://github.com/dart-lang/yaml_edit/issues/15)). +- Updated `repository` and other meta-data in `pubspec.yaml`. + +## 2.0.1 + +- License changed to BSD, as this package is now maintained by the Dart team. +- Fixed minor lints. + +## 2.0.0 + +- Migrated to null-safety. +- API will no-longer return `null` in-place of a `YamlNode`, instead a + `YamlNode` with `YamlNode.value == null` should be used. These are easily + created with `wrapAsYamlNode(null)`. + +## 1.0.3 + +- Fixed bug in adding an empty map as a map value. + +## 1.0.2 + +- Throws an error if the final YAML after edit is not parsable. +- Fixed bug in adding to empty map values, when it is followed by other content. + +## 1.0.1 + +- Updated behavior surrounding list and map removal. +- Fixed bug in dealing with empty values. + +## 1.0.0 + +- Initial release. diff --git a/pkgs/yaml_edit/LICENSE b/pkgs/yaml_edit/LICENSE new file mode 100644 index 000000000..413ed83b7 --- /dev/null +++ b/pkgs/yaml_edit/LICENSE @@ -0,0 +1,26 @@ +Copyright 2020, the Dart project 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 LLC 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/pkgs/yaml_edit/README.md b/pkgs/yaml_edit/README.md new file mode 100644 index 000000000..f10560bfa --- /dev/null +++ b/pkgs/yaml_edit/README.md @@ -0,0 +1,61 @@ +[![Dart CI](https://github.com/dart-lang/yaml_edit/actions/workflows/test-package.yml/badge.svg)](https://github.com/dart-lang/yaml_edit/actions/workflows/test-package.yml) +[![pub package](https://img.shields.io/pub/v/yaml_edit.svg)](https://pub.dev/packages/yaml_edit) +[![package publisher](https://img.shields.io/pub/publisher/yaml_edit.svg)](https://pub.dev/packages/yaml_edit/publisher) +[![Coverage Status](https://coveralls.io/repos/github/dart-lang/yaml_edit/badge.svg)](https://coveralls.io/github/dart-lang/yaml_edit) + +A library for [YAML](https://yaml.org) manipulation while preserving comments. + +## Usage + +A simple usage example: + +```dart +import 'package:yaml_edit/yaml_edit.dart'; + +void main() { + final yamlEditor = YamlEditor('{YAML: YAML}'); + yamlEditor.update(['YAML'], "YAML Ain't Markup Language"); + print(yamlEditor); + // Expected output: + // {YAML: YAML Ain't Markup Language} +} +``` + +### Example: Converting JSON to YAML (block formatted) + +```dart +void main() { + final jsonString = r''' +{ + "key": "value", + "list": [ + "first", + "second", + "last entry in the list" + ], + "map": { + "multiline": "this is a fairly long string with\nline breaks..." + } +} +'''; + final jsonValue = json.decode(jsonString); + + // Convert jsonValue to YAML + final yamlEditor = YamlEditor(''); + yamlEditor.update([], jsonValue); + print(yamlEditor.toString()); +} +``` + +## Testing + +Testing is done in two strategies: Unit testing (`/test/editor_test.dart`) and +Golden testing (`/test/golden_test.dart`). More information on Golden testing +and the input/output format can be found at `/test/testdata/README.md`. + +These tests are automatically run with `pub run test`. + +## Limitations + +1. Users are not allowed to define tags in the modifications. +2. Map keys will always be added in the flow style. diff --git a/pkgs/yaml_edit/analysis_options.yaml b/pkgs/yaml_edit/analysis_options.yaml new file mode 100644 index 000000000..937e7fec4 --- /dev/null +++ b/pkgs/yaml_edit/analysis_options.yaml @@ -0,0 +1,8 @@ +include: package:dart_flutter_team_lints/analysis_options.yaml + +analyzer: + errors: + inference_failure_on_collection_literal: ignore + inference_failure_on_function_invocation: ignore + inference_failure_on_function_return_type: ignore + inference_failure_on_instance_creation: ignore diff --git a/pkgs/yaml_edit/example/example.dart b/pkgs/yaml_edit/example/example.dart new file mode 100644 index 000000000..d49c39bc5 --- /dev/null +++ b/pkgs/yaml_edit/example/example.dart @@ -0,0 +1,12 @@ +import 'package:yaml_edit/yaml_edit.dart'; + +void main() { + final doc = YamlEditor(''' +- 0 # comment 0 +- 1 # comment 1 +- 2 # comment 2 +'''); + doc.remove([1]); + + print(doc); +} diff --git a/pkgs/yaml_edit/example/json2yaml.dart b/pkgs/yaml_edit/example/json2yaml.dart new file mode 100644 index 000000000..d6204d3b8 --- /dev/null +++ b/pkgs/yaml_edit/example/json2yaml.dart @@ -0,0 +1,28 @@ +// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:convert' show json; + +import 'package:yaml_edit/yaml_edit.dart'; + +void main() { + final jsonString = r''' +{ + "key": "value", + "list": [ + "first", + "second", + "last entry in the list" + ], + "map": { + "multiline": "this is a fairly long string with\nline breaks..." + } +} +'''; + final jsonValue = json.decode(jsonString); + + final yamlEditor = YamlEditor(''); + yamlEditor.update([], jsonValue); + print(yamlEditor.toString()); +} diff --git a/pkgs/yaml_edit/lib/src/editor.dart b/pkgs/yaml_edit/lib/src/editor.dart new file mode 100644 index 000000000..54775cce1 --- /dev/null +++ b/pkgs/yaml_edit/lib/src/editor.dart @@ -0,0 +1,634 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:meta/meta.dart'; +import 'package:source_span/source_span.dart'; +import 'package:yaml/yaml.dart'; + +import 'equality.dart'; +import 'errors.dart'; +import 'list_mutations.dart'; +import 'map_mutations.dart'; +import 'source_edit.dart'; +import 'strings.dart'; +import 'utils.dart'; +import 'wrap.dart'; + +/// An interface for modifying [YAML][1] documents while preserving comments +/// and whitespaces. +/// +/// YAML parsing is supported by `package:yaml`, and modifications are performed +/// as string operations. An error will be thrown if internal assertions fail - +/// such a situation should be extremely rare, and should only occur with +/// degenerate formatting. +/// +/// Most modification methods require the user to pass in an `Iterable` +/// path that holds the keys/indices to navigate to the element. +/// +/// **Example:** +/// ```yaml +/// a: 1 +/// b: 2 +/// c: +/// - 3 +/// - 4 +/// - {e: 5, f: [6, 7]} +/// ``` +/// +/// To get to `7`, our path will be `['c', 2, 'f', 1]`. The path for the base +/// object is the empty array `[]`. All modification methods will throw a +/// [ArgumentError] if the path provided is invalid. Note also that that the +/// order of elements in the path is important, and it should be arranged in +/// order of calling, with the first element being the first key or index to be +/// called. +/// +/// In most modification methods, users are required to pass in a value to be +/// used for updating the YAML tree. This value is only allowed to either be a +/// valid scalar that is recognizable by YAML (i.e. `bool`, `String`, `List`, +/// `Map`, `num`, `null`) or a [YamlNode]. Should the user want to specify +/// the style to be applied to the value passed in, the user may wrap the value +/// using [wrapAsYamlNode] while passing in the appropriate `scalarStyle` or +/// `collectionStyle`. While we try to respect the style that is passed in, +/// there will be instances where the formatting will not result in valid YAML, +/// and as such we will fallback to a default formatting while preserving the +/// content. +/// +/// To dump the YAML after all the modifications have been completed, simply +/// call [toString()]. +/// +/// [1]: https://yaml.org/ +@sealed +class YamlEditor { + final List _edits = []; + + /// List of [SourceEdit]s that have been applied to [_yaml] since the creation + /// of this instance, in chronological order. Intended to be compatible with + /// `package:analysis_server`. + /// + /// The [SourceEdit] objects can be serialized to JSON using the `toJSON` + /// function, deserialized using [SourceEdit.fromJson], and applied to a + /// string using the `apply` function. Multiple [SourceEdit]s can be applied + /// to a string using [SourceEdit.applyAll]. + /// + /// For more information, refer to the [SourceEdit] class. + List get edits => [..._edits]; + + /// Current YAML string. + String _yaml; + + /// Root node of YAML AST. + YamlNode _contents; + + /// Stores the list of nodes in [_contents] that are connected by aliases. + /// + /// When a node is anchored with an alias and subsequently referenced, + /// the full content of the anchored node is thought to be copied in the + /// following references. + /// + /// **Example:** + /// ```dart + /// a: &SS Sammy Sosa + /// b: *SS + /// ``` + /// + /// is equivalent to + /// + /// ```dart + /// a: Sammy Sosa + /// b: Sammy Sosa + /// ``` + /// + /// As such, aliased nodes have to be treated with special caution when + /// any modification is taking place. + /// + /// See 7.1 Alias Nodes: https://yaml.org/spec/1.2/spec.html#id2786196 + Set _aliases = {}; + + /// Returns the current YAML string. + @override + String toString() => _yaml; + + factory YamlEditor(String yaml) => YamlEditor._(yaml); + + YamlEditor._(this._yaml) : _contents = loadYamlNode(_yaml) { + _initialize(); + } + + /// Traverses the YAML tree formed to detect alias nodes. + void _initialize() { + _aliases = {}; + + /// Performs a DFS on [_contents] to detect alias nodes. + final visited = {}; + void collectAliases(YamlNode node) { + if (visited.add(node)) { + if (node is YamlMap) { + node.nodes.forEach((key, value) { + collectAliases(key as YamlNode); + collectAliases(value); + }); + } else if (node is YamlList) { + node.nodes.forEach(collectAliases); + } + } else { + _aliases.add(node); + } + } + + collectAliases(_contents); + } + + /// Parses the document to return [YamlNode] currently present at [path]. + /// + /// If no [YamlNode]s exist at [path], the result of invoking the [orElse] + /// function is returned. + /// + /// If [orElse] is omitted, it defaults to throwing a [ArgumentError]. + /// + /// To get a default value when [path] does not point to a value in the + /// [YamlNode]-tree, simply pass `orElse: () => ...`. + /// + /// **Example:** (using orElse) + /// ```dart + /// final myYamlEditor('{"key": "value"}'); + /// final node = myYamlEditor.valueAt( + /// ['invalid', 'path'], + /// orElse: () => wrapAsYamlNode(null), + /// ); + /// print(node.value); // null + /// ``` + /// + /// **Example:** (common usage) + /// ```dart + /// final doc = YamlEditor(''' + /// a: 1 + /// b: + /// d: 4 + /// e: [5, 6, 7] + /// c: 3 + /// '''); + /// print(doc.parseAt(['b', 'e', 2])); // 7 + /// ``` + /// The value returned by [parseAt] is invalidated when the documented is + /// mutated, as illustrated below: + /// + /// **Example:** (old [parseAt] value is invalidated) + /// ```dart + /// final doc = YamlEditor("YAML: YAML Ain't Markup Language"); + /// final node = doc.parseAt(['YAML']); + /// + /// print(node.value); // Expected output: "YAML Ain't Markup Language" + /// + /// doc.update(['YAML'], 'YAML'); + /// + /// final newNode = doc.parseAt(['YAML']); + /// + /// // Note that the value does not change + /// print(newNode.value); // "YAML" + /// print(node.value); // "YAML Ain't Markup Language" + /// ``` + YamlNode parseAt(Iterable path, {YamlNode Function()? orElse}) { + return _traverse(path, orElse: orElse); + } + + /// Sets [value] in the [path]. + /// + /// There is a subtle difference between [update] and [remove] followed by + /// an [insertIntoList], because [update] preserves comments at the same + /// level. + /// + /// Throws a [ArgumentError] if [path] is invalid. + /// + /// Throws an [AliasException] if a node on [path] is an alias or anchor. + /// + /// **Example:** (using [update]) + /// ```dart + /// final doc = YamlEditor(''' + /// - 0 + /// - 1 # comment + /// - 2 + /// '''); + /// doc.update([1], 'test'); + /// ``` + /// + /// **Expected Output:** + /// ```yaml + /// - 0 + /// - test # comment + /// - 2 + /// ``` + /// + /// **Example:** (using [remove] and [insertIntoList]) + /// ```dart + /// final doc2 = YamlEditor(''' + /// - 0 + /// - 1 # comment + /// - 2 + /// '''); + /// doc2.remove([1]); + /// doc2.insertIntoList([], 1, 'test'); + /// ``` + /// + /// **Expected Output:** + /// ```yaml + /// - 0 + /// - test + /// - 2 + /// ``` + void update(Iterable path, Object? value) { + final valueNode = wrapAsYamlNode(value); + + if (path.isEmpty) { + final start = _contents.span.start.offset; + final end = getContentSensitiveEnd(_contents); + final lineEnding = getLineEnding(_yaml); + final edit = SourceEdit( + start, end - start, yamlEncodeBlock(valueNode, 0, lineEnding)); + + return _performEdit(edit, path, valueNode); + } + + final pathAsList = path.toList(); + final collectionPath = pathAsList.take(path.length - 1); + final keyOrIndex = pathAsList.last; + final parentNode = _traverse(collectionPath, checkAlias: true); + + if (parentNode is YamlList) { + if (keyOrIndex is! int) { + throw PathError(path, path, parentNode); + } + final expected = wrapAsYamlNode( + [...parentNode.nodes]..[keyOrIndex] = valueNode, + ); + + return _performEdit(updateInList(this, parentNode, keyOrIndex, valueNode), + collectionPath, expected); + } + + if (parentNode is YamlMap) { + final expectedMap = + updatedYamlMap(parentNode, (nodes) => nodes[keyOrIndex] = valueNode); + return _performEdit(updateInMap(this, parentNode, keyOrIndex, valueNode), + collectionPath, expectedMap); + } + + throw PathError.unexpected( + path, 'Scalar $parentNode does not have key $keyOrIndex'); + } + + /// Appends [value] to the list at [path]. + /// + /// Throws a [ArgumentError] if the element at the given path is not a + /// [YamlList] or if the path is invalid. + /// + /// Throws an [AliasException] if a node on [path] is an alias or anchor. + /// + /// **Example:** + /// ```dart + /// final doc = YamlEditor('[0, 1]'); + /// doc.appendToList([], 2); // [0, 1, 2] + /// ``` + void appendToList(Iterable path, Object? value) { + final yamlList = _traverseToList(path); + + insertIntoList(path, yamlList.length, value); + } + + /// Prepends [value] to the list at [path]. + /// + /// Throws a [ArgumentError] if the element at the given path is not a + /// [YamlList] or if the path is invalid. + /// + /// Throws an [AliasException] if a node on [path] is an alias or anchor. + /// + /// **Example:** + /// ```dart + /// final doc = YamlEditor('[1, 2]'); + /// doc.prependToList([], 0); // [0, 1, 2] + /// ``` + void prependToList(Iterable path, Object? value) { + insertIntoList(path, 0, value); + } + + /// Inserts [value] into the list at [path]. + /// + /// [index] must be non-negative and no greater than the list's length. + /// + /// Throws a [ArgumentError] if the element at the given path is not a + /// [YamlList] or if the path is invalid. + /// + /// Throws an [AliasException] if a node on [path] is an alias or anchor. + /// + /// **Example:** + /// ```dart + /// final doc = YamlEditor('[0, 2]'); + /// doc.insertIntoList([], 1, 1); // [0, 1, 2] + /// ``` + void insertIntoList(Iterable path, int index, Object? value) { + final valueNode = wrapAsYamlNode(value); + + final list = _traverseToList(path, checkAlias: true); + RangeError.checkValueInInterval(index, 0, list.length); + + final edit = insertInList(this, list, index, valueNode); + final expected = wrapAsYamlNode( + [...list.nodes]..insert(index, valueNode), + ); + + _performEdit(edit, path, expected); + } + + /// Changes the contents of the list at [path] by removing [deleteCount] + /// items at [index], and inserting [values] in-place. Returns the elements + /// that are deleted. + /// + /// [index] and [deleteCount] must be non-negative and [index] + [deleteCount] + /// must be no greater than the list's length. + /// + /// Throws a [ArgumentError] if the element at the given path is not a + /// [YamlList] or if the path is invalid. + /// + /// Throws an [AliasException] if a node on [path] is an alias or anchor. + /// + /// **Example:** + /// ```dart + /// final doc = YamlEditor('[Jan, March, April, June]'); + /// doc.spliceList([], 1, 0, ['Feb']); // [Jan, Feb, March, April, June] + /// doc.spliceList([], 4, 1, ['May']); // [Jan, Feb, March, April, May] + /// ``` + Iterable spliceList(Iterable path, int index, + int deleteCount, Iterable values) { + final list = _traverseToList(path, checkAlias: true); + + RangeError.checkValueInInterval(index, 0, list.length); + RangeError.checkValueInInterval(index + deleteCount, 0, list.length); + + final nodesToRemove = list.nodes.getRange(index, index + deleteCount); + + // Perform addition of elements before removal to avoid scenarios where + // a block list gets emptied out to {} to avoid changing collection styles + // where possible. + + // Reverse [values] and insert them. + final reversedValues = values.toList().reversed; + for (final value in reversedValues) { + insertIntoList(path, index, value); + } + + for (var i = 0; i < deleteCount; i++) { + remove([...path, index + values.length]); + } + + return nodesToRemove; + } + + /// Removes the node at [path]. Comments "belonging" to the node will be + /// removed while surrounding comments will be left untouched. + /// + /// Throws an [ArgumentError] if [path] is invalid. + /// + /// Throws an [AliasException] if a node on [path] is an alias or anchor. + /// + /// **Example:** + /// ```dart + /// final doc = YamlEditor(''' + /// - 0 # comment 0 + /// # comment A + /// - 1 # comment 1 + /// # comment B + /// - 2 # comment 2 + /// '''); + /// doc.remove([1]); + /// ``` + /// + /// **Expected Result:** + /// ```dart + /// ''' + /// - 0 # comment 0 + /// # comment A + /// # comment B + /// - 2 # comment 2 + /// ''' + /// ``` + YamlNode remove(Iterable path) { + late SourceEdit edit; + late YamlNode expectedNode; + final nodeToRemove = _traverse(path, checkAlias: true); + + if (path.isEmpty) { + edit = SourceEdit(0, _yaml.length, ''); + expectedNode = wrapAsYamlNode(null); + + /// Parsing an empty YAML document returns YamlScalar with value `null`. + _performEdit(edit, path, expectedNode); + return nodeToRemove; + } + + final pathAsList = path.toList(); + final collectionPath = pathAsList.take(path.length - 1); + final keyOrIndex = pathAsList.last; + final parentNode = _traverse(collectionPath); + + if (parentNode is YamlList) { + edit = removeInList(this, parentNode, keyOrIndex as int); + expectedNode = wrapAsYamlNode( + [...parentNode.nodes]..removeAt(keyOrIndex), + ); + } else if (parentNode is YamlMap) { + edit = removeInMap(this, parentNode, keyOrIndex); + + expectedNode = + updatedYamlMap(parentNode, (nodes) => nodes.remove(keyOrIndex)); + } + + _performEdit(edit, collectionPath, expectedNode); + + return nodeToRemove; + } + + /// Traverses down [path] to return the [YamlNode] at [path] if successful. + /// + /// If no [YamlNode]s exist at [path], the result of invoking the [orElse] + /// function is returned. + /// + /// If [orElse] is omitted, it defaults to throwing a [PathError]. + /// + /// If [checkAlias] is `true`, throw [AliasException] if an aliased node is + /// encountered. + YamlNode _traverse(Iterable path, + {bool checkAlias = false, YamlNode Function()? orElse}) { + if (path.isEmpty) return _contents; + + var currentNode = _contents; + final pathList = path.toList(); + + for (var i = 0; i < pathList.length; i++) { + final keyOrIndex = pathList[i]; + + if (checkAlias && _aliases.contains(currentNode)) { + throw AliasException(path, currentNode); + } + + if (currentNode is YamlList) { + final list = currentNode; + if (!isValidIndex(keyOrIndex, list.length)) { + return _pathErrorOrElse(path, path.take(i + 1), list, orElse); + } + + currentNode = list.nodes[keyOrIndex as int]; + } else if (currentNode is YamlMap) { + final map = currentNode; + + if (!containsKey(map, keyOrIndex)) { + return _pathErrorOrElse(path, path.take(i + 1), map, orElse); + } + final keyNode = getKeyNode(map, keyOrIndex); + + if (checkAlias) { + if (_aliases.contains(keyNode)) throw AliasException(path, keyNode); + } + + currentNode = map.nodes[keyNode]!; + } else { + return _pathErrorOrElse(path, path.take(i + 1), currentNode, orElse); + } + } + + if (checkAlias) _assertNoChildAlias(path, currentNode); + + return currentNode; + } + + /// Throws a [PathError] if [orElse] is not provided, returns the result + /// of invoking the [orElse] function otherwise. + YamlNode _pathErrorOrElse(Iterable path, Iterable subPath, + YamlNode parent, YamlNode Function()? orElse) { + if (orElse == null) throw PathError(path, subPath, parent); + return orElse(); + } + + /// Asserts that [node] and none its children are aliases + void _assertNoChildAlias(Iterable path, [YamlNode? node]) { + if (node == null) return _assertNoChildAlias(path, _traverse(path)); + if (_aliases.contains(node)) throw AliasException(path, node); + + if (node is YamlScalar) return; + + if (node is YamlList) { + for (var i = 0; i < node.length; i++) { + final updatedPath = [...path, i]; + _assertNoChildAlias(updatedPath, node.nodes[i]); + } + } + + if (node is YamlMap) { + final keyList = node.keys.toList(); + for (var i = 0; i < node.length; i++) { + final updatedPath = [...path, keyList[i]]; + if (_aliases.contains(keyList[i])) { + throw AliasException(path, keyList[i] as YamlNode); + } + _assertNoChildAlias(updatedPath, node.nodes[keyList[i]]); + } + } + } + + /// Traverses down the provided [path] to return the [YamlList] at [path]. + /// + /// Convenience function to ensure that a [YamlList] is returned. + /// + /// Throws [ArgumentError] if the element at the given path is not a + /// [YamlList] or if the path is invalid. If [checkAlias] is `true`, and an + /// aliased node is encountered along [path], an [AliasException] will be + /// thrown. + YamlList _traverseToList(Iterable path, {bool checkAlias = false}) { + final possibleList = _traverse(path, checkAlias: checkAlias); + + if (possibleList is YamlList) { + return possibleList; + } else { + throw PathError.unexpected( + path, 'Path $path does not point to a YamlList!'); + } + } + + /// Utility method to replace the substring of [_yaml] according to [edit]. + /// + /// When [_yaml] is modified with this method, the resulting string is parsed + /// and reloaded and traversed down [path] to ensure that the reloaded YAML + /// tree is equal to our expectations by deep equality of values. Throws an + /// [AssertionError] if the two trees do not match. + void _performEdit( + SourceEdit edit, Iterable path, YamlNode expectedNode) { + final expectedTree = _deepModify(_contents, path, [], expectedNode); + final initialYaml = _yaml; + _yaml = edit.apply(_yaml); + + try { + _initialize(); + } on YamlException { + throw createAssertionError( + 'Failed to produce valid YAML after modification.', + initialYaml, + _yaml); + } + + final actualTree = withYamlWarningCallback(() => loadYamlNode(_yaml)); + if (!deepEquals(actualTree, expectedTree)) { + throw createAssertionError( + 'Modification did not result in expected result.', + initialYaml, + _yaml); + } + + _contents = actualTree; + _edits.add(edit); + } + + /// Utility method to produce an updated YAML tree equivalent to converting + /// the [YamlNode] at [path] to be [expectedNode]. [subPath] holds the portion + /// of [path] that has been traversed thus far. + /// + /// Throws a [PathError] if path is invalid. + /// + /// When called, it creates a new [YamlNode] of the same type as [tree], and + /// copies its children over, except for the child that is on the path. Doing + /// so allows us to "update" the immutable [YamlNode] without having to clone + /// the whole tree. + /// + /// [SourceSpan]s in this new tree are not guaranteed to be accurate. + YamlNode _deepModify(YamlNode tree, Iterable path, + Iterable subPath, YamlNode expectedNode) { + RangeError.checkValueInInterval(subPath.length, 0, path.length); + + if (path.length == subPath.length) return expectedNode; + + final keyOrIndex = path.elementAt(subPath.length); + + if (tree is YamlList) { + if (!isValidIndex(keyOrIndex, tree.length)) { + throw PathError(path, subPath, tree); + } + + return wrapAsYamlNode([...tree.nodes]..[keyOrIndex as int] = _deepModify( + tree.nodes[keyOrIndex], + path, + path.take(subPath.length + 1), + expectedNode)); + } + + if (tree is YamlMap) { + return updatedYamlMap( + tree, + (nodes) => nodes[keyOrIndex] = _deepModify( + nodes[keyOrIndex] as YamlNode, + path, + path.take(subPath.length + 1), + expectedNode)); + } + + /// Should not ever reach here. + throw PathError(path, subPath, tree); + } +} diff --git a/pkgs/yaml_edit/lib/src/equality.dart b/pkgs/yaml_edit/lib/src/equality.dart new file mode 100644 index 000000000..0c6a9526a --- /dev/null +++ b/pkgs/yaml_edit/lib/src/equality.dart @@ -0,0 +1,116 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:collection'; + +import 'package:collection/collection.dart'; +import 'package:yaml/yaml.dart'; + +/// Creates a map that uses our custom [deepEquals] and [deepHashCode] functions +/// to determine equality. +Map deepEqualsMap() => + LinkedHashMap(equals: deepEquals, hashCode: deepHashCode); + +/// Compares two [Object]s for deep equality. This implementation differs from +/// `package:yaml`'s deep equality notation by allowing for comparison of +/// non-scalar map keys. +bool deepEquals(dynamic obj1, dynamic obj2) { + if (obj1 is YamlNode) obj1 = obj1.value; + if (obj2 is YamlNode) obj2 = obj2.value; + + if (obj1 is Map && obj2 is Map) { + return mapDeepEquals(obj1, obj2); + } + + if (obj1 is List && obj2 is List) { + return listDeepEquals(obj1, obj2); + } + + return obj1 == obj2; +} + +/// Compares two [List]s for deep equality. +bool listDeepEquals(List list1, List list2) { + if (list1.length != list2.length) return false; + + if (list1 is YamlList) list1 = list1.nodes; + if (list2 is YamlList) list2 = list2.nodes; + + for (var i = 0; i < list1.length; i++) { + if (!deepEquals(list1[i], list2[i])) { + return false; + } + } + + return true; +} + +/// Compares two [Map]s for deep equality. Differs from `package:yaml`'s deep +/// equality notation by allowing for comparison of non-scalar map keys. +bool mapDeepEquals(Map map1, Map map2) { + if (map1.length != map2.length) return false; + + if (map1 is YamlList) map1 = (map1 as YamlMap).nodes; + if (map2 is YamlList) map2 = (map2 as YamlMap).nodes; + + return map1.keys.every((key) { + if (!containsKey(map2, key)) return false; + + /// Because two keys may be equal by deep equality but using one key on the + /// other map might not get a hit since they may not be both using our + /// [deepEqualsMap]. + final key2 = getKey(map2, key); + + if (!deepEquals(map1[key], map2[key2])) { + return false; + } + + return true; + }); +} + +/// Returns a hashcode for [value] such that structures that are equal by +/// [deepEquals] will have the same hash code. +int deepHashCode(Object? value) { + if (value is Map) { + const equality = UnorderedIterableEquality(); + return equality.hash(value.keys.map(deepHashCode)) ^ + equality.hash(value.values.map(deepHashCode)); + } else if (value is Iterable) { + return const IterableEquality().hash(value.map(deepHashCode)); + } else if (value is YamlScalar) { + return (value.value as Object?).hashCode; + } + + return value.hashCode; +} + +/// Returns the [YamlNode] corresponding to the provided [key]. +YamlNode getKeyNode(YamlMap map, Object? key) { + return map.nodes.keys.firstWhere((node) => deepEquals(node, key)) as YamlNode; +} + +/// Returns the [YamlNode] after the [YamlNode] corresponding to the provided +/// [key]. +YamlNode? getNextKeyNode(YamlMap map, Object? key) { + final keyIterator = map.nodes.keys.iterator; + while (keyIterator.moveNext()) { + if (deepEquals(keyIterator.current, key) && keyIterator.moveNext()) { + return keyIterator.current as YamlNode?; + } + } + + return null; +} + +/// Returns the key in [map] that is equal to the provided [key] by the notion +/// of deep equality. +Object? getKey(Map map, Object? key) { + return map.keys.firstWhere((k) => deepEquals(k, key)); +} + +/// Checks if [map] has any keys equal to the provided [key] by deep equality. +bool containsKey(Map map, Object? key) { + return map.keys.where((node) => deepEquals(node, key)).isNotEmpty; +} diff --git a/pkgs/yaml_edit/lib/src/errors.dart b/pkgs/yaml_edit/lib/src/errors.dart new file mode 100644 index 000000000..0e60dd885 --- /dev/null +++ b/pkgs/yaml_edit/lib/src/errors.dart @@ -0,0 +1,99 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:meta/meta.dart'; +import 'package:yaml/yaml.dart'; + +/// Error thrown when a function is passed an invalid path. +@sealed +class PathError extends ArgumentError { + /// The full path that caused the error + final Iterable path; + + /// The subpath that caused the error + final Iterable subPath; + + /// The last element of [path] that could be traversed. + YamlNode? parent; + + PathError(this.path, this.subPath, this.parent, [String? message]) + : super.value(subPath, 'path', message); + + PathError.unexpected(this.path, String message) + : subPath = path, + super(message); + + @override + String toString() { + if (message == null) { + var errorMessage = 'Failed to traverse to subpath $subPath!'; + + if (subPath.isNotEmpty) { + errorMessage += + ' Parent $parent does not contain key or index ${subPath.last}'; + } + + return 'Invalid path: $path. $errorMessage.'; + } + + return 'Invalid path: $path. $message'; + } +} + +/// Exception thrown when the path contains an alias along the way. +/// +/// When a path contains an aliased node, the behavior becomes less well-defined +/// because we cannot be certain if the user wishes for the change to propagate +/// throughout all the other aliased nodes, or if the user wishes for only that +/// particular node to be modified. As such, [AliasException] reflects the +/// detection that our change will impact an alias, and we do not intend on +/// supporting such changes for the foreseeable future. +@sealed +class AliasException extends FormatException { + /// The path that caused the error + final Iterable path; + + /// The anchor node of the alias + final YamlNode anchor; + + AliasException(this.path, this.anchor) + : super('Encountered an alias node along $path! ' + 'Alias nodes are nodes that refer to a previously serialized ' + 'nodes, and are denoted by either the "*" or the "&" indicators in ' + 'the original YAML. As the resulting behavior of mutations on ' + 'these nodes is not well-defined, the operation will not be ' + 'supported by this library.\n\n' + '${anchor.span.message('The alias was first defined here.')}'); +} + +/// Error thrown when an assertion about the YAML fails. Extends +/// [AssertionError] to override the [toString] method for pretty printing. +class _YamlAssertionError extends AssertionError { + _YamlAssertionError(super.message); + + @override + String toString() { + if (message != null) { + return 'Assertion failed: $message'; + } + return 'Assertion failed'; + } +} + +/// Throws an [AssertionError] with the given [message], and format +/// [oldYaml] and [newYaml] for information. +Error createAssertionError(String message, String oldYaml, String newYaml) { + return _YamlAssertionError(''' +(package:yaml_edit) $message + +# YAML before edit: +> ${oldYaml.replaceAll('\n', '\n> ')} + +# YAML after edit: +> ${newYaml.replaceAll('\n', '\n> ')} + +Please file an issue at: +https://github.com/dart-lang/yaml_edit/issues/new?labels=bug +'''); +} diff --git a/pkgs/yaml_edit/lib/src/list_mutations.dart b/pkgs/yaml_edit/lib/src/list_mutations.dart new file mode 100644 index 000000000..17da6dd77 --- /dev/null +++ b/pkgs/yaml_edit/lib/src/list_mutations.dart @@ -0,0 +1,403 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:yaml/yaml.dart'; + +import 'editor.dart'; +import 'source_edit.dart'; +import 'strings.dart'; +import 'utils.dart'; +import 'wrap.dart'; + +/// Returns a [SourceEdit] describing the change to be made on [yamlEdit] to +/// achieve the effect of setting the element at [index] to [newValue] when +/// re-parsed. +SourceEdit updateInList( + YamlEditor yamlEdit, YamlList list, int index, YamlNode newValue) { + RangeError.checkValueInInterval(index, 0, list.length - 1); + + final currValue = list.nodes[index]; + var offset = currValue.span.start.offset; + final yaml = yamlEdit.toString(); + String valueString; + + /// We do not use [_formatNewBlock] since we want to only replace the contents + /// of this node while preserving comments/whitespace, while [_formatNewBlock] + /// produces a string representation of a new node. + if (list.style == CollectionStyle.BLOCK) { + final listIndentation = getListIndentation(yaml, list); + final indentation = listIndentation + getIndentation(yamlEdit); + final lineEnding = getLineEnding(yaml); + valueString = + yamlEncodeBlock(wrapAsYamlNode(newValue), indentation, lineEnding); + + /// We prefer the compact nested notation for collections. + /// + /// By virtue of [yamlEncodeBlockString], collections automatically + /// have the necessary line endings. + if ((newValue is List && (newValue as List).isNotEmpty) || + (newValue is Map && (newValue as Map).isNotEmpty)) { + valueString = valueString.substring(indentation); + } else if (currValue.collectionStyle == CollectionStyle.BLOCK) { + valueString += lineEnding; + } + + var end = getContentSensitiveEnd(currValue); + if (end <= offset) { + offset++; + end = offset; + valueString = ' $valueString'; + } + + return SourceEdit(offset, end - offset, valueString); + } else { + valueString = yamlEncodeFlow(newValue); + return SourceEdit(offset, currValue.span.length, valueString); + } +} + +/// Returns a [SourceEdit] describing the change to be made on [yamlEdit] to +/// achieve the effect of appending [item] to the list. +SourceEdit appendIntoList(YamlEditor yamlEdit, YamlList list, YamlNode item) { + if (list.style == CollectionStyle.FLOW) { + return _appendToFlowList(yamlEdit, list, item); + } else { + return _appendToBlockList(yamlEdit, list, item); + } +} + +/// Returns a [SourceEdit] describing the change to be made on [yamlEdit] to +/// achieve the effect of inserting [item] to the list at [index]. +SourceEdit insertInList( + YamlEditor yamlEdit, YamlList list, int index, YamlNode item) { + RangeError.checkValueInInterval(index, 0, list.length); + + /// We call the append method if the user wants to append it to the end of the + /// list because appending requires different techniques. + if (index == list.length) { + return appendIntoList(yamlEdit, list, item); + } else { + if (list.style == CollectionStyle.FLOW) { + return _insertInFlowList(yamlEdit, list, index, item); + } else { + return _insertInBlockList(yamlEdit, list, index, item); + } + } +} + +/// Returns a [SourceEdit] describing the change to be made on [yamlEdit] to +/// achieve the effect of removing the element at [index] when re-parsed. +SourceEdit removeInList(YamlEditor yamlEdit, YamlList list, int index) { + final nodeToRemove = list.nodes[index]; + + if (list.style == CollectionStyle.FLOW) { + return _removeFromFlowList(yamlEdit, list, nodeToRemove, index); + } else { + return _removeFromBlockList(yamlEdit, list, nodeToRemove, index); + } +} + +/// Returns a [SourceEdit] describing the change to be made on [yamlEdit] to +/// achieve the effect of addition [item] into [list], noting that this is a +/// flow list. +SourceEdit _appendToFlowList( + YamlEditor yamlEdit, YamlList list, YamlNode item) { + final valueString = _formatNewFlow(list, item, true); + return SourceEdit(list.span.end.offset - 1, 0, valueString); +} + +/// Returns a [SourceEdit] describing the change to be made on [yamlEdit] to +/// achieve the effect of addition [item] into [list], noting that this is a +/// block list. +SourceEdit _appendToBlockList( + YamlEditor yamlEdit, YamlList list, YamlNode item) { + var (indentSize, valueToIndent) = _formatNewBlock(yamlEdit, list, item); + var formattedValue = '${' ' * indentSize}$valueToIndent'; + + final yaml = yamlEdit.toString(); + var offset = list.span.end.offset; + + // Adjusts offset to after the trailing newline of the last entry, if it + // exists + if (list.isNotEmpty) { + final lastValueSpanEnd = list.nodes.last.span.end.offset; + final nextNewLineIndex = yaml.indexOf('\n', lastValueSpanEnd - 1); + if (nextNewLineIndex == -1) { + formattedValue = getLineEnding(yaml) + formattedValue; + } else { + offset = nextNewLineIndex + 1; + } + } + + return SourceEdit(offset, 0, formattedValue); +} + +/// Formats [item] into a new node for block lists. +(int indentSize, String valueStringToIndent) _formatNewBlock( + YamlEditor yamlEdit, YamlList list, YamlNode item) { + final yaml = yamlEdit.toString(); + final listIndentation = getListIndentation(yaml, list); + final newIndentation = listIndentation + getIndentation(yamlEdit); + final lineEnding = getLineEnding(yaml); + + var valueString = yamlEncodeBlock(item, newIndentation, lineEnding); + if (isCollection(item) && !isFlowYamlCollectionNode(item) && !isEmpty(item)) { + valueString = valueString.substring(newIndentation); + } + + return (listIndentation, '- $valueString$lineEnding'); +} + +/// Formats [item] into a new node for flow lists. +String _formatNewFlow(YamlList list, YamlNode item, [bool isLast = false]) { + var valueString = yamlEncodeFlow(item); + if (list.isNotEmpty) { + if (isLast) { + valueString = ', $valueString'; + } else { + valueString += ', '; + } + } + + return valueString; +} + +/// Returns a [SourceEdit] describing the change to be made on [yamlEdit] to +/// achieve the effect of inserting [item] into [list] at [index], noting that +/// this is a block list. +/// +/// [index] should be non-negative and less than or equal to `list.length`. +SourceEdit _insertInBlockList( + YamlEditor yamlEdit, YamlList list, int index, YamlNode item) { + RangeError.checkValueInInterval(index, 0, list.length); + + if (index == list.length) return _appendToBlockList(yamlEdit, list, item); + + var (indentSize, formattedValue) = _formatNewBlock(yamlEdit, list, item); + + final currNode = list.nodes[index]; + final currNodeStart = currNode.span.start.offset; + final yaml = yamlEdit.toString(); + + final currSequenceOffset = yaml.lastIndexOf('-', currNodeStart - 1); + + final (isNested, offset) = _isNestedInBlockList(currSequenceOffset, yaml); + + /// We have to get rid of the left indentation applied by default + if (isNested && index == 0) { + /// The [insertionIndex] will be equal to the start of + /// [currentSequenceOffset] of the element we are inserting before in most + /// cases. + /// + /// Example: + /// + /// - - value + /// ^ Inserting before this and we get rid of indent + /// + /// If not, we need to account for the space between them that is not an + /// indent. + /// + /// Example: + /// + /// - - value + /// ^ Inserting before this and we get rid of indent. But also account + /// for space in between + final leftPad = currSequenceOffset - offset; + final padding = ' ' * leftPad; + + final indent = ' ' * (indentSize - leftPad); + + // Give the indent to the first element + formattedValue = '$padding${formattedValue.trimLeft()}$indent'; + } else { + final indent = ' ' * indentSize; // Calculate indent normally + formattedValue = '$indent$formattedValue'; + } + + return SourceEdit(offset, 0, formattedValue); +} + +/// Determines if the list containing an element is nested within another list. +/// The [currentSequenceOffset] indicates the index of the element's `-` and +/// [yaml] represents the entire yaml document. +/// +/// ```yaml +/// # Returns true +/// - - value +/// +/// # Returns true +/// - - value +/// +/// # Returns false +/// key: +/// - value +/// +/// # Returns false. Even though nested, a "\n" precedes the previous "-" +/// - +/// - value +/// ``` +(bool isNested, int offset) _isNestedInBlockList( + int currentSequenceOffset, String yaml) { + final startIndex = currentSequenceOffset - 1; + + /// Indicates the element we are inserting before is at index `0` of the list + /// at the root of the yaml + /// + /// Example: + /// + /// - foo + /// ^ Inserting before this + if (startIndex < 0) return (false, 0); + + final newLineStart = yaml.lastIndexOf('\n', startIndex); + final seqStart = yaml.lastIndexOf('-', startIndex); + + /// Indicates that a `\n` is closer to the last `-`. Meaning this list is not + /// nested. + /// + /// Example: + /// + /// key: + /// - value + /// ^ Inserting before this and we need to keep the indent. + /// + /// Also this list may be nested but the nested list starts its indent after + /// a new line. + /// + /// Example: + /// + /// - + /// - value + /// ^ Inserting before this and we need to keep the indent. + if (newLineStart >= seqStart) { + return (false, newLineStart + 1); + } + + return (true, seqStart + 2); // Inclusive of space +} + +/// Returns a [SourceEdit] describing the change to be made on [yamlEdit] to +/// achieve the effect of inserting [item] into [list] at [index], noting that +/// this is a flow list. +/// +/// [index] should be non-negative and less than or equal to `list.length`. +SourceEdit _insertInFlowList( + YamlEditor yamlEdit, YamlList list, int index, YamlNode item) { + RangeError.checkValueInInterval(index, 0, list.length); + + if (index == list.length) return _appendToFlowList(yamlEdit, list, item); + + final formattedValue = _formatNewFlow(list, item); + + final yaml = yamlEdit.toString(); + final currNode = list.nodes[index]; + final currNodeStart = currNode.span.start.offset; + var start = yaml.lastIndexOf(RegExp(r',|\['), currNodeStart - 1) + 1; + if (yaml[start] == ' ') start++; + + return SourceEdit(start, 0, formattedValue); +} + +/// Returns a [SourceEdit] describing the change to be made on [yamlEdit] to +/// achieve the effect of removing [nodeToRemove] from [list], noting that this +/// is a block list. +/// +/// [index] should be non-negative and less than or equal to `list.length`. +SourceEdit _removeFromBlockList( + YamlEditor yamlEdit, YamlList list, YamlNode nodeToRemove, int index) { + RangeError.checkValueInInterval(index, 0, list.length - 1); + + var end = getContentSensitiveEnd(nodeToRemove); + + /// If we are removing the last element in a block list, convert it into a + /// flow empty list. + if (list.length == 1) { + final start = list.span.start.offset; + + return SourceEdit(start, end - start, '[]'); + } + + final yaml = yamlEdit.toString(); + final span = nodeToRemove.span; + + /// Adjust the end to clear the new line after the end too. + /// + /// We do this because we suspect that our users will want the inline + /// comments to disappear too. + final nextNewLine = yaml.indexOf('\n', end); + if (nextNewLine != -1) { + end = nextNewLine + 1; + } + + /// If the value is empty + if (span.length == 0) { + var start = span.start.offset; + return SourceEdit(start, end - start, ''); + } + + /// -1 accounts for the fact that the content can start with a dash + var start = yaml.lastIndexOf('-', span.start.offset - 1); + + /// Check if there is a `-` before the node + if (start > 0) { + final lastHyphen = yaml.lastIndexOf('-', start - 1); + final lastNewLine = yaml.lastIndexOf('\n', start - 1); + if (lastHyphen > lastNewLine) { + start = lastHyphen + 2; + + /// If there is a `-` before the node, we need to check if we have + /// to update the indentation of the next node. + if (index < list.length - 1) { + /// Since [end] is currently set to the next new line after the current + /// node, check if we see a possible comment first, or a hyphen first. + /// Note that no actual content can appear here. + /// + /// We check this way because the start of a span in a block list is + /// the start of its value, and checking from the back leaves us + /// easily confused if there are comments that have dashes in them. + final nextHash = yaml.indexOf('#', end); + final nextHyphen = yaml.indexOf('-', end); + final nextNewLine = yaml.indexOf('\n', end); + + /// If [end] is on the same line as the hyphen of the next node + if ((nextHash == -1 || nextHyphen < nextHash) && + nextHyphen < nextNewLine) { + end = nextHyphen; + } + } + } else if (lastNewLine > lastHyphen) { + start = lastNewLine + 1; + } + } + + return SourceEdit(start, end - start, ''); +} + +/// Returns a [SourceEdit] describing the change to be made on [yamlEdit] to +/// achieve the effect of removing [nodeToRemove] from [list], noting that this +/// is a flow list. +/// +/// [index] should be non-negative and less than or equal to `list.length`. +SourceEdit _removeFromFlowList( + YamlEditor yamlEdit, YamlList list, YamlNode nodeToRemove, int index) { + RangeError.checkValueInInterval(index, 0, list.length - 1); + + final span = nodeToRemove.span; + final yaml = yamlEdit.toString(); + var start = span.start.offset; + var end = span.end.offset; + + if (index == 0) { + start = yaml.lastIndexOf('[', start - 1) + 1; + if (index == list.length - 1) { + end = yaml.indexOf(']', end); + } else { + end = yaml.indexOf(',', end) + 1; + } + } else { + start = yaml.lastIndexOf(',', start - 1); + } + + return SourceEdit(start, end - start, ''); +} diff --git a/pkgs/yaml_edit/lib/src/map_mutations.dart b/pkgs/yaml_edit/lib/src/map_mutations.dart new file mode 100644 index 000000000..46e8c7935 --- /dev/null +++ b/pkgs/yaml_edit/lib/src/map_mutations.dart @@ -0,0 +1,257 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:yaml/yaml.dart'; + +import 'editor.dart'; +import 'equality.dart'; +import 'source_edit.dart'; +import 'strings.dart'; +import 'utils.dart'; +import 'wrap.dart'; + +/// Performs the string operation on [yamlEdit] to achieve the effect of setting +/// the element at [key] to [newValue] when re-parsed. +SourceEdit updateInMap( + YamlEditor yamlEdit, YamlMap map, Object? key, YamlNode newValue) { + if (!containsKey(map, key)) { + final keyNode = wrapAsYamlNode(key); + + if (map.style == CollectionStyle.FLOW) { + return _addToFlowMap(yamlEdit, map, keyNode, newValue); + } else { + return _addToBlockMap(yamlEdit, map, keyNode, newValue); + } + } else { + if (map.style == CollectionStyle.FLOW) { + return _replaceInFlowMap(yamlEdit, map, key, newValue); + } else { + return _replaceInBlockMap(yamlEdit, map, key, newValue); + } + } +} + +/// Performs the string operation on [yamlEdit] to achieve the effect of +/// removing the element at [key] when re-parsed. +SourceEdit removeInMap(YamlEditor yamlEdit, YamlMap map, Object? key) { + assert(containsKey(map, key)); + final keyNode = getKeyNode(map, key); + final valueNode = map.nodes[keyNode]!; + + if (map.style == CollectionStyle.FLOW) { + return _removeFromFlowMap(yamlEdit, map, keyNode, valueNode); + } else { + return _removeFromBlockMap(yamlEdit, map, keyNode, valueNode); + } +} + +/// Performs the string operation on [yamlEdit] to achieve the effect of adding +/// the [key]:[newValue] pair when reparsed, bearing in mind that this is a +/// block map. +SourceEdit _addToBlockMap( + YamlEditor yamlEdit, YamlMap map, Object key, YamlNode newValue) { + final yaml = yamlEdit.toString(); + final newIndentation = + getMapIndentation(yaml, map) + getIndentation(yamlEdit); + final keyString = yamlEncodeFlow(wrapAsYamlNode(key)); + final lineEnding = getLineEnding(yaml); + + var formattedValue = ' ' * getMapIndentation(yaml, map); + var offset = map.span.end.offset; + + final insertionIndex = getMapInsertionIndex(map, keyString); + + if (map.isNotEmpty) { + /// Adjusts offset to after the trailing newline of the last entry, if it + /// exists + if (insertionIndex == map.length) { + final lastValueSpanEnd = getContentSensitiveEnd(map.nodes.values.last); + final nextNewLineIndex = yaml.indexOf('\n', lastValueSpanEnd); + + if (nextNewLineIndex != -1) { + offset = nextNewLineIndex + 1; + } else { + formattedValue = lineEnding + formattedValue; + } + } else { + final keyAtIndex = map.nodes.keys.toList()[insertionIndex] as YamlNode; + final keySpanStart = keyAtIndex.span.start.offset; + final prevNewLineIndex = yaml.lastIndexOf('\n', keySpanStart); + + offset = prevNewLineIndex + 1; + } + } + + var valueString = yamlEncodeBlock(newValue, newIndentation, lineEnding); + if (isCollection(newValue) && + !isFlowYamlCollectionNode(newValue) && + !isEmpty(newValue)) { + formattedValue += '$keyString:$lineEnding$valueString$lineEnding'; + } else { + formattedValue += '$keyString: $valueString$lineEnding'; + } + + return SourceEdit(offset, 0, formattedValue); +} + +/// Performs the string operation on [yamlEdit] to achieve the effect of adding +/// the [keyNode]:[newValue] pair when reparsed, bearing in mind that this is a +/// flow map. +SourceEdit _addToFlowMap( + YamlEditor yamlEdit, YamlMap map, YamlNode keyNode, YamlNode newValue) { + final keyString = yamlEncodeFlow(keyNode); + final valueString = yamlEncodeFlow(newValue); + + // The -1 accounts for the closing bracket. + if (map.isEmpty) { + return SourceEdit(map.span.end.offset - 1, 0, '$keyString: $valueString'); + } + + final insertionIndex = getMapInsertionIndex(map, keyString); + + if (insertionIndex == map.length) { + return SourceEdit(map.span.end.offset - 1, 0, ', $keyString: $valueString'); + } + + final insertionOffset = + (map.nodes.keys.toList()[insertionIndex] as YamlNode).span.start.offset; + + return SourceEdit(insertionOffset, 0, '$keyString: $valueString, '); +} + +/// Performs the string operation on [yamlEdit] to achieve the effect of +/// replacing the value at [key] with [newValue] when reparsed, bearing in mind +/// that this is a block map. +SourceEdit _replaceInBlockMap( + YamlEditor yamlEdit, YamlMap map, Object? key, YamlNode newValue) { + final yaml = yamlEdit.toString(); + final lineEnding = getLineEnding(yaml); + final newIndentation = + getMapIndentation(yaml, map) + getIndentation(yamlEdit); + + final keyNode = getKeyNode(map, key); + var valueAsString = + yamlEncodeBlock(wrapAsYamlNode(newValue), newIndentation, lineEnding); + if (isCollection(newValue) && + !isFlowYamlCollectionNode(newValue) && + !isEmpty(newValue)) { + valueAsString = lineEnding + valueAsString; + } + + if (!valueAsString.startsWith(lineEnding)) { + // prepend whitespace to ensure there is space after colon. + valueAsString = ' $valueAsString'; + } + + /// +1 accounts for the colon + // TODO: What if here is a whitespace following the key, before the colon? + final start = keyNode.span.end.offset + 1; + var end = getContentSensitiveEnd(map.nodes[key]!); + + /// `package:yaml` parses empty nodes in a way where the start/end of the + /// empty value node is the end of the key node, so we have to adjust for + /// this. + if (end < start) end = start; + + return SourceEdit(start, end - start, valueAsString); +} + +/// Performs the string operation on [yamlEdit] to achieve the effect of +/// replacing the value at [key] with [newValue] when reparsed, bearing in mind +/// that this is a flow map. +SourceEdit _replaceInFlowMap( + YamlEditor yamlEdit, YamlMap map, Object? key, YamlNode newValue) { + final valueSpan = map.nodes[key]!.span; + final valueString = yamlEncodeFlow(newValue); + + return SourceEdit(valueSpan.start.offset, valueSpan.length, valueString); +} + +/// Performs the string operation on [yamlEdit] to achieve the effect of +/// removing the [keyNode] from the map, bearing in mind that this is a block +/// map. +SourceEdit _removeFromBlockMap( + YamlEditor yamlEdit, YamlMap map, YamlNode keyNode, YamlNode valueNode) { + final keySpan = keyNode.span; + var end = getContentSensitiveEnd(valueNode); + final yaml = yamlEdit.toString(); + final lineEnding = getLineEnding(yaml); + + if (map.length == 1) { + final start = map.span.start.offset; + final nextNewLine = yaml.indexOf(lineEnding, end); + if (nextNewLine != -1) { + // Remove everything up to the next newline, this strips comments that + // follows on the same line as the value we're removing. + // It also ensures we consume colon when [valueNode.value] is `null` + // because there is no value (e.g. `key: \n`). Because [valueNode.span] in + // such cases point to the colon `:`. + end = nextNewLine; + } else { + // Remove everything until the end of the document, if there is no newline + end = yaml.length; + } + return SourceEdit(start, end - start, '{}'); + } + + var start = keySpan.start.offset; + + /// Adjust the end to clear the new line after the end too. + /// + /// We do this because we suspect that our users will want the inline + /// comments to disappear too. + final nextNewLine = yaml.indexOf(lineEnding, end); + if (nextNewLine != -1) { + end = nextNewLine + lineEnding.length; + } else { + // Remove everything until the end of the document, if there is no newline + end = yaml.length; + } + + final nextNode = getNextKeyNode(map, keyNode); + + if (start > 0) { + final lastHyphen = yaml.lastIndexOf('-', start - 1); + final lastNewLine = yaml.lastIndexOf(lineEnding, start - 1); + if (lastHyphen > lastNewLine) { + start = lastHyphen + 2; + + /// If there is a `-` before the node, and the end is on the same line + /// as the next node, we need to add the necessary offset to the end to + /// make sure the next node has the correct indentation. + if (nextNode != null && + nextNode.span.start.offset - end <= nextNode.span.start.column) { + end += nextNode.span.start.column; + } + } else if (lastNewLine > lastHyphen) { + start = lastNewLine + lineEnding.length; + } + } + + return SourceEdit(start, end - start, ''); +} + +/// Performs the string operation on [yamlEdit] to achieve the effect of +/// removing the [keyNode] from the map, bearing in mind that this is a flow +/// map. +SourceEdit _removeFromFlowMap( + YamlEditor yamlEdit, YamlMap map, YamlNode keyNode, YamlNode valueNode) { + var start = keyNode.span.start.offset; + var end = valueNode.span.end.offset; + final yaml = yamlEdit.toString(); + + if (deepEquals(keyNode, map.keys.first)) { + start = yaml.lastIndexOf('{', start - 1) + 1; + + if (deepEquals(keyNode, map.keys.last)) { + end = yaml.indexOf('}', end); + } else { + end = yaml.indexOf(',', end) + 1; + } + } else { + start = yaml.lastIndexOf(',', start - 1); + } + + return SourceEdit(start, end - start, ''); +} diff --git a/pkgs/yaml_edit/lib/src/source_edit.dart b/pkgs/yaml_edit/lib/src/source_edit.dart new file mode 100644 index 000000000..d177a199e --- /dev/null +++ b/pkgs/yaml_edit/lib/src/source_edit.dart @@ -0,0 +1,133 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:meta/meta.dart'; + +/// A class representing a change on a [String], intended to be compatible with +/// `package:analysis_server`'s [SourceEdit]. +/// +/// For example, changing a string from +/// ``` +/// foo: foobar +/// ``` +/// to +/// ``` +/// foo: barbar +/// ``` +/// will be represented by +/// `SourceEdit(offset: 4, length: 3, replacement: 'bar')` +@sealed +class SourceEdit { + /// The offset from the start of the string where the modification begins. + final int offset; + + /// The length of the substring to be replaced. + final int length; + + /// The replacement string to be used. + final String replacement; + + /// Creates a new [SourceEdit] instance. [offset], [length] and [replacement] + /// must be non-null, and [offset] and [length] must be non-negative. + factory SourceEdit(int offset, int length, String replacement) => + SourceEdit._(offset, length, replacement); + + SourceEdit._(this.offset, this.length, this.replacement) { + RangeError.checkNotNegative(offset); + RangeError.checkNotNegative(length); + } + + @override + bool operator ==(Object other) { + if (other is SourceEdit) { + return offset == other.offset && + length == other.length && + replacement == other.replacement; + } + + return false; + } + + @override + int get hashCode => offset.hashCode ^ length.hashCode ^ replacement.hashCode; + + /// Constructs a SourceEdit from JSON. + /// + /// **Example:** + /// ```dart + /// final edit = { + /// 'offset': 1, + /// 'length': 2, + /// 'replacement': 'replacement string' + /// }; + /// + /// final sourceEdit = SourceEdit.fromJson(edit); + /// ``` + factory SourceEdit.fromJson(Map json) { + final offset = json['offset']; + final length = json['length']; + final replacement = json['replacement']; + + if (offset is int && length is int && replacement is String) { + return SourceEdit(offset, length, replacement); + } + + throw const FormatException('Invalid JSON passed to SourceEdit'); + } + + /// Encodes this object as JSON-compatible structure. + /// + /// **Example:** + /// ```dart + /// import 'dart:convert' show jsonEncode; + /// + /// final edit = SourceEdit(offset, length, 'replacement string'); + /// final jsonString = jsonEncode(edit.toJson()); + /// print(jsonString); + /// ``` + Map toJson() { + return {'offset': offset, 'length': length, 'replacement': replacement}; + } + + @override + String toString() => 'SourceEdit($offset, $length, "$replacement")'; + + /// Applies a series of [SourceEdit]s to an original string, and return the + /// final output. + /// + /// [edits] should be in order i.e. the first [SourceEdit] in [edits] should + /// be the first edit applied to [original]. + /// + /// **Example:** + /// ```dart + /// const original = 'YAML: YAML'; + /// final sourceEdits = [ + /// SourceEdit(6, 4, "YAML Ain't Markup Language"), + /// SourceEdit(6, 4, "YAML Ain't Markup Language"), + /// SourceEdit(0, 4, "YAML Ain't Markup Language") + /// ]; + /// final result = SourceEdit.applyAll(original, sourceEdits); + /// ``` + /// **Expected result:** + /// ```dart + /// "YAML Ain't Markup Language: YAML Ain't Markup Language Ain't Markup + /// Language" + /// ``` + static String applyAll(String original, Iterable edits) { + return edits.fold(original, (current, edit) => edit.apply(current)); + } + + /// Applies one [SourceEdit]s to an original string, and return the final + /// output. + /// + /// **Example:** + /// ```dart + /// final edit = SourceEdit(4, 3, 'bar'); + /// final originalString = 'foo: foobar'; + /// print(edit.apply(originalString)); // 'foo: barbar' + /// ``` + String apply(String original) { + return original.replaceRange(offset, offset + length, replacement); + } +} diff --git a/pkgs/yaml_edit/lib/src/strings.dart b/pkgs/yaml_edit/lib/src/strings.dart new file mode 100644 index 000000000..1b8564179 --- /dev/null +++ b/pkgs/yaml_edit/lib/src/strings.dart @@ -0,0 +1,366 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:collection/collection.dart'; +import 'package:yaml/yaml.dart'; + +import 'utils.dart'; + +/// Given [value], tries to format it into a plain string recognizable by YAML. +/// +/// Not all values can be formatted into a plain string. If the string contains +/// an escape sequence, it can only be detected when in a double-quoted +/// sequence. Plain strings may also be misinterpreted by the YAML parser (e.g. +/// ' null'). +/// +/// Returns `null` if [value] cannot be encoded as a plain string. +String? _tryYamlEncodePlain(String value) { + /// If it contains a dangerous character we want to wrap the result with + /// double quotes because the double quoted style allows for arbitrary + /// strings with "\" escape sequences. + /// + /// See 7.3.1 Double-Quoted Style + /// https://yaml.org/spec/1.2/spec.html#id2787109 + return isDangerousString(value) ? null : value; +} + +/// Checks if [string] has unprintable characters according to +/// [unprintableCharCodes]. +bool _hasUnprintableCharacters(String string) { + final codeUnits = string.codeUnits; + + for (final key in unprintableCharCodes.keys) { + if (codeUnits.contains(key)) return true; + } + + return false; +} + +/// Generates a YAML-safe double-quoted string based on [string], escaping the +/// list of characters as defined by the YAML 1.2 spec. +/// +/// See 5.7 Escaped Characters https://yaml.org/spec/1.2/spec.html#id2776092 +String _yamlEncodeDoubleQuoted(String string) { + final buffer = StringBuffer(); + for (final codeUnit in string.codeUnits) { + if (doubleQuoteEscapeChars[codeUnit] != null) { + buffer.write(doubleQuoteEscapeChars[codeUnit]); + } else { + buffer.writeCharCode(codeUnit); + } + } + + return '"$buffer"'; +} + +/// Encodes [string] as YAML single quoted string. +/// +/// Returns `null`, if the [string] can't be encoded as single-quoted string. +/// This might happen if it contains line-breaks or [_hasUnprintableCharacters]. +/// +/// See: https://yaml.org/spec/1.2.2/#732-single-quoted-style +String? _tryYamlEncodeSingleQuoted(String string) { + // If [string] contains a newline we'll use double quoted strings instead. + // Single quoted strings can represent newlines, but then we have to use an + // empty line (replace \n with \n\n). But since leading spaces following + // line breaks are ignored, we can't represent "\n ". + // Thus, if the string contains `\n` and we're asked to do single quoted, + // we'll fallback to a double quoted string. + if (_hasUnprintableCharacters(string) || string.contains('\n')) return null; + + final result = string.replaceAll('\'', '\'\''); + return '\'$result\''; +} + +/// Attempts to encode a [string] as a _YAML folded string_ and apply the +/// appropriate _chomping indicator_. +/// +/// Returns `null`, if the [string] cannot be encoded as a _YAML folded +/// string_. +/// +/// **Examples** of folded strings. +/// ```yaml +/// # With the "strip" chomping indicator +/// key: >- +/// my folded +/// string +/// +/// # With the "keep" chomping indicator +/// key: >+ +/// my folded +/// string +/// ``` +/// +/// See: https://yaml.org/spec/1.2.2/#813-folded-style +String? _tryYamlEncodeFolded(String string, int indentSize, String lineEnding) { + // A string that starts with space or newline followed by space can't be + // encoded in folded mode. + if (string.isEmpty || string.trim().length != string.length) return null; + + if (_hasUnprintableCharacters(string)) return null; + + // TODO: Are there other strings we can't encode in folded mode? + + final indent = ' ' * indentSize; + + /// Remove trailing `\n` & white-space to ease string folding + var trimmed = string.trimRight(); + final stripped = string.substring(trimmed.length); + + final trimmedSplit = + trimmed.replaceAll('\n', lineEnding + indent).split(lineEnding); + + /// Try folding to match specification: + /// * https://yaml.org/spec/1.2.2/#65-line-folding + trimmed = trimmedSplit.reduceIndexed((index, previous, current) { + var updated = current; + + /// If initially empty, this line holds only `\n` or white-space. This + /// tells us we don't need to apply an additional `\n`. + /// + /// See https://yaml.org/spec/1.2.2/#64-empty-lines + /// + /// If this line is not empty, we need to apply an additional `\n` if and + /// only if: + /// 1. The preceding line was non-empty too + /// 2. If the current line doesn't begin with white-space + /// + /// Such that we apply `\n` for `foo\nbar` but not `foo\n bar`. + if (current.trim().isNotEmpty && + trimmedSplit[index - 1].trim().isNotEmpty && + !current.replaceFirst(indent, '').startsWith(' ')) { + updated = lineEnding + updated; + } + + /// Apply a `\n` by default. + return previous + lineEnding + updated; + }); + + return '>-\n' + '$indent$trimmed' + '${stripped.replaceAll('\n', lineEnding + indent)}'; +} + +/// Attempts to encode a [string] as a _YAML literal string_ and apply the +/// appropriate _chomping indicator_. +/// +/// Returns `null`, if the [string] cannot be encoded as a _YAML literal +/// string_. +/// +/// **Examples** of literal strings. +/// ```yaml +/// # With the "strip" chomping indicator +/// key: |- +/// my literal +/// string +/// +/// # Without chomping indicator +/// key: | +/// my literal +/// string +/// ``` +/// +/// See: https://yaml.org/spec/1.2.2/#812-literal-style +String? _tryYamlEncodeLiteral( + String string, int indentSize, String lineEnding) { + if (string.isEmpty || string.trim().length != string.length) return null; + + // A string that starts with space or newline followed by space can't be + // encoded in literal mode. + if (_hasUnprintableCharacters(string)) return null; + + // TODO: Are there other strings we can't encode in literal mode? + + final indent = ' ' * indentSize; + + /// Simplest block style. + /// * https://yaml.org/spec/1.2.2/#812-literal-style + return '|-\n$indent${string.replaceAll('\n', lineEnding + indent)}'; +} + +/// Encodes a flow [YamlScalar] based on the provided [YamlScalar.style]. +/// +/// Falls back to [ScalarStyle.DOUBLE_QUOTED] if the [yamlScalar] cannot be +/// encoded with the [YamlScalar.style] or with [ScalarStyle.PLAIN] when the +/// [yamlScalar] is not a [String]. +String _yamlEncodeFlowScalar(YamlScalar yamlScalar) { + final YamlScalar(:value, :style) = yamlScalar; + + if (value is! String) { + return value.toString(); + } + + switch (style) { + /// Only encode as double-quoted if it's a string. + case ScalarStyle.DOUBLE_QUOTED: + return _yamlEncodeDoubleQuoted(value); + + case ScalarStyle.SINGLE_QUOTED: + return _tryYamlEncodeSingleQuoted(value) ?? + _yamlEncodeDoubleQuoted(value); + + /// Cast into [String] if [null] as this condition only returns [null] + /// for a [String] that can't be encoded. + default: + return _tryYamlEncodePlain(value) ?? _yamlEncodeDoubleQuoted(value); + } +} + +/// Encodes a block [YamlScalar] based on the provided [YamlScalar.style]. +/// +/// Falls back to [ScalarStyle.DOUBLE_QUOTED] if the [yamlScalar] cannot be +/// encoded with the [YamlScalar.style] provided. +String _yamlEncodeBlockScalar( + YamlScalar yamlScalar, + int indentation, + String lineEnding, +) { + final YamlScalar(:value, :style) = yamlScalar; + assertValidScalar(value); + + if (value is! String) { + return value.toString(); + } + + switch (style) { + /// Prefer 'plain', fallback to "double quoted" + case ScalarStyle.PLAIN: + return _tryYamlEncodePlain(value) ?? _yamlEncodeDoubleQuoted(value); + + // Prefer 'single quoted', fallback to "double quoted" + case ScalarStyle.SINGLE_QUOTED: + return _tryYamlEncodeSingleQuoted(value) ?? + _yamlEncodeDoubleQuoted(value); + + /// Prefer folded string, fallback to "double quoted" + case ScalarStyle.FOLDED: + return _tryYamlEncodeFolded(value, indentation, lineEnding) ?? + _yamlEncodeDoubleQuoted(value); + + /// Prefer literal string, fallback to "double quoted" + case ScalarStyle.LITERAL: + return _tryYamlEncodeLiteral(value, indentation, lineEnding) ?? + _yamlEncodeDoubleQuoted(value); + + /// Prefer plain, fallback to "double quoted" + default: + return _tryYamlEncodePlain(value) ?? _yamlEncodeDoubleQuoted(value); + } +} + +/// Returns [value] with the necessary formatting applied in a flow context. +/// +/// If [value] is a [YamlNode], we try to respect its [YamlScalar.style] +/// parameter where possible. Certain cases make this impossible (e.g. a plain +/// string scalar that starts with '>', a child having a block style +/// parameters), in which case we will produce [value] with default styling +/// options. +String yamlEncodeFlow(YamlNode value) { + if (value is YamlList) { + final list = value.nodes; + + final safeValues = list.map(yamlEncodeFlow); + return '[${safeValues.join(', ')}]'; + } else if (value is YamlMap) { + final safeEntries = value.nodes.entries.map((entry) { + final safeKey = yamlEncodeFlow(entry.key as YamlNode); + final safeValue = yamlEncodeFlow(entry.value); + return '$safeKey: $safeValue'; + }); + + return '{${safeEntries.join(', ')}}'; + } + + return _yamlEncodeFlowScalar(value as YamlScalar); +} + +/// Returns [value] with the necessary formatting applied in a block context. +String yamlEncodeBlock( + YamlNode value, + int indentation, + String lineEnding, +) { + const additionalIndentation = 2; + + if (!isBlockNode(value)) return yamlEncodeFlow(value); + + final newIndentation = indentation + additionalIndentation; + + if (value is YamlList) { + if (value.isEmpty) return '${' ' * indentation}[]'; + + Iterable safeValues; + + final children = value.nodes; + + safeValues = children.map((child) { + var valueString = yamlEncodeBlock(child, newIndentation, lineEnding); + if (isCollection(child) && !isFlowYamlCollectionNode(child)) { + valueString = valueString.substring(newIndentation); + } + + return '${' ' * indentation}- $valueString'; + }); + + return safeValues.join(lineEnding); + } else if (value is YamlMap) { + if (value.isEmpty) return '${' ' * indentation}{}'; + + return value.nodes.entries.map((entry) { + final MapEntry(:key, :value) = entry; + + final safeKey = yamlEncodeFlow(key as YamlNode); + final formattedKey = ' ' * indentation + safeKey; + + final formattedValue = yamlEncodeBlock( + value, + newIndentation, + lineEnding, + ); + + /// Empty collections are always encoded in flow-style, so new-line must + /// be avoided + if (isCollection(value) && !isEmpty(value)) { + return '$formattedKey:$lineEnding$formattedValue'; + } + + return '$formattedKey: $formattedValue'; + }).join(lineEnding); + } + + return _yamlEncodeBlockScalar( + value as YamlScalar, + newIndentation, + lineEnding, + ); +} + +/// List of unprintable characters. +/// +/// See 5.7 Escape Characters https://yaml.org/spec/1.2/spec.html#id2776092 +final Map unprintableCharCodes = { + 0: '\\0', // Escaped ASCII null (#x0) character. + 7: '\\a', // Escaped ASCII bell (#x7) character. + 8: '\\b', // Escaped ASCII backspace (#x8) character. + 11: '\\v', // Escaped ASCII vertical tab (#xB) character. + 12: '\\f', // Escaped ASCII form feed (#xC) character. + 13: '\\r', // Escaped ASCII carriage return (#xD) character. Line Break. + 27: '\\e', // Escaped ASCII escape (#x1B) character. + 133: '\\N', // Escaped Unicode next line (#x85) character. + 160: '\\_', // Escaped Unicode non-breaking space (#xA0) character. + 8232: '\\L', // Escaped Unicode line separator (#x2028) character. + 8233: '\\P', // Escaped Unicode paragraph separator (#x2029) character. +}; + +/// List of escape characters. +/// +/// See 5.7 Escape Characters https://yaml.org/spec/1.2/spec.html#id2776092 +final Map doubleQuoteEscapeChars = { + ...unprintableCharCodes, + 9: '\\t', // Escaped ASCII horizontal tab (#x9) character. Printable + 10: '\\n', // Escaped ASCII line feed (#xA) character. Line Break. + 34: '\\"', // Escaped ASCII double quote (#x22). + 47: '\\/', // Escaped ASCII slash (#x2F), for JSON compatibility. + 92: '\\\\', // Escaped ASCII back slash (#x5C). +}; diff --git a/pkgs/yaml_edit/lib/src/utils.dart b/pkgs/yaml_edit/lib/src/utils.dart new file mode 100644 index 000000000..ef85526d3 --- /dev/null +++ b/pkgs/yaml_edit/lib/src/utils.dart @@ -0,0 +1,291 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:source_span/source_span.dart'; +import 'package:yaml/yaml.dart'; + +import 'editor.dart'; +import 'wrap.dart'; + +/// Invoke [fn] while setting [yamlWarningCallback] to [warn], and restore +/// [YamlWarningCallback] after [fn] returns. +/// +/// Defaults to a [warn] function that ignores all warnings. +T withYamlWarningCallback( + T Function() fn, { + YamlWarningCallback warn = _ignoreWarning, +}) { + final original = yamlWarningCallback; + try { + yamlWarningCallback = warn; + return fn(); + } finally { + yamlWarningCallback = original; + } +} + +void _ignoreWarning(String warning, [SourceSpan? span]) {/* ignore warning */} + +/// Determines if [string] is dangerous by checking if parsing the plain string +/// can return a result different from [string]. +/// +/// This function is also capable of detecting if non-printable characters are +/// in [string]. +bool isDangerousString(String string) { + try { + final node = withYamlWarningCallback(() => loadYamlNode(string)); + if (node.value != string) { + return true; + } + + // [string] should also not contain the `[`, `]`, `,`, `{` and `}` indicator + // characters. + return string.contains(RegExp(r'\{|\[|\]|\}|,')); + } catch (e) { + /// This catch statement catches [ArgumentError] in `loadYamlNode` when + /// a string can be interpreted as a URI tag, but catches for other + /// [YamlException]s + return true; + } +} + +/// Asserts that [value] is a valid scalar according to YAML. +/// +/// A valid scalar is a number, String, boolean, or null. +void assertValidScalar(Object? value) { + if (value is num || value is String || value is bool || value == null) { + return; + } + + throw ArgumentError.value(value, 'value', 'Not a valid scalar type!'); +} + +/// Checks if [node] is a [YamlNode] with block styling. +/// +/// [ScalarStyle.ANY] and [CollectionStyle.ANY] are considered to be block +/// styling by default for maximum flexibility. +bool isBlockNode(YamlNode node) { + if (node is YamlScalar) { + if (node.style == ScalarStyle.LITERAL || + node.style == ScalarStyle.FOLDED || + node.style == ScalarStyle.ANY) { + return true; + } + } + + if (node is YamlList && + (node.style == CollectionStyle.BLOCK || + node.style == CollectionStyle.ANY)) { + return true; + } + if (node is YamlMap && + (node.style == CollectionStyle.BLOCK || + node.style == CollectionStyle.ANY)) { + return true; + } + + return false; +} + +/// Returns the content sensitive ending offset of [yamlNode] (i.e. where the +/// last meaningful content happens) +int getContentSensitiveEnd(YamlNode yamlNode) { + if (yamlNode is YamlList) { + if (yamlNode.style == CollectionStyle.FLOW) { + return yamlNode.span.end.offset; + } else { + return getContentSensitiveEnd(yamlNode.nodes.last); + } + } else if (yamlNode is YamlMap) { + if (yamlNode.style == CollectionStyle.FLOW) { + return yamlNode.span.end.offset; + } else { + return getContentSensitiveEnd(yamlNode.nodes.values.last); + } + } + + return yamlNode.span.end.offset; +} + +/// Checks if the item is a Map or a List +bool isCollection(Object item) => item is Map || item is List; + +/// Checks if [index] is [int], >=0, < [length] +bool isValidIndex(Object? index, int length) { + return index is int && index >= 0 && index < length; +} + +/// Checks if the item is empty, if it is a List or a Map. +/// +/// Returns `false` if [item] is not a List or Map. +bool isEmpty(Object item) { + if (item is Map) return item.isEmpty; + if (item is List) return item.isEmpty; + + return false; +} + +/// Creates a [SourceSpan] from [sourceUrl] with no meaningful location +/// information. +/// +/// Mainly used with [wrapAsYamlNode] to allow for a reasonable +/// implementation of [SourceSpan.message]. +SourceSpan shellSpan(Object? sourceUrl) { + final shellSourceLocation = SourceLocation(0, sourceUrl: sourceUrl); + return SourceSpanBase(shellSourceLocation, shellSourceLocation, ''); +} + +/// Returns if [value] is a [YamlList] or [YamlMap] with [CollectionStyle.FLOW]. +bool isFlowYamlCollectionNode(Object value) => + value is YamlNode && value.collectionStyle == CollectionStyle.FLOW; + +/// Determines the index where [newKey] will be inserted if the keys in [map] +/// are in alphabetical order when converted to strings. +/// +/// Returns the length of [map] if the keys in [map] are not in alphabetical +/// order. +int getMapInsertionIndex(YamlMap map, Object newKey) { + final keys = map.nodes.keys.map((k) => k.toString()).toList(); + + // We can't deduce ordering if list is empty, so then we just we just append + if (keys.length <= 1) { + return map.length; + } + + for (var i = 1; i < keys.length; i++) { + if (keys[i].compareTo(keys[i - 1]) < 0) { + return map.length; + } + } + + final insertionIndex = + keys.indexWhere((key) => key.compareTo(newKey as String) > 0); + + if (insertionIndex != -1) return insertionIndex; + + return map.length; +} + +/// Returns the detected indentation step used in [editor], or defaults to a +/// value of `2` if no indentation step can be detected. +/// +/// Indentation step is determined by the difference in indentation of the +/// first block-styled yaml collection in the second level as compared to the +/// top-level elements. In the case where there are multiple possible +/// candidates, we choose the candidate closest to the start of [editor]. +int getIndentation(YamlEditor editor) { + final node = editor.parseAt([]); + Iterable? children; + var indentation = 2; + + if (node is YamlMap && node.style == CollectionStyle.BLOCK) { + children = node.nodes.values; + } else if (node is YamlList && node.style == CollectionStyle.BLOCK) { + children = node.nodes; + } + + if (children != null) { + for (final child in children) { + var indent = 0; + if (child is YamlList) { + indent = getListIndentation(editor.toString(), child); + } else if (child is YamlMap) { + indent = getMapIndentation(editor.toString(), child); + } + + if (indent != 0) indentation = indent; + } + } + return indentation; +} + +/// Gets the indentation level of [list]. This is 0 if it is a flow list, +/// but returns the number of spaces before the hyphen of elements for +/// block lists. +/// +/// Throws [UnsupportedError] if an empty block map is passed in. +int getListIndentation(String yaml, YamlList list) { + if (list.style == CollectionStyle.FLOW) return 0; + + /// An empty block map doesn't really exist. + if (list.isEmpty) { + throw UnsupportedError('Unable to get indentation for empty block list'); + } + + final lastSpanOffset = list.nodes.last.span.start.offset; + final lastHyphen = yaml.lastIndexOf('-', lastSpanOffset - 1); + + if (lastHyphen == 0) return lastHyphen; + + // Look for '\n' that's before hyphen + final lastNewLine = yaml.lastIndexOf('\n', lastHyphen - 1); + + return lastHyphen - lastNewLine - 1; +} + +/// Gets the indentation level of [map]. This is 0 if it is a flow map, +/// but returns the number of spaces before the keys for block maps. +int getMapIndentation(String yaml, YamlMap map) { + if (map.style == CollectionStyle.FLOW) return 0; + + /// An empty block map doesn't really exist. + if (map.isEmpty) { + throw UnsupportedError('Unable to get indentation for empty block map'); + } + + /// Use the number of spaces between the last key and the newline as + /// indentation. + final lastKey = map.nodes.keys.last as YamlNode; + final lastSpanOffset = lastKey.span.start.offset; + final lastNewLine = yaml.lastIndexOf('\n', lastSpanOffset); + final lastQuestionMark = yaml.lastIndexOf('?', lastSpanOffset); + + if (lastQuestionMark == -1) { + if (lastNewLine == -1) return lastSpanOffset; + return lastSpanOffset - lastNewLine - 1; + } + + /// If there is a question mark, it might be a complex key. Check if it + /// is on the same line as the key node to verify. + if (lastNewLine == -1) return lastQuestionMark; + if (lastQuestionMark > lastNewLine) { + return lastQuestionMark - lastNewLine - 1; + } + + return lastSpanOffset - lastNewLine - 1; +} + +/// Returns the detected line ending used in [yaml], more specifically, whether +/// [yaml] appears to use Windows `\r\n` or Unix `\n` line endings. +/// +/// The heuristic used is to count all `\n` in the text and if strictly more +/// than half of them are preceded by `\r` we report that windows line endings +/// are used. +String getLineEnding(String yaml) { + var index = -1; + var unixNewlines = 0; + var windowsNewlines = 0; + while ((index = yaml.indexOf('\n', index + 1)) != -1) { + if (index != 0 && yaml[index - 1] == '\r') { + windowsNewlines++; + } else { + unixNewlines++; + } + } + + return windowsNewlines > unixNewlines ? '\r\n' : '\n'; +} + +extension YamlNodeExtension on YamlNode { + /// Returns the [CollectionStyle] of `this` if `this` is [YamlMap] or + /// [YamlList]. + /// + /// Otherwise, returns `null`. + CollectionStyle? get collectionStyle { + final me = this; + if (me is YamlMap) return me.style; + if (me is YamlList) return me.style; + return null; + } +} diff --git a/pkgs/yaml_edit/lib/src/wrap.dart b/pkgs/yaml_edit/lib/src/wrap.dart new file mode 100644 index 000000000..73f775127 --- /dev/null +++ b/pkgs/yaml_edit/lib/src/wrap.dart @@ -0,0 +1,216 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:collection' as collection; + +import 'package:collection/collection.dart'; +import 'package:source_span/source_span.dart'; +import 'package:yaml/yaml.dart'; + +import 'equality.dart'; +import 'utils.dart'; + +/// Returns a new [YamlMap] constructed by applying [update] onto the nodes of +/// this [YamlMap]. +YamlMap updatedYamlMap(YamlMap map, Function(Map) update) { + final dummyMap = deepEqualsMap(); + dummyMap.addAll(map.nodes); + + update(dummyMap); + + return wrapAsYamlNode(dummyMap) as YamlMap; +} + +/// Wraps [value] into a [YamlNode]. +/// +/// [Map]s, [List]s and Scalars will be wrapped as [YamlMap]s, [YamlList]s, +/// and [YamlScalar]s respectively. If [collectionStyle]/[scalarStyle] is +/// defined, and [value] is a collection or scalar, the wrapped [YamlNode] will +/// have the respective style, otherwise it defaults to the ANY style. +/// +/// If [value] is a [Map] or [List], then [wrapAsYamlNode] will be called +/// recursively on all children, and [collectionStyle]/[scalarStyle] will be +/// applied to any children that are not instances of [YamlNode]. +/// +/// If a [YamlNode] is passed in, no further wrapping will be done, and the +/// [collectionStyle]/[scalarStyle] will not be applied. +YamlNode wrapAsYamlNode( + Object? value, { + CollectionStyle collectionStyle = CollectionStyle.ANY, + ScalarStyle scalarStyle = ScalarStyle.ANY, +}) { + if (value is YamlScalar) { + assertValidScalar(value.value); + return value; + } else if (value is YamlList) { + for (final item in value.nodes) { + wrapAsYamlNode(item); + } + + return value; + } else if (value is YamlMap) { + /// Both [entry.key] and [entry.values] are guaranteed to be [YamlNode]s, + /// so running this will just assert that they are valid scalars. + for (final entry in value.nodes.entries) { + wrapAsYamlNode(entry.key); + wrapAsYamlNode(entry.value); + } + + return value; + } else if (value is Map) { + return YamlMapWrap( + value, + collectionStyle: collectionStyle, + scalarStyle: scalarStyle, + ); + } else if (value is List) { + return YamlListWrap( + value, + collectionStyle: collectionStyle, + scalarStyle: scalarStyle, + ); + } else { + assertValidScalar(value); + + return YamlScalarWrap(value, style: scalarStyle); + } +} + +/// Internal class that allows us to define a constructor on [YamlScalar] +/// which takes in [style] as an argument. +class YamlScalarWrap implements YamlScalar { + /// The [ScalarStyle] to be used for the scalar. + @override + final ScalarStyle style; + + @override + final SourceSpan span; + + @override + final dynamic value; + + YamlScalarWrap(this.value, {this.style = ScalarStyle.ANY, Object? sourceUrl}) + : span = shellSpan(sourceUrl); + + @override + String toString() => value.toString(); +} + +/// Internal class that allows us to define a constructor on [YamlMap] +/// which takes in [style] as an argument. +class YamlMapWrap + with collection.MapMixin, UnmodifiableMapMixin + implements YamlMap { + /// The [CollectionStyle] to be used for the map. + @override + final CollectionStyle style; + + @override + final Map nodes; + + @override + final SourceSpan span; + + factory YamlMapWrap( + Map dartMap, { + CollectionStyle collectionStyle = CollectionStyle.ANY, + ScalarStyle scalarStyle = ScalarStyle.ANY, + Object? sourceUrl, + }) { + final wrappedMap = deepEqualsMap(); + + for (final entry in dartMap.entries) { + final wrappedKey = wrapAsYamlNode( + entry.key, + collectionStyle: collectionStyle, + scalarStyle: scalarStyle, + ); + final wrappedValue = wrapAsYamlNode( + entry.value, + collectionStyle: collectionStyle, + scalarStyle: scalarStyle, + ); + wrappedMap[wrappedKey] = wrappedValue; + } + + return YamlMapWrap._( + wrappedMap, + style: collectionStyle, + sourceUrl: sourceUrl, + ); + } + + YamlMapWrap._( + this.nodes, { + CollectionStyle style = CollectionStyle.ANY, + Object? sourceUrl, + }) : span = shellSpan(sourceUrl), + style = nodes.isEmpty ? CollectionStyle.FLOW : style; + + @override + dynamic operator [](Object? key) => nodes[key]?.value; + + @override + Iterable get keys => nodes.keys.map((node) => (node as YamlNode).value); + + @override + Map get value => this; +} + +/// Internal class that allows us to define a constructor on [YamlList] +/// which takes in [style] as an argument. +class YamlListWrap with collection.ListMixin implements YamlList { + /// The [CollectionStyle] to be used for the list. + @override + final CollectionStyle style; + + @override + final List nodes; + + @override + final SourceSpan span; + + @override + int get length => nodes.length; + + @override + set length(int index) { + throw UnsupportedError('Cannot modify an unmodifiable List'); + } + + factory YamlListWrap( + List dartList, { + CollectionStyle collectionStyle = CollectionStyle.ANY, + ScalarStyle scalarStyle = ScalarStyle.ANY, + Object? sourceUrl, + }) { + return YamlListWrap._( + dartList + .map((v) => wrapAsYamlNode( + v, + collectionStyle: collectionStyle, + scalarStyle: scalarStyle, + )) + .toList(), + style: collectionStyle, + sourceUrl: sourceUrl, + ); + } + + YamlListWrap._(this.nodes, + {CollectionStyle style = CollectionStyle.ANY, Object? sourceUrl}) + : span = shellSpan(sourceUrl), + style = nodes.isEmpty ? CollectionStyle.FLOW : style; + + @override + dynamic operator [](int index) => nodes[index].value; + + @override + void operator []=(int index, Object? value) { + throw UnsupportedError('Cannot modify an unmodifiable List'); + } + + @override + List get value => this; +} diff --git a/pkgs/yaml_edit/lib/yaml_edit.dart b/pkgs/yaml_edit/lib/yaml_edit.dart new file mode 100644 index 000000000..49558b274 --- /dev/null +++ b/pkgs/yaml_edit/lib/yaml_edit.dart @@ -0,0 +1,28 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// YAML parsing is supported by `package:yaml`, and each time a change is +/// made, the resulting YAML AST is compared against our expected output +/// with deep equality to ensure that the output conforms to our expectations. +/// +/// **Example** +/// ```dart +/// import 'package:yaml_edit/yaml_edit.dart'; +/// +/// void main() { +/// final yamlEditor = YamlEditor('{YAML: YAML}'); +/// yamlEditor.update(['YAML'], "YAML Ain't Markup Language"); +/// print(yamlEditor); +/// // Expected Output: +/// // {YAML: YAML Ain't Markup Language} +/// } +/// ``` +/// +/// [1]: https://yaml.org/ +library; + +export 'src/editor.dart'; +export 'src/errors.dart' show AliasException; +export 'src/source_edit.dart'; +export 'src/wrap.dart' show wrapAsYamlNode; diff --git a/pkgs/yaml_edit/pubspec.yaml b/pkgs/yaml_edit/pubspec.yaml new file mode 100644 index 000000000..8127a1253 --- /dev/null +++ b/pkgs/yaml_edit/pubspec.yaml @@ -0,0 +1,25 @@ +name: yaml_edit +version: 2.2.2 +description: >- + A library for YAML manipulation with comment and whitespace preservation. +repository: https://github.com/dart-lang/tools/tree/main/pkgs/yaml_edit + +issue_tracker: https://github.com/dart-lang/yaml_edit/issues + +topics: + - yaml + +environment: + sdk: ^3.1.0 + +dependencies: + collection: ^1.15.0 + meta: ^1.7.0 + source_span: ^1.8.1 + yaml: ^3.1.0 + +dev_dependencies: + coverage: any # we only need format_coverage, don't care what version + dart_flutter_team_lints: ^3.0.0 + path: ^1.8.0 + test: ^1.17.12 diff --git a/pkgs/yaml_edit/test/alias_test.dart b/pkgs/yaml_edit/test/alias_test.dart new file mode 100644 index 000000000..acc0df71d --- /dev/null +++ b/pkgs/yaml_edit/test/alias_test.dart @@ -0,0 +1,139 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:test/test.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + +import 'test_utils.dart'; + +/// This test suite is a temporary measure until we are able to better handle +/// aliases. +void main() { + group('list ', () { + test('removing an alias anchor results in AliasError', () { + final doc = YamlEditor(''' +- &SS Sammy Sosa +- *SS +'''); + expect(() => doc.remove([0]), throwsAliasException); + }); + + test('removing an alias reference results in AliasError', () { + final doc = YamlEditor(''' +- &SS Sammy Sosa +- *SS +'''); + + expect(() => doc.remove([1]), throwsAliasException); + }); + + test('it is okay to remove a non-alias node', () { + final doc = YamlEditor(''' +- &SS Sammy Sosa +- *SS +- Sammy Sosa +'''); + + doc.remove([2]); + expect(doc.toString(), equals(''' +- &SS Sammy Sosa +- *SS +''')); + }); + }); + + group('map', () { + test('removing an alias anchor value results in AliasError', () { + final doc = YamlEditor(''' +a: &SS Sammy Sosa +b: *SS +'''); + + expect(() => doc.remove(['a']), throwsAliasException); + }); + + test('removing an alias reference value results in AliasError', () { + final doc = YamlEditor(''' +a: &SS Sammy Sosa +b: *SS +'''); + + expect(() => doc.remove(['b']), throwsAliasException); + }); + + test('removing an alias anchor key results in AliasError', () { + final doc = YamlEditor(''' +&SS Sammy Sosa: a +b: *SS +'''); + + expect(() => doc.remove(['Sammy Sosa']), throwsAliasException); + }); + + test('removing an alias reference key results in AliasError', () { + final doc = YamlEditor(''' +a: &SS Sammy Sosa +*SS : b +'''); + + expect(() => doc.remove(['Sammy Sosa']), throwsAliasException); + }); + + test('it is okay to remove a non-alias node', () { + final doc = YamlEditor(''' +a: &SS Sammy Sosa +b: *SS +c: Sammy Sosa +'''); + + doc.remove(['c']); + expect(doc.toString(), equals(''' +a: &SS Sammy Sosa +b: *SS +''')); + }); + }); + + group('nested alias', () { + test('nested list alias anchors are detected too', () { + final doc = YamlEditor(''' +- + - &SS Sammy Sosa +- *SS +'''); + + expect(() => doc.remove([0]), throwsAliasException); + }); + + test('nested list alias references are detected too', () { + final doc = YamlEditor(''' +- &SS Sammy Sosa +- + - *SS +'''); + + expect(() => doc.remove([1]), throwsAliasException); + }); + + test('removing nested map alias anchor results in AliasError', () { + final doc = YamlEditor(''' +a: + c: &SS Sammy Sosa +b: *SS +'''); + + expect(() => doc.remove(['a']), throwsAliasException); + }); + + test('removing nested map alias reference results in AliasError', () { + final doc = YamlEditor(''' +a: &SS Sammy Sosa +b: + c: *SS +'''); + + expect(() => doc.remove(['b']), throwsAliasException); + }); + }); +} diff --git a/pkgs/yaml_edit/test/append_test.dart b/pkgs/yaml_edit/test/append_test.dart new file mode 100644 index 000000000..cb705edac --- /dev/null +++ b/pkgs/yaml_edit/test/append_test.dart @@ -0,0 +1,269 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:test/test.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + +import 'test_utils.dart'; + +void main() { + group('throws PathError', () { + test('if it is a map', () { + final doc = YamlEditor('a:1'); + expect(() => doc.appendToList([], 4), throwsPathError); + }); + + test('if it is a scalar', () { + final doc = YamlEditor('1'); + expect(() => doc.appendToList([], 4), throwsPathError); + }); + }); + + group('block list', () { + test('(1)', () { + final doc = YamlEditor(''' +- 0 +- 1 +- 2 +- 3 +'''); + doc.appendToList([], 4); + expect(doc.toString(), equals(''' +- 0 +- 1 +- 2 +- 3 +- 4 +''')); + expectYamlBuilderValue(doc, [0, 1, 2, 3, 4]); + }); + + test('null path', () { + final doc = YamlEditor(''' +~: + - 0 + - 1 + - 2 + - 3 +'''); + doc.appendToList([null], 4); + expect(doc.toString(), equals(''' +~: + - 0 + - 1 + - 2 + - 3 + - 4 +''')); + expectYamlBuilderValue(doc, { + null: [0, 1, 2, 3, 4] + }); + }); + + test('element to simple block list ', () { + final doc = YamlEditor(''' +- 0 +- 1 +- 2 +- 3 +'''); + doc.appendToList([], [4, 5, 6]); + expect(doc.toString(), equals(''' +- 0 +- 1 +- 2 +- 3 +- - 4 + - 5 + - 6 +''')); + expectYamlBuilderValue(doc, [ + 0, + 1, + 2, + 3, + [4, 5, 6] + ]); + }); + + test('nested', () { + final doc = YamlEditor(''' +- 0 +- - 1 + - 2 +'''); + doc.appendToList([1], 3); + expect(doc.toString(), equals(''' +- 0 +- - 1 + - 2 + - 3 +''')); + expectYamlBuilderValue(doc, [ + 0, + [1, 2, 3] + ]); + }); + + test('block list element to nested block list ', () { + final doc = YamlEditor(''' +- 0 +- - 1 + - 2 +'''); + doc.appendToList([1], [3, 4, 5]); + + expect(doc.toString(), equals(''' +- 0 +- - 1 + - 2 + - - 3 + - 4 + - 5 +''')); + expectYamlBuilderValue(doc, [ + 0, + [ + 1, + 2, + [3, 4, 5] + ] + ]); + }); + + test('nested', () { + final yamlEditor = YamlEditor(''' +a: + 1: + - null + 2: null +'''); + yamlEditor.appendToList(['a', 1], false); + + expect(yamlEditor.toString(), equals(''' +a: + 1: + - null + - false + 2: null +''')); + }); + + test('block append (1)', () { + final yamlEditor = YamlEditor(''' +# comment +- z: + x: 1 + y: 2 +- z: + x: 3 + y: 4 +'''); + yamlEditor.appendToList([], { + 'z': {'x': 5, 'y': 6} + }); + + expect(yamlEditor.toString(), equals(''' +# comment +- z: + x: 1 + y: 2 +- z: + x: 3 + y: 4 +- z: + x: 5 + y: 6 +''')); + }); + + test('block append (2)', () { + final yamlEditor = YamlEditor(''' +# comment +a: + - z: + x: 1 + y: 2 + - z: + x: 3 + y: 4 +b: + - w: + m: 2 + n: 4 +'''); + yamlEditor.appendToList([ + 'a' + ], { + 'z': {'x': 5, 'y': 6} + }); + + expect(yamlEditor.toString(), equals(''' +# comment +a: + - z: + x: 1 + y: 2 + - z: + x: 3 + y: 4 + - z: + x: 5 + y: 6 +b: + - w: + m: 2 + n: 4 +''')); + }); + + test('block append nested and with comments', () { + final yamlEditor = YamlEditor(''' +a: + b: + - c: + d: 1 + - c: + d: 2 +# comment + e: + - g: + e: 1 + f: 2 +# comment +'''); + expect( + () => yamlEditor.appendToList([ + 'a', + 'e' + ], { + 'g': {'e': 3, 'f': 4} + }), + returnsNormally); + }); + }); + + group('flow list', () { + test('(1)', () { + final doc = YamlEditor('[0, 1, 2]'); + doc.appendToList([], 3); + expect(doc.toString(), equals('[0, 1, 2, 3]')); + expectYamlBuilderValue(doc, [0, 1, 2, 3]); + }); + + test('null value', () { + final doc = YamlEditor('[0, 1, 2]'); + doc.appendToList([], null); + expect(doc.toString(), equals('[0, 1, 2, null]')); + expectYamlBuilderValue(doc, [0, 1, 2, null]); + }); + + test('empty ', () { + final doc = YamlEditor('[]'); + doc.appendToList([], 0); + expect(doc.toString(), equals('[0]')); + expectYamlBuilderValue(doc, [0]); + }); + }); +} diff --git a/pkgs/yaml_edit/test/editor_test.dart b/pkgs/yaml_edit/test/editor_test.dart new file mode 100644 index 000000000..b0a0081e7 --- /dev/null +++ b/pkgs/yaml_edit/test/editor_test.dart @@ -0,0 +1,56 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:test/test.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + +void main() { + group('YamlEditor records edits', () { + test('returns empty list at start', () { + final yamlEditor = YamlEditor('YAML: YAML'); + + expect(yamlEditor.edits, []); + }); + + test('after one change', () { + final yamlEditor = YamlEditor('YAML: YAML'); + yamlEditor.update(['YAML'], "YAML Ain't Markup Language"); + + expect( + yamlEditor.edits, [SourceEdit(5, 5, " YAML Ain't Markup Language")]); + }); + + test('after multiple changes', () { + final yamlEditor = YamlEditor('YAML: YAML'); + yamlEditor.update(['YAML'], "YAML Ain't Markup Language"); + yamlEditor.update(['XML'], 'Extensible Markup Language'); + yamlEditor.remove(['YAML']); + + expect(yamlEditor.edits, [ + SourceEdit(5, 5, " YAML Ain't Markup Language"), + SourceEdit(32, 0, '\nXML: Extensible Markup Language\n'), + SourceEdit(0, 33, '') + ]); + }); + + test('that do not automatically update with internal list', () { + final yamlEditor = YamlEditor('YAML: YAML'); + yamlEditor.update(['YAML'], "YAML Ain't Markup Language"); + + final firstEdits = yamlEditor.edits; + + expect(firstEdits, [SourceEdit(5, 5, " YAML Ain't Markup Language")]); + + yamlEditor.update(['XML'], 'Extensible Markup Language'); + yamlEditor.remove(['YAML']); + + expect(firstEdits, [SourceEdit(5, 5, " YAML Ain't Markup Language")]); + expect(yamlEditor.edits, [ + SourceEdit(5, 5, " YAML Ain't Markup Language"), + SourceEdit(32, 0, '\nXML: Extensible Markup Language\n'), + SourceEdit(0, 33, '') + ]); + }); + }); +} diff --git a/pkgs/yaml_edit/test/golden_test.dart b/pkgs/yaml_edit/test/golden_test.dart new file mode 100644 index 000000000..1dd6ff329 --- /dev/null +++ b/pkgs/yaml_edit/test/golden_test.dart @@ -0,0 +1,40 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn('vm') +library; + +import 'dart:io'; +import 'dart:isolate'; + +import 'package:test/test.dart'; + +import 'test_case.dart'; + +/// This script performs snapshot testing of the inputs in the testing directory +/// against golden files if they exist, and creates the golden files otherwise. +/// +/// Input directory should be in `test/test_cases`, while the golden files should +/// be in `test/test_cases_golden`. +/// +/// For more information on the expected input and output, refer to the README +/// in the testdata folder +Future main() async { + final packageUri = await Isolate.resolvePackageUri( + Uri.parse('package:yaml_edit/yaml_edit.dart')); + + final testdataUri = packageUri!.resolve('../test/testdata/'); + final inputDirectory = Directory.fromUri(testdataUri.resolve('input/')); + final goldDirectoryUri = testdataUri.resolve('output/'); + + if (!inputDirectory.existsSync()) { + throw FileSystemException( + 'Testing Directory does not exist!', inputDirectory.path); + } + + final testCases = + await TestCases.getTestCases(inputDirectory.uri, goldDirectoryUri); + + testCases.test(); +} diff --git a/pkgs/yaml_edit/test/insert_test.dart b/pkgs/yaml_edit/test/insert_test.dart new file mode 100644 index 000000000..8c0e3b215 --- /dev/null +++ b/pkgs/yaml_edit/test/insert_test.dart @@ -0,0 +1,207 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:test/test.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + +import 'test_utils.dart'; + +void main() { + group('throws PathError', () { + test('if it is a map', () { + final doc = YamlEditor('a:1'); + expect(() => doc.insertIntoList([], 0, 4), throwsPathError); + }); + + test('if it is a scalar', () { + final doc = YamlEditor('1'); + expect(() => doc.insertIntoList([], 0, 4), throwsPathError); + }); + }); + + test('throws RangeError if index is out of range', () { + final doc = YamlEditor('[1, 2]'); + expect(() => doc.insertIntoList([], -1, 0), throwsRangeError); + expect(() => doc.insertIntoList([], 3, 0), throwsRangeError); + }); + + group('block list', () { + test('(1)', () { + final doc = YamlEditor(''' +- 1 +- 2'''); + doc.insertIntoList([], 0, 0); + expect(doc.toString(), equals(''' +- 0 +- 1 +- 2''')); + expectYamlBuilderValue(doc, [0, 1, 2]); + }); + + test('(2)', () { + final doc = YamlEditor(''' +- 1 +- 2'''); + doc.insertIntoList([], 1, 3); + expect(doc.toString(), equals(''' +- 1 +- 3 +- 2''')); + expectYamlBuilderValue(doc, [1, 3, 2]); + }); + + test('(3)', () { + final doc = YamlEditor(''' +- 1 +- 2 +'''); + doc.insertIntoList([], 2, 3); + expect(doc.toString(), equals(''' +- 1 +- 2 +- 3 +''')); + expectYamlBuilderValue(doc, [1, 2, 3]); + }); + + test('(4)', () { + final doc = YamlEditor(''' +- 1 +- 3 +'''); + doc.insertIntoList([], 1, [4, 5, 6]); + expect(doc.toString(), equals(''' +- 1 +- - 4 + - 5 + - 6 +- 3 +''')); + expectYamlBuilderValue(doc, [ + 1, + [4, 5, 6], + 3 + ]); + }); + + test(' with comments', () { + final doc = YamlEditor(''' +- 0 # comment a +- 2 # comment b +'''); + doc.insertIntoList([], 1, 1); + expect(doc.toString(), equals(''' +- 0 # comment a +- 1 +- 2 # comment b +''')); + expectYamlBuilderValue(doc, [0, 1, 2]); + }); + + for (var i = 0; i < 3; i++) { + test('block insert(1) at $i', () { + final yamlEditor = YamlEditor(''' +# comment +- z: + x: 1 + y: 2 +- z: + x: 3 + y: 4 +'''); + expect( + () => yamlEditor.insertIntoList( + [], + i, + { + 'z': {'x': 5, 'y': 6} + }), + returnsNormally); + }); + } + + for (var i = 0; i < 3; i++) { + test('block insert(2) at $i', () { + final yamlEditor = YamlEditor(''' +a: + - z: + x: 1 + y: 2 + - z: + x: 3 + y: 4 +b: + - w: + m: 2 + n: 4 +'''); + expect( + () => yamlEditor.insertIntoList( + ['a'], + i, + { + 'z': {'x': 5, 'y': 6} + }), + returnsNormally); + }); + } + + for (var i = 0; i < 2; i++) { + test('block insert nested and with comments at $i', () { + final yamlEditor = YamlEditor(''' +a: + b: + - c: + d: 1 + - c: + d: 2 +# comment + e: + - g: + e: 1 + f: 2 +# comment +'''); + expect( + () => yamlEditor.insertIntoList( + ['a', 'b'], + i, + { + 'g': {'e': 3, 'f': 4} + }), + returnsNormally); + }); + } + }); + + group('flow list', () { + test('(1)', () { + final doc = YamlEditor('[1, 2]'); + doc.insertIntoList([], 0, 0); + expect(doc.toString(), equals('[0, 1, 2]')); + expectYamlBuilderValue(doc, [0, 1, 2]); + }); + + test('(2)', () { + final doc = YamlEditor('[1, 2]'); + doc.insertIntoList([], 1, 3); + expect(doc.toString(), equals('[1, 3, 2]')); + expectYamlBuilderValue(doc, [1, 3, 2]); + }); + + test('(3)', () { + final doc = YamlEditor('[1, 2]'); + doc.insertIntoList([], 2, 3); + expect(doc.toString(), equals('[1, 2, 3]')); + expectYamlBuilderValue(doc, [1, 2, 3]); + }); + + test('(4)', () { + final doc = YamlEditor('["[],", "[],"]'); + doc.insertIntoList([], 1, 'test'); + expect(doc.toString(), equals('["[],", test, "[],"]')); + expectYamlBuilderValue(doc, ['[],', 'test', '[],']); + }); + }); +} diff --git a/pkgs/yaml_edit/test/naughty_test.dart b/pkgs/yaml_edit/test/naughty_test.dart new file mode 100644 index 000000000..533a535c7 --- /dev/null +++ b/pkgs/yaml_edit/test/naughty_test.dart @@ -0,0 +1,30 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:test/test.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + +import 'problem_strings.dart'; + +void main() { + for (final string in problemStrings) { + test('expect string $string', () { + final doc = YamlEditor(''); + + /// Using [runZoned] to hide `package:yaml`'s warnings. + /// Test failures and errors will still be shown. + runZoned(() { + expect(() => doc.update([], string), returnsNormally); + final value = doc.parseAt([]).value; + expect(value, isA()); + expect(value, equals(string)); + }, + zoneSpecification: ZoneSpecification( + print: (Zone self, ZoneDelegate parent, Zone zone, + String message) {})); + }); + } +} diff --git a/pkgs/yaml_edit/test/parse_test.dart b/pkgs/yaml_edit/test/parse_test.dart new file mode 100644 index 000000000..382307c78 --- /dev/null +++ b/pkgs/yaml_edit/test/parse_test.dart @@ -0,0 +1,156 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:test/test.dart'; +import 'package:yaml/yaml.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + +import 'test_utils.dart'; + +void main() { + group('throws', () { + test('PathError if key does not exist', () { + final doc = YamlEditor('{a: 4}'); + final path = ['b']; + + expect(() => doc.parseAt(path), throwsPathError); + }); + + test('PathError if path tries to go deeper into a scalar', () { + final doc = YamlEditor('{a: 4}'); + final path = ['a', 'b']; + + expect(() => doc.parseAt(path), throwsPathError); + }); + + test('PathError if index is out of bounds', () { + final doc = YamlEditor('[0,1]'); + final path = [2]; + + expect(() => doc.parseAt(path), throwsPathError); + }); + + test('PathError if index is not an integer', () { + final doc = YamlEditor('[0,1]'); + final path = ['2']; + + expect(() => doc.parseAt(path), throwsPathError); + }); + }); + + group('orElse provides a default value', () { + test('simple example with null node return ', () { + final doc = YamlEditor('{a: {d: 4}, c: ~}'); + final result = doc.parseAt(['b'], orElse: () => wrapAsYamlNode(null)); + + expect(result.value, equals(null)); + }); + + test('simple example with map return', () { + final doc = YamlEditor('{a: {d: 4}, c: ~}'); + final result = + doc.parseAt(['b'], orElse: () => wrapAsYamlNode({'a': 42})); + + expect(result, isA()); + expect(result.value, equals({'a': 42})); + }); + + test('simple example with scalar return', () { + final doc = YamlEditor('{a: {d: 4}, c: ~}'); + final result = doc.parseAt(['b'], orElse: () => wrapAsYamlNode(42)); + + expect(result, isA()); + expect(result.value, equals(42)); + }); + + test('simple example with list return', () { + final doc = YamlEditor('{a: {d: 4}, c: ~}'); + final result = doc.parseAt(['b'], orElse: () => wrapAsYamlNode([42])); + + expect(result, isA()); + expect(result.value, equals([42])); + }); + }); + + group('returns a YamlNode', () { + test('with the correct type', () { + final doc = YamlEditor("YAML: YAML Ain't Markup Language"); + final expectedYamlScalar = doc.parseAt(['YAML']); + + expect(expectedYamlScalar, isA()); + }); + + test('with the correct value', () { + final doc = YamlEditor("YAML: YAML Ain't Markup Language"); + + expect(doc.parseAt(['YAML']).value, "YAML Ain't Markup Language"); + }); + + test('with the correct value in nested collection', () { + final doc = YamlEditor(''' +a: 1 +b: + d: 4 + e: [5, 6, 7] +c: 3 +'''); + + expect(doc.parseAt(['b', 'e', 2]).value, 7); + }); + + test('with a null value in nested collection', () { + final doc = YamlEditor(''' +key1: + key2: null +'''); + + expect(doc.parseAt(['key1', 'key2']).value, null); + }); + + test('with the correct type (2)', () { + final doc = YamlEditor("YAML: YAML Ain't Markup Language"); + final expectedYamlMap = doc.parseAt([]); + + expect(expectedYamlMap is YamlMap, equals(true)); + }); + + test('that is immutable', () { + final doc = YamlEditor("YAML: YAML Ain't Markup Language"); + final expectedYamlMap = doc.parseAt([]); + + expect(() => (expectedYamlMap as YamlMap)['YAML'] = 'test', + throwsUnsupportedError); + }); + + test('that has immutable children', () { + final doc = YamlEditor("YAML: ['Y', 'A', 'M', 'L']"); + final expectedYamlMap = doc.parseAt([]); + + expect(() => ((expectedYamlMap as YamlMap)['YAML'] as List)[0] = 'X', + throwsUnsupportedError); + }); + }); + + test('works with map keys', () { + final doc = YamlEditor('{a: {{[1, 2]: 3}: 4}}'); + expect( + doc.parseAt([ + 'a', + { + [1, 2]: 3 + } + ]).value, + equals(4)); + }); + + test('works with null in path', () { + final doc = YamlEditor('{a: { ~: 4}}'); + expect(doc.parseAt(['a', null]).value, equals(4)); + }); + + test('works with null value', () { + final doc = YamlEditor('{a: null}'); + expect(doc.parseAt(['a']).value, equals(null)); + }); +} diff --git a/pkgs/yaml_edit/test/prepend_test.dart b/pkgs/yaml_edit/test/prepend_test.dart new file mode 100644 index 000000000..311265317 --- /dev/null +++ b/pkgs/yaml_edit/test/prepend_test.dart @@ -0,0 +1,169 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:test/test.dart'; +import 'package:yaml/yaml.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + +import 'test_utils.dart'; + +void main() { + group('throws PathError', () { + test('if it is a map', () { + final doc = YamlEditor('a:1'); + expect(() => doc.prependToList([], 4), throwsPathError); + }); + + test('if it is a scalar', () { + final doc = YamlEditor('1'); + expect(() => doc.prependToList([], 4), throwsPathError); + }); + }); + + group('flow list', () { + test('(1)', () { + final doc = YamlEditor('[1, 2]'); + doc.prependToList([], 0); + expect(doc.toString(), equals('[0, 1, 2]')); + expectYamlBuilderValue(doc, [0, 1, 2]); + }); + + test('null value', () { + final doc = YamlEditor('[1, 2]'); + doc.prependToList([], null); + expect(doc.toString(), equals('[null, 1, 2]')); + expectYamlBuilderValue(doc, [null, 1, 2]); + }); + + test('with spaces (1)', () { + final doc = YamlEditor('[ 1 , 2 ]'); + doc.prependToList([], 0); + expect(doc.toString(), equals('[ 0, 1 , 2 ]')); + expectYamlBuilderValue(doc, [0, 1, 2]); + }); + }); + + group('block list', () { + test('(1)', () { + final doc = YamlEditor(''' +- 1 +- 2'''); + doc.prependToList([], 0); + expect(doc.toString(), equals(''' +- 0 +- 1 +- 2''')); + expectYamlBuilderValue(doc, [0, 1, 2]); + }); + + /// Regression testing for no trailing spaces. + test('(2)', () { + final doc = YamlEditor('''- 1 +- 2'''); + doc.prependToList([], 0); + expect(doc.toString(), equals('''- 0 +- 1 +- 2''')); + expectYamlBuilderValue(doc, [0, 1, 2]); + }); + + test('(3)', () { + final doc = YamlEditor(''' +- 1 +- 2 +'''); + doc.prependToList([], [4, 5, 6]); + expect(doc.toString(), equals(''' +- - 4 + - 5 + - 6 +- 1 +- 2 +''')); + expectYamlBuilderValue(doc, [ + [4, 5, 6], + 1, + 2 + ]); + }); + + test('(4)', () { + final doc = YamlEditor(''' +a: + - b + - - c + - d +'''); + doc.prependToList( + ['a'], wrapAsYamlNode({1: 2}, collectionStyle: CollectionStyle.FLOW)); + + expect(doc.toString(), equals(''' +a: + - {1: 2} + - b + - - c + - d +''')); + expectYamlBuilderValue(doc, { + 'a': [ + {1: 2}, + 'b', + ['c', 'd'] + ] + }); + }); + + test('with comments ', () { + final doc = YamlEditor(''' +# comments +- 1 # comments +- 2 +'''); + doc.prependToList([], 0); + expect(doc.toString(), equals(''' +# comments +- 0 +- 1 # comments +- 2 +''')); + expectYamlBuilderValue(doc, [0, 1, 2]); + }); + + test('nested in map', () { + final doc = YamlEditor(''' +a: + - 1 + - 2 +'''); + doc.prependToList(['a'], 0); + expect(doc.toString(), equals(''' +a: + - 0 + - 1 + - 2 +''')); + expectYamlBuilderValue(doc, { + 'a': [0, 1, 2] + }); + }); + + test('nested in map with comments ', () { + final doc = YamlEditor(''' +a: # comments + - 1 # comments + - 2 +'''); + doc.prependToList(['a'], 0); + expect(doc.toString(), equals(''' +a: # comments + - 0 + - 1 # comments + - 2 +''')); + expectYamlBuilderValue(doc, { + 'a': [0, 1, 2] + }); + }); + }); +} diff --git a/pkgs/yaml_edit/test/preservation_test.dart b/pkgs/yaml_edit/test/preservation_test.dart new file mode 100644 index 000000000..a763296e6 --- /dev/null +++ b/pkgs/yaml_edit/test/preservation_test.dart @@ -0,0 +1,61 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:test/test.dart'; + +import 'test_utils.dart'; + +void main() { + group('preserves original yaml: ', () { + test('number', expectLoadPreservesYAML('2')); + test('number with leading and trailing lines', expectLoadPreservesYAML(''' + + 2 + + ''')); + test('octal numbers', expectLoadPreservesYAML('0o14')); + test('negative numbers', expectLoadPreservesYAML('-345')); + test('hexadecimal numbers', expectLoadPreservesYAML('0x123abc')); + test('floating point numbers', expectLoadPreservesYAML('345.678')); + test('exponential numbers', expectLoadPreservesYAML('12.3015e+02')); + test('string', expectLoadPreservesYAML('a string')); + test('string with control characters', + expectLoadPreservesYAML('a string \\n')); + test('string with control characters', + expectLoadPreservesYAML('a string \n\r')); + test('string with hex escapes', + expectLoadPreservesYAML('\\x0d\\x0a is \\r\\n')); + test('flow map', expectLoadPreservesYAML('{a: 2}')); + test('flow list', expectLoadPreservesYAML('[1, 2]')); + test('flow list with different types of elements', + expectLoadPreservesYAML('[1, a]')); + test('flow list with weird spaces', + expectLoadPreservesYAML('[ 1 , 2]')); + test('multiline string', expectLoadPreservesYAML(''' + Mark set a major league + home run record in 1998.''')); + test('tilde', expectLoadPreservesYAML('~')); + test('false', expectLoadPreservesYAML('false')); + + test('block map', expectLoadPreservesYAML('''a: + b: 1 + ''')); + test('block list', expectLoadPreservesYAML('''a: + - 1 + ''')); + test('complicated example', () { + expectLoadPreservesYAML('''verb: RecommendCafes +map: + a: + b: 1 +recipe: + - verb: Score + outputs: ["DishOffering[]/Scored", "Suggestions"] + name: Hotpot + - verb: Rate + inputs: Dish + '''); + }); + }); +} diff --git a/pkgs/yaml_edit/test/problem_strings.dart b/pkgs/yaml_edit/test/problem_strings.dart new file mode 100644 index 000000000..527a9e0b7 --- /dev/null +++ b/pkgs/yaml_edit/test/problem_strings.dart @@ -0,0 +1,91 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// ignore_for_file: lines_longer_than_80_chars + +const problemStrings = [ + '[]', + '{}', + '', + ',', + '~', + 'undefined', + 'undef', + 'null', + 'NULL', + '(null)', + 'nil', + 'NIL', + 'true', + 'false', + 'True', + 'False', + 'TRUE', + 'FALSE', + 'None', + '\\', + '\\\\', + '0', + '1', + '\$1.00', + '1/2', + '1E2', + '-\$1.00', + '-1/2', + '-1E+02', + '1/0', + '0/0', + '-0', + '+0.0', + '0..0', + '.', + '0.0.0', + '0,00', + ',', + '0.0/0', + '1.0/0.0', + '0.0/0.0', + '--1', + '-', + '-.', + '-,', + '999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999', + 'NaN', + 'Infinity', + '-Infinity', + 'INF', + '1#INF', + '0x0', + '0xffffffffffffffff', + "1'000.00", + '1,000,000.00', + '1.000,00', + "1'000,00", + '1.000.000,00', + ",./;'[]\\-=", + '<>?:"{}|_+', + '!@#\$%^&*()`~', + '\u0001\u0002\u0003\u0004\u0005\u0006\u0007\b\u000e\u000f\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017\u0018\u0019\u001a\u001b\u001c\u001d\u001e\u001f', + '\t\u000b\f …             ​

   ', + 'ด้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็ ด้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็ ด้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็็้้้้้้้้็็็็็้้้้้็็็็', + "'", + '"', + "''", + '\'"', + "'\"'", + '社會科學院語學研究所', + 'Ⱥ', + 'ヽ༼ຈل͜ຈ༽ノ ヽ༼ຈل͜ຈ༽ノ', + '❤️ 💔 💌 💕 💞 💓 💗 💖 💘 💝 💟 💜 💛 💚 💙', + '𝕋𝕙𝕖 𝕢𝕦𝕚𝕔𝕜 𝕓𝕣𝕠𝕨𝕟 𝕗𝕠𝕩 𝕛𝕦𝕞𝕡𝕤 𝕠𝕧𝕖𝕣 𝕥𝕙𝕖 𝕝𝕒𝕫𝕪 𝕕𝕠𝕘', + ' ', + '%', + '%d', + '%s%s%s%s%s', + '{0}', + '%*.*s', + '%@', + '%n', + 'The quic\b\b\b\b\b\bk brown fo\u0007\u0007\u0007\u0007\u0007\u0007\u0007\u0007\u0007\u0007\u0007x... [Beeeep]', +]; diff --git a/pkgs/yaml_edit/test/random_test.dart b/pkgs/yaml_edit/test/random_test.dart new file mode 100644 index 000000000..85cea4a7c --- /dev/null +++ b/pkgs/yaml_edit/test/random_test.dart @@ -0,0 +1,311 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:math' show Random; + +import 'package:test/test.dart'; +import 'package:yaml/yaml.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + +import 'problem_strings.dart'; +import 'test_utils.dart'; + +/// Performs naive fuzzing on an initial YAML file based on an initial seed. +/// +/// Starting with a template YAML, we randomly generate modifications and their +/// inputs (boolean, null, strings, or numbers) to modify the YAML and assert +/// that the change produced was expected. +void main() { + final generator = _Generator(maxDepth: 5); + + const roundsOfTesting = 40; + const modificationsPerRound = 1000; + + for (var i = 0; i < roundsOfTesting; i++) { + test( + 'testing with randomly generated modifications: test $i', + () { + final editor = YamlEditor(''' +name: yaml_edit +description: A library for YAML manipulation with comment and whitespace preservation. +version: 0.0.1-dev + +environment: + sdk: ">=2.4.0 <3.0.0" + +dependencies: + meta: ^1.1.8 + quiver_hashcode: ^2.0.0 + +dev_dependencies: + pedantic: ^1.9.0 + test: ^1.14.4 +'''); + + for (var j = 0; j < modificationsPerRound; j++) { + expect( + () => generator.performNextModification(editor, i), + returnsNormally, + ); + } + }, + ); + } +} + +/// Generates the random variables we need for fuzzing. +class _Generator { + final Random r; + + /// 2^32 + static const int maxInt = 4294967296; + + /// Maximum depth of random YAML collection generated. + final int maxDepth; + + // ignore: unused_element + _Generator({int seed = 0, required this.maxDepth}) : r = Random(seed); + + int nextInt([int max = maxInt]) => r.nextInt(max); + + double nextDouble() => r.nextDouble(); + + bool nextBool() => r.nextBool(); + + /// Generates a new string by individually generating characters and + /// appending them to a buffer. Currently only generates strings from + /// ascii 32 - 127. + String nextString() { + if (nextBool()) { + return problemStrings[nextInt(problemStrings.length)]; + } + + final length = nextInt(100); + final buffer = StringBuffer(); + + for (var i = 0; i < length; i++) { + final charCode = nextInt(95) + 32; + buffer.writeCharCode(charCode); + } + + return buffer.toString(); + } + + /// Generates a new scalar recognizable by YAML. + Object? nextScalar() { + final typeIndex = nextInt(5); + + switch (typeIndex) { + case 0: + return nextBool(); + case 1: + return nextDouble(); + case 2: + return nextInt(); + case 3: + return null; + default: + return nextString(); + } + } + + YamlScalar nextYamlScalar() { + return wrapAsYamlNode(nextScalar(), scalarStyle: nextScalarStyle()) + as YamlScalar; + } + + /// Generates the next [YamlList], with the current [depth]. + YamlList nextYamlList(int depth) { + final length = nextInt(9); + final list = []; + + for (var i = 0; i < length; i++) { + list.add(nextYamlNode(depth + 1)); + } + + return wrapAsYamlNode(list, collectionStyle: nextCollectionStyle()) + as YamlList; + } + + /// Generates the next [YamlList], with the current [depth]. + YamlMap nextYamlMap(int depth) { + final length = nextInt(9); + final nodes = {}; + + for (var i = 0; i < length; i++) { + nodes[nextYamlNode(depth + 1)] = nextYamlScalar(); + } + + return wrapAsYamlNode(nodes, collectionStyle: nextCollectionStyle()) + as YamlMap; + } + + /// Returns a [YamlNode], with it being a [YamlScalar] 80% of the time, a + /// [YamlList] 10% of the time, and a [YamlMap] 10% of the time. + /// + /// If [depth] is greater than [maxDepth], we instantly return a [YamlScalar] + /// to prevent the parent from growing any further, to improve our speeds. + YamlNode nextYamlNode([int depth = 0]) { + if (depth >= maxDepth) { + return nextYamlScalar(); + } + + final roll = nextInt(10); + + if (roll < 8) { + return nextYamlScalar(); + } else if (roll == 8) { + return nextYamlList(depth); + } else { + return nextYamlMap(depth); + } + } + + /// Performs a random modification + void performNextModification(YamlEditor editor, int count) { + final path = findPath(editor); + final node = editor.parseAt(path); + final initialString = editor.toString(); + final args = []; + var method = YamlModificationMethod.remove; + + try { + if (node is YamlScalar) { + editor.remove(path); + return; + } + + if (node is YamlList) { + final methodIndex = nextInt(YamlModificationMethod.values.length); + method = YamlModificationMethod.values[methodIndex]; + + switch (method) { + case YamlModificationMethod.remove: + editor.remove(path); + break; + case YamlModificationMethod.update: + if (node.isEmpty) break; + final index = nextInt(node.length); + args.add(nextYamlNode()); + path.add(index); + editor.update(path, args[0]); + break; + case YamlModificationMethod.appendTo: + args.add(nextYamlNode()); + editor.appendToList(path, args[0]); + break; + case YamlModificationMethod.prependTo: + args.add(nextYamlNode()); + editor.prependToList(path, args[0]); + break; + case YamlModificationMethod.insert: + args.add(nextInt(node.length + 1)); + args.add(nextYamlNode()); + editor.insertIntoList(path, args[0] as int, args[1]); + break; + case YamlModificationMethod.splice: + args.add(nextInt(node.length + 1)); + args.add(nextInt(node.length + 1 - (args[0] as int))); + args.add(nextYamlList(0)); + editor.spliceList( + path, args[0] as int, args[1] as int, args[2] as List); + break; + } + return; + } + + if (node is YamlMap) { + final replace = nextBool(); + method = YamlModificationMethod.update; + + if (replace && node.isNotEmpty) { + final keyList = node.keys.toList(); + path.add(keyList[nextInt(keyList.length)]); + } else { + path.add(nextScalar()); + } + final value = nextYamlNode(); + args.add(value); + editor.update(path, value); + return; + } + } catch (error, stacktrace) { + /// TODO: Fix once reproducible. Identify pattern. + if (count == 20) return; + + print(''' +Failed to call $method on: +$initialString +with the following arguments: +$args +and path: +$path + +Error Details: +$error + +$stacktrace +'''); + rethrow; + } + + throw AssertionError('Got invalid node'); + } + + /// Obtains a random path by traversing [editor]. + /// + /// At every node, we return the path to the node if the node has no children. + /// Otherwise, we return at a 50% chance, or traverse to one random child. + List findPath(YamlEditor editor) { + final path = []; + + // 50% chance of stopping at the collection + while (nextBool()) { + final node = editor.parseAt(path); + + if (node is YamlList && node.isNotEmpty) { + path.add(nextInt(node.length)); + } else if (node is YamlMap && node.isNotEmpty) { + final keyList = node.keys.toList(); + path.add(keyList[nextInt(keyList.length)]); + } else { + break; + } + } + + return path; + } + + ScalarStyle nextScalarStyle() { + final seed = nextInt(6); + + switch (seed) { + case 0: + return ScalarStyle.DOUBLE_QUOTED; + case 1: + return ScalarStyle.FOLDED; + case 2: + return ScalarStyle.LITERAL; + case 3: + return ScalarStyle.PLAIN; + case 4: + return ScalarStyle.SINGLE_QUOTED; + default: + return ScalarStyle.ANY; + } + } + + CollectionStyle nextCollectionStyle() { + final seed = nextInt(3); + + switch (seed) { + case 0: + return CollectionStyle.BLOCK; + case 1: + return CollectionStyle.FLOW; + default: + return CollectionStyle.ANY; + } + } +} diff --git a/pkgs/yaml_edit/test/remove_test.dart b/pkgs/yaml_edit/test/remove_test.dart new file mode 100644 index 000000000..4742b5608 --- /dev/null +++ b/pkgs/yaml_edit/test/remove_test.dart @@ -0,0 +1,603 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:test/test.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + +import 'test_utils.dart'; + +void main() { + group('throws', () { + test('PathError if collectionPath points to a scalar', () { + final doc = YamlEditor(''' +a: 1 +b: 2 +c: 3 +'''); + + expect(() => doc.remove(['a', 0]), throwsPathError); + }); + + test('PathError if collectionPath is invalid', () { + final doc = YamlEditor(''' +a: 1 +b: 2 +c: 3 +'''); + + expect(() => doc.remove(['d']), throwsPathError); + }); + + test('PathError if collectionPath is invalid in nested path', () { + final doc = YamlEditor(''' +a: + b: 'foo' +'''); + + expect(() => doc.remove(['d']), throwsPathError); + }); + + test('PathError if collectionPath is invalid - list', () { + final doc = YamlEditor(''' +[1, 2, 3] +'''); + + expect(() => doc.remove([4]), throwsPathError); + }); + + test('PathError in list if using a non-integer as index', () { + final doc = YamlEditor("{ a: ['b', 'c'] }"); + expect(() => doc.remove(['a', 'b']), throwsPathError); + }); + + test('PathError if path is invalid', () { + final doc = YamlEditor("{ a: ['b', 'c'] }"); + expect(() => doc.remove(['a', 0, '1']), throwsPathError); + }); + }); + + group('returns', () { + test('returns the removed node when successful', () { + final doc = YamlEditor('{ a: { b: foo } }'); + final node = doc.remove(['a', 'b']); + expect(node.value, equals('foo')); + }); + + test('returns null-value node when doc is empty and path is empty', () { + final doc = YamlEditor(''); + final node = doc.remove([]); + expect(node.value, equals(null)); + }); + }); + + test('empty path should clear string', () { + final doc = YamlEditor(''' +a: 1 +b: 2 +c: [3, 4] +'''); + doc.remove([]); + expect(doc.toString(), equals('')); + }); + + group('block map', () { + test('(1)', () { + final doc = YamlEditor(''' +a: 1 +b: 2 +c: 3 +'''); + doc.remove(['b']); + expect(doc.toString(), equals(''' +a: 1 +c: 3 +''')); + }); + + test('empty value', () { + final doc = YamlEditor(''' +a: 1 +b: +c: 3 +'''); + doc.remove(['b']); + expect(doc.toString(), equals(''' +a: 1 +c: 3 +''')); + }); + + test('empty value (2)', () { + final doc = YamlEditor(''' +- a: 1 + b: + c: 3 +'''); + doc.remove([0, 'b']); + expect(doc.toString(), equals(''' +- a: 1 + c: 3 +''')); + }); + + test('empty value (3)', () { + final doc = YamlEditor(''' +- a: 1 + b: + + c: 3 +'''); + doc.remove([0, 'b']); + expect(doc.toString(), equals(''' +- a: 1 + + c: 3 +''')); + }); + + test('preserves comments', () { + final doc = YamlEditor(''' +a: 1 # preserved 1 +# preserved 2 +b: 2 +# preserved 3 +c: 3 # preserved 4 +'''); + doc.remove(['b']); + expect(doc.toString(), equals(''' +a: 1 # preserved 1 +# preserved 2 +# preserved 3 +c: 3 # preserved 4 +''')); + }); + + test('final element in map', () { + final doc = YamlEditor(''' +a: 1 +b: 2 +'''); + doc.remove(['b']); + expect(doc.toString(), equals(''' +a: 1 +''')); + }); + + test('final element in nested map', () { + final doc = YamlEditor(''' +a: + aa: 11 + bb: 22 +b: 2 +'''); + doc.remove(['a', 'bb']); + expect(doc.toString(), equals(''' +a: + aa: 11 +b: 2 +''')); + }); + + test('last element should return flow empty map', () { + final doc = YamlEditor(''' +a: 1 +'''); + doc.remove(['a']); + expect(doc.toString(), equals(''' +{} +''')); + }); + + test('last element should return flow empty map (2)', () { + final doc = YamlEditor(''' +- a: 1 +- b: 2 +'''); + doc.remove([0, 'a']); + expect(doc.toString(), equals(''' +- {} +- b: 2 +''')); + }); + + test('nested', () { + final doc = YamlEditor(''' +a: 1 +b: + d: 4 + e: 5 +c: 3 +'''); + doc.remove(['b', 'd']); + expect(doc.toString(), equals(''' +a: 1 +b: + e: 5 +c: 3 +''')); + }); + + test('issue #55 reopend', () { + final doc = YamlEditor('''name: sample +version: 0.1.0 +environment: + sdk: ^3.0.0 +dependencies: + retry: ^3.1.2 +dev_dependencies: + retry:'''); + doc.remove(['dev_dependencies']); + }); + + test('issue #55 reopend, variant 2', () { + final doc = YamlEditor('''name: sample +version: 0.1.0 +environment: + sdk: ^3.0.0 +dependencies: + retry: ^3.1.2 +dev_dependencies: + retry:'''); + doc.remove(['dev_dependencies', 'retry']); + }); + }); + + group('flow map', () { + test('(1)', () { + final doc = YamlEditor('{a: 1, b: 2, c: 3}'); + doc.remove(['b']); + expect(doc.toString(), equals('{a: 1, c: 3}')); + }); + + test('(2) ', () { + final doc = YamlEditor('{a: 1}'); + doc.remove(['a']); + expect(doc.toString(), equals('{}')); + }); + + test('(3) ', () { + final doc = YamlEditor('{a: 1, b: 2}'); + doc.remove(['a']); + expect(doc.toString(), equals('{ b: 2}')); + }); + + test('(4) ', () { + final doc = + YamlEditor('{"{}[],": {"{}[],": 1, b: "}{[]},", "}{[],": 3}}'); + doc.remove(['{}[],', 'b']); + expect(doc.toString(), equals('{"{}[],": {"{}[],": 1, "}{[],": 3}}')); + }); + + test('empty value', () { + final doc = YamlEditor('{a: 1, b:, c: 3}'); + doc.remove(['b']); + expect(doc.toString(), equals('{a: 1, c: 3}')); + }); + + test('nested flow map ', () { + final doc = YamlEditor('{a: 1, b: {d: 4, e: 5}, c: 3}'); + doc.remove(['b', 'd']); + expect(doc.toString(), equals('{a: 1, b: { e: 5}, c: 3}')); + }); + + test('nested flow map (2)', () { + final doc = YamlEditor('{a: {{[1] : 2}: 3, b: 2}}'); + doc.remove([ + 'a', + { + [1]: 2 + } + ]); + expect(doc.toString(), equals('{a: { b: 2}}')); + }); + }); + + group('block list', () { + test('empty value', () { + final doc = YamlEditor(''' +- 0 +- +- 2 +'''); + doc.remove([1]); + expect(doc.toString(), equals(''' +- 0 +- 2 +''')); + }); + + test('last element should return flow empty list', () { + final doc = YamlEditor(''' +- 0 +'''); + doc.remove([0]); + expect(doc.toString(), equals(''' +[] +''')); + }); + + test('last element should return flow empty list (2)', () { + final doc = YamlEditor(''' +a: + - 1 +b: [3] +'''); + doc.remove(['a', 0]); + expect(doc.toString(), equals(''' +a: + [] +b: [3] +''')); + }); + + test('last element should return flow empty list (3)', () { + final doc = YamlEditor(''' +a: + - 1 +b: + - 3 +'''); + doc.remove(['a', 0]); + expect(doc.toString(), equals(''' +a: + [] +b: + - 3 +''')); + }); + + test('(1) ', () { + final doc = YamlEditor(''' +- 0 +- 1 +- 2 +- 3 +'''); + doc.remove([1]); + expect(doc.toString(), equals(''' +- 0 +- 2 +- 3 +''')); + expectYamlBuilderValue(doc, [0, 2, 3]); + }); + + test('(2)', () { + final doc = YamlEditor(''' +- 0 +- [1,2,3] +- 2 +- 3 +'''); + doc.remove([1]); + expect(doc.toString(), equals(''' +- 0 +- 2 +- 3 +''')); + expectYamlBuilderValue(doc, [0, 2, 3]); + }); + + test('(3)', () { + final doc = YamlEditor(''' +- 0 +- {a: 1, b: 2} +- 2 +- 3 +'''); + doc.remove([1]); + expect(doc.toString(), equals(''' +- 0 +- 2 +- 3 +''')); + expectYamlBuilderValue(doc, [0, 2, 3]); + }); + + test('last element', () { + final doc = YamlEditor(''' +- 0 +- 1 +'''); + doc.remove([1]); + expect(doc.toString(), equals(''' +- 0 +''')); + expectYamlBuilderValue(doc, [0]); + }); + + test('with comments', () { + final doc = YamlEditor(''' +- 0 # comment 0 +# comment 1 +- 1 # comment 2 +# comment 3 +- 2 # comment 4 +- 3 +'''); + doc.remove([1]); + expect(doc.toString(), equals(''' +- 0 # comment 0 +# comment 1 +# comment 3 +- 2 # comment 4 +- 3 +''')); + expectYamlBuilderValue(doc, [0, 2, 3]); + }); + + test('nested list', () { + final doc = YamlEditor(''' +- - - 0 + - 1 +'''); + doc.remove([0, 0, 0]); + expect(doc.toString(), equals(''' +- - - 1 +''')); + expectYamlBuilderValue(doc, [ + [ + [1] + ] + ]); + }); + + test('nested list (2)', () { + final doc = YamlEditor(''' +- - 0 + - 1 +- 2 +'''); + doc.remove([0]); + expect(doc.toString(), equals(''' +- 2 +''')); + expectYamlBuilderValue(doc, [2]); + }); + + test('nested list (3)', () { + final doc = YamlEditor(''' +- - 0 + - 1 +- 2 +'''); + doc.remove([0, 1]); + expect(doc.toString(), equals(''' +- - 0 +- 2 +''')); + expectYamlBuilderValue(doc, [ + [0], + 2 + ]); + }); + + test('nested list (4)', () { + final doc = YamlEditor(''' +- + - - 0 + - 1 + - 2 +'''); + doc.remove([0, 0, 1]); + expect(doc.toString(), equals(''' +- + - - 0 + - 2 +''')); + expectYamlBuilderValue(doc, [ + [ + [0], + 2 + ] + ]); + }); + + test('nested list (5)', () { + final doc = YamlEditor(''' +- - 0 + - + 1 +'''); + doc.remove([0, 0]); + expect(doc.toString(), equals(''' +- - + 1 +''')); + expectYamlBuilderValue(doc, [ + [1] + ]); + }); + + test('nested list (6)', () { + final doc = YamlEditor(''' +- - 0 # - + # - + - + 1 +'''); + doc.remove([0, 0]); + expect(doc.toString(), equals(''' +- # - + - + 1 +''')); + expectYamlBuilderValue(doc, [ + [1] + ]); + }); + + test('nested map', () { + final doc = YamlEditor(''' +- - a: b + c: d +'''); + doc.remove([0, 0, 'a']); + expect(doc.toString(), equals(''' +- - c: d +''')); + expectYamlBuilderValue(doc, [ + [ + {'c': 'd'} + ] + ]); + }); + + test('nested map (2)', () { + final doc = YamlEditor(''' +- a: + - 0 + - 1 + c: d +'''); + doc.remove([0, 'a', 1]); + expect(doc.toString(), equals(''' +- a: + - 0 + c: d +''')); + expectYamlBuilderValue(doc, [ + { + 'a': [0], + 'c': 'd' + } + ]); + }); + }); + + group('flow list', () { + test('(1)', () { + final doc = YamlEditor('[1, 2, 3]'); + doc.remove([1]); + expect(doc.toString(), equals('[1, 3]')); + expectYamlBuilderValue(doc, [1, 3]); + }); + + test('(2)', () { + final doc = YamlEditor('[1, "b", "c"]'); + doc.remove([0]); + expect(doc.toString(), equals('[ "b", "c"]')); + expectYamlBuilderValue(doc, ['b', 'c']); + }); + + test('(3)', () { + final doc = YamlEditor('[1, {a: 1}, "c"]'); + doc.remove([1]); + expect(doc.toString(), equals('[1, "c"]')); + expectYamlBuilderValue(doc, [1, 'c']); + }); + + test('(4) ', () { + final doc = YamlEditor('["{}", b, "}{"]'); + doc.remove([1]); + expect(doc.toString(), equals('["{}", "}{"]')); + }); + + test('(5) ', () { + final doc = YamlEditor('["{}[],", [test, "{}[],", "{}[],"], "{}[],"]'); + doc.remove([1, 0]); + expect(doc.toString(), equals('["{}[],", [ "{}[],", "{}[],"], "{}[],"]')); + }); + }); +} diff --git a/pkgs/yaml_edit/test/source_edit_test.dart b/pkgs/yaml_edit/test/source_edit_test.dart new file mode 100644 index 000000000..693455e96 --- /dev/null +++ b/pkgs/yaml_edit/test/source_edit_test.dart @@ -0,0 +1,139 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:test/test.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + +void main() { + group('SourceEdit', () { + group('fromJson', () { + test('converts from jsonEncode', () { + final sourceEditMap = { + 'offset': 1, + 'length': 2, + 'replacement': 'replacement string' + }; + final sourceEdit = SourceEdit.fromJson(sourceEditMap); + + expect(sourceEdit.offset, 1); + expect(sourceEdit.length, 2); + expect(sourceEdit.replacement, 'replacement string'); + }); + + test('throws formatException if offset is non-int', () { + final sourceEditJson = { + 'offset': '1', + 'length': 2, + 'replacement': 'replacement string' + }; + + expect( + () => SourceEdit.fromJson(sourceEditJson), throwsFormatException); + }); + + test('throws formatException if length is non-int', () { + final sourceEditJson = { + 'offset': 1, + 'length': '2', + 'replacement': 'replacement string' + }; + + expect( + () => SourceEdit.fromJson(sourceEditJson), throwsFormatException); + }); + + test('throws formatException if replacement is non-string', () { + final sourceEditJson = {'offset': 1, 'length': 2, 'replacement': 3}; + + expect( + () => SourceEdit.fromJson(sourceEditJson), throwsFormatException); + }); + + test('throws formatException if a field is not present', () { + final sourceEditJson = {'offset': 1, 'length': 2}; + + expect( + () => SourceEdit.fromJson(sourceEditJson), throwsFormatException); + }); + }); + + test('toString returns a nice string representation', () { + final sourceEdit = SourceEdit(1, 2, 'replacement string'); + expect(sourceEdit.toString(), + equals('SourceEdit(1, 2, "replacement string")')); + }); + + group('hashCode', () { + test('returns same value for equal SourceEdits', () { + final sourceEdit1 = SourceEdit(1, 2, 'replacement string'); + final sourceEdit2 = SourceEdit(1, 2, 'replacement string'); + expect(sourceEdit1.hashCode, equals(sourceEdit2.hashCode)); + }); + + test('returns different value for equal SourceEdits', () { + final sourceEdit1 = SourceEdit(1, 2, 'replacement string'); + final sourceEdit2 = SourceEdit(1, 3, 'replacement string'); + expect(sourceEdit1.hashCode == sourceEdit2.hashCode, equals(false)); + }); + }); + + group('toJson', () { + test('behaves as expected', () { + final sourceEdit = SourceEdit(1, 2, 'replacement string'); + final sourceEditJson = sourceEdit.toJson(); + + expect( + sourceEditJson, + equals({ + 'offset': 1, + 'length': 2, + 'replacement': 'replacement string' + })); + }); + + test('is compatible with fromJson', () { + final sourceEdit = SourceEdit(1, 2, 'replacement string'); + final sourceEditJson = sourceEdit.toJson(); + final newSourceEdit = SourceEdit.fromJson(sourceEditJson); + + expect(newSourceEdit.offset, 1); + expect(newSourceEdit.length, 2); + expect(newSourceEdit.replacement, 'replacement string'); + }); + }); + + group('applyAll', () { + test('returns original string when empty list is passed in', () { + const original = 'YAML: YAML'; + final result = SourceEdit.applyAll(original, []); + + expect(result, original); + }); + test('works with list of one SourceEdit', () { + const original = 'YAML: YAML'; + final sourceEdits = [SourceEdit(6, 4, 'YAML Ain\'t Markup Language')]; + + final result = SourceEdit.applyAll(original, sourceEdits); + + expect(result, "YAML: YAML Ain't Markup Language"); + }); + test('works with list of multiple SourceEdits', () { + const original = 'YAML: YAML'; + final sourceEdits = [ + SourceEdit(6, 4, "YAML Ain't Markup Language"), + SourceEdit(6, 4, "YAML Ain't Markup Language"), + SourceEdit(0, 4, "YAML Ain't Markup Language") + ]; + + final result = SourceEdit.applyAll(original, sourceEdits); + + expect( + result, + "YAML Ain't Markup Language: YAML Ain't Markup Language Ain't Markup " + 'Language', + ); + }); + }); + }); +} diff --git a/pkgs/yaml_edit/test/special_test.dart b/pkgs/yaml_edit/test/special_test.dart new file mode 100644 index 000000000..9e6c0114e --- /dev/null +++ b/pkgs/yaml_edit/test/special_test.dart @@ -0,0 +1,111 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:test/test.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + +import 'test_utils.dart'; + +void main() { + test('test if "No" is recognized as false', () { + final doc = YamlEditor(''' +~: null +false: false +No: No +true: true +'''); + doc.update([null], 'tilde'); + doc.update([false], false); + doc.update(['No'], 'no'); + doc.update([true], 'true'); + + expect(doc.toString(), equals(''' +~: tilde +false: false +No: no +true: "true" +''')); + + expectYamlBuilderValue( + doc, {null: 'tilde', false: false, 'No': 'no', true: 'true'}); + }); + + test('array keys are recognized', () { + final doc = YamlEditor('{[1,2,3]: a}'); + doc.update([ + [1, 2, 3] + ], 'sums to 6'); + + expect(doc.toString(), equals('{[1,2,3]: sums to 6}')); + expectYamlBuilderValue(doc, { + [1, 2, 3]: 'sums to 6' + }); + }); + + test('map keys are recognized', () { + final doc = YamlEditor('{{a: 1}: a}'); + doc.update([ + {'a': 1} + ], 'sums to 6'); + + expect(doc.toString(), equals('{{a: 1}: sums to 6}')); + expectYamlBuilderValue(doc, { + {'a': 1}: 'sums to 6' + }); + }); + + test('documents can have directives', () { + final doc = YamlEditor('''%YAML 1.2 +--- text'''); + doc.update([], 'test'); + + expect(doc.toString(), equals('%YAML 1.2\n--- test')); + expectYamlBuilderValue(doc, 'test'); + }); + + test('tags should be removed if value is changed', () { + final doc = YamlEditor(''' + - !!str a + - b + - !!int 42 + - d +'''); + doc.update([2], 'test'); + + expect(doc.toString(), equals(''' + - !!str a + - b + - test + - d +''')); + expectYamlBuilderValue(doc, ['a', 'b', 'test', 'd']); + }); + + test('tags should be removed if key is changed', () { + final doc = YamlEditor(''' +!!str a: b +c: !!int 42 +e: !!str f +g: h +!!str 23: !!bool false +'''); + doc.remove(['23']); + + expect(doc.toString(), equals(''' +!!str a: b +c: !!int 42 +e: !!str f +g: h +''')); + expectYamlBuilderValue(doc, {'a': 'b', 'c': 42, 'e': 'f', 'g': 'h'}); + }); + + test('detect invalid extra closing bracket', () { + final doc = YamlEditor('''[ a, b ]'''); + doc.appendToList([], 'c ]'); + + expect(doc.toString(), equals('''[ a, b , "c ]"]''')); + expectYamlBuilderValue(doc, ['a', 'b', 'c ]']); + }); +} diff --git a/pkgs/yaml_edit/test/splice_test.dart b/pkgs/yaml_edit/test/splice_test.dart new file mode 100644 index 000000000..3efeea1cc --- /dev/null +++ b/pkgs/yaml_edit/test/splice_test.dart @@ -0,0 +1,191 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:test/test.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + +import 'test_utils.dart'; + +void main() { + test( + 'throws RangeError if invalid index + deleteCount combination is ' + 'passed in', () { + final doc = YamlEditor('[0, 0]'); + expect(() => doc.spliceList([], 1, 5, [1, 2]), throwsRangeError); + }); + + group('block list', () { + test('(1)', () { + final doc = YamlEditor(''' +- 0 +- 0 +'''); + final nodes = doc.spliceList([], 1, 1, [1, 2]); + expect(doc.toString(), equals(''' +- 0 +- 1 +- 2 +''')); + + expectDeepEquals(nodes.toList(), [0]); + }); + + test('(2)', () { + final doc = YamlEditor(''' +- 0 +- 0 +'''); + final nodes = doc.spliceList([], 0, 2, [0, 1, 2]); + expect(doc.toString(), equals(''' +- 0 +- 1 +- 2 +''')); + + expectDeepEquals(nodes.toList(), [0, 0]); + }); + + test('(3)', () { + final doc = YamlEditor(''' +- Jan +- March +- April +- June +'''); + final nodes = doc.spliceList([], 1, 0, ['Feb']); + expect(doc.toString(), equals(''' +- Jan +- Feb +- March +- April +- June +''')); + + expectDeepEquals(nodes.toList(), []); + + final nodes2 = doc.spliceList([], 4, 1, ['May']); + expect(doc.toString(), equals(''' +- Jan +- Feb +- March +- April +- May +''')); + + expectDeepEquals(nodes2.toList(), ['June']); + }); + + test('nested block list (inline)', () { + final doc = YamlEditor(''' +- - Jan + - Tuesday + - April +'''); + + final nodes = doc.spliceList([0], 1, 1, ['Feb', 'March']); + + expectDeepEquals(nodes.toList(), ['Tuesday']); + + expect(doc.toString(), equals(''' +- - Jan + - Feb + - March + - April +''')); + }); + + test('nested block list (inline with multiple new lines)', () { + final doc = YamlEditor(''' +- + + + + + - Jan + - Tuesday + - April +'''); + + final nodes = doc.spliceList([0], 1, 1, ['Feb', 'March']); + + expectDeepEquals(nodes.toList(), ['Tuesday']); + + expect(doc.toString(), equals(''' +- + + + + + - Jan + - Feb + - March + - April +''')); + }); + + test('update before nested list', () { + final doc = YamlEditor(''' +key: + - value + - another + - - nested + - continued +'''); + + final nodes = doc.spliceList(['key'], 2, 0, ['spliced']); + + expectDeepEquals(nodes.toList(), []); + + expect(doc.toString(), equals(''' +key: + - value + - another + - spliced + - - nested + - continued +''')); + }); + + test('replace nested block', () { + final doc = YamlEditor(''' +key: + - value + - another + - - nested + - continued +'''); + + final nodes = doc.spliceList(['key'], 2, 1, ['spliced']); + + expectDeepEquals(nodes.toList(), [ + ['nested', 'continued'], + ]); + + expect(doc.toString(), equals(''' +key: + - value + - another + - spliced +''')); + }); + }); + + group('flow list', () { + test('(1)', () { + final doc = YamlEditor('[0, 0]'); + final nodes = doc.spliceList([], 1, 1, [1, 2]); + expect(doc.toString(), equals('[0, 1, 2]')); + + expectDeepEquals(nodes.toList(), [0]); + }); + + test('(2)', () { + final doc = YamlEditor('[0, 0]'); + final nodes = doc.spliceList([], 0, 2, [0, 1, 2]); + expect(doc.toString(), equals('[0, 1, 2]')); + + expectDeepEquals(nodes.toList(), [0, 0]); + }); + }); +} diff --git a/pkgs/yaml_edit/test/string_test.dart b/pkgs/yaml_edit/test/string_test.dart new file mode 100644 index 000000000..5ea7e73c2 --- /dev/null +++ b/pkgs/yaml_edit/test/string_test.dart @@ -0,0 +1,49 @@ +// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:test/test.dart'; +import 'package:yaml/yaml.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + +final _testStrings = [ + "this is a fairly' long string with\nline breaks", + 'whitespace\n after line breaks', + 'whitespace\n \nbetween line breaks', + '\n line break at the start', + 'whitespace and line breaks at end 1\n ', + 'whitespace and line breaks at end 2 \n \n', + 'whitespace and line breaks at end 3 \n\n', + 'whitespace and line breaks at end 4 \n\n ', + '\n\nline with multiple trailing line break \n\n\n\n\n', + 'word', + 'foo bar', + 'foo\nbar', + '"', + '\'', + 'word"word', + 'word\'word' +]; + +final _scalarStyles = [ + ScalarStyle.ANY, + ScalarStyle.DOUBLE_QUOTED, + ScalarStyle.FOLDED, + ScalarStyle.LITERAL, + ScalarStyle.PLAIN, + ScalarStyle.SINGLE_QUOTED, +]; + +void main() { + for (final style in _scalarStyles) { + for (var i = 0; i < _testStrings.length; i++) { + final testString = _testStrings[i]; + test('Root $style string (${i + 1})', () { + final yamlEditor = YamlEditor(''); + yamlEditor.update([], wrapAsYamlNode(testString, scalarStyle: style)); + final yaml = yamlEditor.toString(); + expect(loadYaml(yaml), equals(testString)); + }); + } + } +} diff --git a/pkgs/yaml_edit/test/test_case.dart b/pkgs/yaml_edit/test/test_case.dart new file mode 100644 index 000000000..107e4f793 --- /dev/null +++ b/pkgs/yaml_edit/test/test_case.dart @@ -0,0 +1,299 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. 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:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:yaml/yaml.dart'; +import 'package:yaml_edit/src/utils.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + +import 'test_utils.dart'; + +/// Interface for creating golden Test cases +class TestCases { + final List<_TestCase> _testCases; + + /// Creates a [TestCases] object based on test directory and golden directory + /// path. + static Future getTestCases(Uri testDirUri, Uri goldDirUri) async { + final testDir = Directory.fromUri(testDirUri); + + if (!testDir.existsSync()) return TestCases([]); + + /// Recursively grab all the files in the testing directory. + return TestCases(await testDir + .list(recursive: true, followLinks: false) + .where((entity) => entity.path.endsWith('.test')) + .map((entity) => entity.uri) + .map((inputUri) { + final inputWithoutExtension = + p.basenameWithoutExtension(inputUri.toFilePath()); + final goldenUri = goldDirUri.resolve('./$inputWithoutExtension.golden'); + + return _TestCase(inputUri, goldenUri); + }).toList()); + } + + /// Tests all the [_TestCase]s if the golden files exist, create the golden + /// files otherwise. + void test() { + var tested = 0; + var created = 0; + + for (final testCase in _testCases) { + testCase.testOrCreate(); + if (testCase.state == _TestCaseStates.testedGoldenFile) { + tested++; + } else if (testCase.state == _TestCaseStates.createdGoldenFile) { + created++; + } + } + + print('Successfully tested $tested inputs against golden files, created ' + '$created golden files'); + } + + TestCases(this._testCases); + + int get length => _testCases.length; +} + +/// Enum representing the different states of [_TestCase]s. +enum _TestCaseStates { initialized, createdGoldenFile, testedGoldenFile } + +/// Interface for a golden test case. Handles the logic for test conduct/golden +/// test update accordingly. +class _TestCase { + final Uri inputUri; + final Uri goldenUri; + final List states = []; + + late String info; + late YamlEditor yamlBuilder; + late List<_YamlModification> modifications; + + String inputLineEndings = '\n'; + + _TestCaseStates state = _TestCaseStates.initialized; + + _TestCase(this.inputUri, this.goldenUri) { + final inputFile = File.fromUri(inputUri); + if (!inputFile.existsSync()) { + throw Exception('Input File does not exist!'); + } + + _initialize(inputFile); + } + + /// Initializes the [_TestCase] by reading the corresponding [inputFile] and + /// parsing the different portions, and then running the input yaml against + /// the specified modifications. + /// + /// Precondition: [inputFile] must exist, and inputs must be well-formatted. + void _initialize(File inputFile) { + final input = inputFile.readAsStringSync(); + + final inputLineEndings = getLineEnding(input); + final inputElements = input.split('---$inputLineEndings'); + + if (inputElements.length != 3) { + throw AssertionError('File ${inputFile.path} is not properly formatted.'); + } + + info = inputElements[0]; + yamlBuilder = YamlEditor(inputElements[1]); + final rawModifications = + _getValueFromYamlNode(loadYaml(inputElements[2]) as YamlNode) as List; + modifications = _parseModifications(rawModifications); + + /// Adds the initial state as well, so we can check that the simplest + /// parse -> immediately dump does not affect the string. + states.add(yamlBuilder.toString()); + + _performModifications(); + } + + void _performModifications() { + for (final mod in modifications) { + _performModification(mod); + states.add(yamlBuilder.toString()); + } + } + + void _performModification(_YamlModification mod) { + switch (mod.method) { + case YamlModificationMethod.update: + yamlBuilder.update(mod.path, mod.value); + return; + case YamlModificationMethod.remove: + yamlBuilder.remove(mod.path); + return; + case YamlModificationMethod.appendTo: + yamlBuilder.appendToList(mod.path, mod.value); + return; + case YamlModificationMethod.prependTo: + yamlBuilder.prependToList(mod.path, mod.value); + return; + case YamlModificationMethod.insert: + yamlBuilder.insertIntoList(mod.path, mod.index, mod.value); + return; + case YamlModificationMethod.splice: + yamlBuilder.spliceList( + mod.path, mod.index, mod.deleteCount, mod.value as List); + return; + } + } + + void testOrCreate() { + final goldenFile = File.fromUri(goldenUri); + if (!goldenFile.existsSync()) { + createGoldenFile(goldenFile); + } else { + testGoldenFile(goldenFile); + } + } + + void createGoldenFile(File goldenFile) { + /// Assumes user wants the golden file to have the same line endings as + /// the input file. + final goldenOutput = states.join('---$inputLineEndings'); + + goldenFile.writeAsStringSync(goldenOutput); + state = _TestCaseStates.createdGoldenFile; + } + + /// Tests the golden file. Ensures that the number of states are the same, and + /// that the individual states are the same. + void testGoldenFile(File goldenFile) { + final inputFileName = p.basename(inputUri.toFilePath()); + final golden = goldenFile.readAsStringSync(); + final goldenStates = golden.split('---${getLineEnding(golden)}'); + + group('testing $inputFileName - input and golden files have', () { + test('same number of states', () { + expect(states.length, equals(goldenStates.length)); + }); + + for (var i = 0; i < states.length; i++) { + test('same state $i', () { + expect(states[i], equals(goldenStates[i])); + }); + } + }); + + state = _TestCaseStates.testedGoldenFile; + } +} + +/// Converts [yamlList] into a Dart list. +List _getValueFromYamlList(YamlList yamlList) { + return yamlList.value.map((n) { + if (n is YamlNode) return _getValueFromYamlNode(n); + return n; + }).toList(); +} + +/// Converts [yamlMap] into a Dart Map. +Map _getValueFromYamlMap(YamlMap yamlMap) { + final keys = yamlMap.keys; + final result = {}; + for (final key in keys) { + final value = yamlMap[key]; + + if (value is YamlNode) { + result[key] = _getValueFromYamlNode(value); + } else { + result[key] = value; + } + } + + return result; +} + +/// Converts a [YamlNode] into a Dart object. +dynamic _getValueFromYamlNode(YamlNode node) { + if (node is YamlList) { + return _getValueFromYamlList(node); + } + if (node is YamlMap) { + return _getValueFromYamlMap(node); + } + return node.value; +} + +/// Converts the list of modifications from the raw input to [_YamlModification] +/// objects. +List<_YamlModification> _parseModifications(List modifications) { + return modifications.map((mod) { + if (mod is! List) throw UnimplementedError(); + Object? value; + var index = 0; + var deleteCount = 0; + final method = _getModificationMethod(mod[0] as String); + + final path = mod[1] as List; + + if (method == YamlModificationMethod.appendTo || + method == YamlModificationMethod.update || + method == YamlModificationMethod.prependTo) { + value = mod[2]; + } else if (method == YamlModificationMethod.insert) { + index = mod[2] as int; + value = mod[3]; + } else if (method == YamlModificationMethod.splice) { + index = mod[2] as int; + deleteCount = mod[3] as int; + + if (mod[4] is! List) { + throw ArgumentError('Invalid array ${mod[4]} used in splice'); + } + + value = mod[4]; + } + + return _YamlModification(method, path, index, value, deleteCount); + }).toList(); +} + +/// Gets the YAML modification method corresponding to [method] +YamlModificationMethod _getModificationMethod(String method) { + switch (method) { + case 'update': + return YamlModificationMethod.update; + case 'remove': + return YamlModificationMethod.remove; + case 'append': + case 'appendTo': + return YamlModificationMethod.appendTo; + case 'prepend': + case 'prependTo': + return YamlModificationMethod.prependTo; + case 'insert': + case 'insertIn': + return YamlModificationMethod.insert; + case 'splice': + return YamlModificationMethod.splice; + default: + throw Exception('$method not recognized!'); + } +} + +/// Class representing an abstract YAML modification to be performed +class _YamlModification { + final YamlModificationMethod method; + final List path; + final int index; + final dynamic value; + final int deleteCount; + + _YamlModification( + this.method, this.path, this.index, this.value, this.deleteCount); + + @override + String toString() => + 'method: $method, path: $path, index: $index, value: $value, ' + 'deleteCount: $deleteCount'; +} diff --git a/pkgs/yaml_edit/test/test_utils.dart b/pkgs/yaml_edit/test/test_utils.dart new file mode 100644 index 000000000..f60467d07 --- /dev/null +++ b/pkgs/yaml_edit/test/test_utils.dart @@ -0,0 +1,45 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:test/test.dart'; +import 'package:yaml_edit/src/equality.dart'; +import 'package:yaml_edit/src/errors.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + +/// Asserts that a string containing a single YAML document is unchanged +/// when dumped right after loading. +void Function() expectLoadPreservesYAML(String source) { + final doc = YamlEditor(source); + return () => expect(doc.toString(), equals(source)); +} + +/// Asserts that [builder] has the same internal value as [expected]. +void expectYamlBuilderValue(YamlEditor builder, Object expected) { + final builderValue = builder.parseAt([]); + expectDeepEquals(builderValue, expected); +} + +/// Asserts that [actual] has the same internal value as [expected]. +void expectDeepEquals(Object? actual, Object expected) { + expect( + actual, predicate((actual) => deepEquals(actual, expected), '$expected')); +} + +Matcher notEquals(dynamic expected) => isNot(equals(expected)); + +/// A matcher for functions that throw [PathError]. +Matcher throwsPathError = throwsA(isA()); + +/// A matcher for functions that throw [AliasException]. +Matcher throwsAliasException = throwsA(isA()); + +/// Enum to hold the possible modification methods. +enum YamlModificationMethod { + appendTo, + insert, + prependTo, + remove, + splice, + update, +} diff --git a/pkgs/yaml_edit/test/testdata/README.md b/pkgs/yaml_edit/test/testdata/README.md new file mode 100644 index 000000000..51452648b --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/README.md @@ -0,0 +1,79 @@ +# Golden Testing + +This folder contains the files used for Golden testing performed by [golden_test.dart](../golden_test.dart). + +With golden testing, we are able to quickly ensure that our output conforms to our expectations given input parameters, which are extremely valuable especially on complex test cases not easily captured by unit tests. + +When the tests are run (see [Running Tests](#Running-Tests)), the series of specified modifications will be performed on the input, and the various output states will be compared against the `.golden` files if they exist. Otherwise, if the `.golden` files do not exist (such as in the case of a new test case), they will be created. + +## Table of Contents + +1. [Running Tests](#Running-Tests) +1. [Input Format](#Input-Format) +1. [Adding Test Cases](#Adding-Test-Cases) +1. [Output Format](#Output-Format) + +## Running Tests + +By default, golden testing is performed with `pub run test`. If we only wanted to +performed golden testing, simply do: `pub run test test/golden_test.dart`. + +## Input Format + +Input files have the following format: + +``` +INFORMATION (e.g. description) - parsed as text +--- +INPUT - parsed as YAML +--- +Modifications - parsed as YAML, must be a list. +``` + +The information section is meant for a brief description of your test, and other further elaboration on what your test is targeted at (e.g. modification of complex keys). The Input section should be the YAML that you wish to parse, and the modifications section should be a list of modification operations, formatted as a YAML list. The valid list of modifications are as follows: + +- [update, [ path ], newValue] +- [remove, [ path ], keyOrIndex] +- [append, [ collectionPath ], newValue] + +An example of what an input file might look like is: + +``` +BASIC LIST TEST - Ensures that the basic list operations work. +--- +- 0 +- 1 +- 2 +- 3 +--- +- [remove, [1]] +- [append, [], 4] +``` + +Note that the parser uses `\n---\n` as the delimiter to separate the different sections. + +## Adding Test Cases + +To add test cases, simple create `.test` files in `/test/testdata/input` in the format explained in [Input Format](#Input-Format). When the test script is first run, the respective `.golden` files will be created in `/test/testdata/output`, you should check to ensure that the output is as expected since future collaborators will be counting on your output! + +## Output Format + +The output `.golden` files contain a series of YAML strings representing the state of the YAML after each specified modification, with the first string being the initial state as specified in the output. These states are separated by `\n---\n` as a delimiter. For example, the output file for the sample input file above is: + +``` +- 0 +- 1 +- 2 +- 3 +--- +- 0 +- 2 +- 3 +--- +- 0 +- 2 +- 3 +- 4 +``` + +The first state is the input, the second state is the first state with the removal of the element at index 1, and the last state is the result of the second state with the insertion of the element 4 into the list. diff --git a/pkgs/yaml_edit/test/testdata/input/allowed_characters_in_keys.test b/pkgs/yaml_edit/test/testdata/input/allowed_characters_in_keys.test new file mode 100644 index 000000000..1057df079 --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/input/allowed_characters_in_keys.test @@ -0,0 +1,9 @@ +ALLOWED CHARACTERS IN KEYS +--- +test: test +--- +- [update, ["a!\"#$%&'()*+,-./09:;<=>?@AZ[\\]^_`az{|}~"], safe] +- [update, ['?foo'], safe question mark] +- [update, [':foo'], safe colon] +- [update, ['-foo'], safe dash] +- [update, ['this is#not'], a comment] \ No newline at end of file diff --git a/pkgs/yaml_edit/test/testdata/input/basic.test b/pkgs/yaml_edit/test/testdata/input/basic.test new file mode 100644 index 000000000..02c082d12 --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/input/basic.test @@ -0,0 +1,5 @@ +BASIC TEST +--- +YAML: YAML Ain't Markup Language +--- +- [update, ['YAML'], hi] \ No newline at end of file diff --git a/pkgs/yaml_edit/test/testdata/input/basic_list.test b/pkgs/yaml_edit/test/testdata/input/basic_list.test new file mode 100644 index 000000000..3ba942be7 --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/input/basic_list.test @@ -0,0 +1,12 @@ +BASIC LIST TEST - Ensures that the basic list operations work. +--- +- 0 +- 1 +- 2 +- 3 +--- +- [remove, [1]] +- [append, [], 4] +- [update, [2], 5] +- [prepend, [], 6] +- [splice, [], 2, 3, [7, 8]] \ No newline at end of file diff --git a/pkgs/yaml_edit/test/testdata/input/ignore_key_order.test b/pkgs/yaml_edit/test/testdata/input/ignore_key_order.test new file mode 100644 index 000000000..e9d62010b --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/input/ignore_key_order.test @@ -0,0 +1,7 @@ +IGNORES KEY ORDER +--- +Z: 1 +D: 2 +F: 3 +--- +- [update, ['A'], 4] \ No newline at end of file diff --git a/pkgs/yaml_edit/test/testdata/input/issue_55.test b/pkgs/yaml_edit/test/testdata/input/issue_55.test new file mode 100644 index 000000000..ff1ac907c --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/input/issue_55.test @@ -0,0 +1,11 @@ +TEST FOR ISSUE #55 -- https://github.com/dart-lang/yaml_edit/issues/55 +--- +name: sample +version: 0.1.0 +environment: + sdk: ^3.0.0 +dependencies: +dev_dependencies: + retry: +--- + - [remove, ['dev_dependencies', 'retry']] diff --git a/pkgs/yaml_edit/test/testdata/input/nested_block_map.test b/pkgs/yaml_edit/test/testdata/input/nested_block_map.test new file mode 100644 index 000000000..bc7c35b14 --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/input/nested_block_map.test @@ -0,0 +1,11 @@ +NESTED BLOCK MAP +--- +a: 1 +b: + d: 4 + e: 5 +c: 3 +--- +- [update, ['b', 'e'], 6] +- [update, ['b', 'e'], [1,2,3]] +- [update, ['b', 'f'], 6] \ No newline at end of file diff --git a/pkgs/yaml_edit/test/testdata/input/pubspec.test b/pkgs/yaml_edit/test/testdata/input/pubspec.test new file mode 100644 index 000000000..5559e1da0 --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/input/pubspec.test @@ -0,0 +1,29 @@ +TESTING WITH A SAMPLE PUBSPEC +--- +name: yaml_edit +description: A library for YAML manipulation with comment and whitespace preservation. +version: 1.0.0 + +homepage: https://github.com/google/dart-neats/tree/master/yaml_edit + +repository: https://github.com/google/dart-neats.git + +issue_tracker: https://github.com/google/dart-neats/labels/pkg:yaml_edit + +environment: + sdk: ">=2.4.0 <3.0.0" + +dependencies: + meta: ^1.1.8 # To annotate + # quiver_hashcode + quiver_hashcode: ^2.0.0 # For hashcodes + # yaml + yaml: ^2.2.1 # For YAML + +dev_dependencies: + +--- +- [update, ['dependencies', 'yaml'], ^3.2.0] +- [update, ['dependencies', 'retry'], ^3.0.1] +- [remove, ['dependencies', 'meta']] +- [update, ['dev_dependencies'], {'test': '^1.14.4'}] \ No newline at end of file diff --git a/pkgs/yaml_edit/test/testdata/input/remove_block_list.test b/pkgs/yaml_edit/test/testdata/input/remove_block_list.test new file mode 100644 index 000000000..cf61522ad --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/input/remove_block_list.test @@ -0,0 +1,14 @@ +REMOVE FROM LIST IN BLOCK MODE +--- + - true + - test: + - foo: true + bar: + - baz: + - nested: + foo: +--- + - [remove, [1, 'test']] + - [remove, [2, 'bar']] + - [remove, [3, 'baz']] + - [remove, [4, 'nested', 'foo']] diff --git a/pkgs/yaml_edit/test/testdata/input/remove_flow_map.test b/pkgs/yaml_edit/test/testdata/input/remove_flow_map.test new file mode 100644 index 000000000..6bbf7ab41 --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/input/remove_flow_map.test @@ -0,0 +1,44 @@ +REMOVE FROM MAP IN FLOW MODE +--- +A: true +B: {foo: } +C: { + foo:,bar:true +} +D: { + foo: + ,bar:true +} +E: { + foo: # comment + ,bar:true +} +F: { + # comment + foo: + ,bar:true +} +G: { + # comment + foo: + # comment + ,bar:true +} +H: { + foo: # comment + , + bar:true +} +I: { + bar: true, foo: } +J: { foo : } +--- +- [remove, [B, foo]] +- [remove, [C, foo]] +- [remove, [D, foo]] +- [remove, [E, foo]] +- [remove, [F, foo]] +- [remove, [G, foo]] +- [remove, [H, foo]] +- [remove, [I, foo]] +- [remove, [J, foo]] diff --git a/pkgs/yaml_edit/test/testdata/input/remove_key.test b/pkgs/yaml_edit/test/testdata/input/remove_key.test new file mode 100644 index 000000000..b28818c4e --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/input/remove_key.test @@ -0,0 +1,6 @@ +REMOVE KEY FROM MAP +--- +foo: true +bar: false +--- +- [remove, [bar]] diff --git a/pkgs/yaml_edit/test/testdata/input/remove_key_with_trailing_comma.test b/pkgs/yaml_edit/test/testdata/input/remove_key_with_trailing_comma.test new file mode 100644 index 000000000..ce6755172 --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/input/remove_key_with_trailing_comma.test @@ -0,0 +1,6 @@ +REMOVE KEY FROM MAP WITH TRAILING COMMA +--- +foo: true +bar: false # remove this comment +--- +- [remove, [bar]] diff --git a/pkgs/yaml_edit/test/testdata/input/remove_nested_key.test b/pkgs/yaml_edit/test/testdata/input/remove_nested_key.test new file mode 100644 index 000000000..201caca7e --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/input/remove_nested_key.test @@ -0,0 +1,9 @@ +REMOVE NESTED KEY FROM MAP +--- +A: true +B: + foo: true + bar: true +--- +- [remove, [B, foo]] +- [remove, [B, bar]] diff --git a/pkgs/yaml_edit/test/testdata/input/remove_nested_key_with_null.test b/pkgs/yaml_edit/test/testdata/input/remove_nested_key_with_null.test new file mode 100644 index 000000000..2a6655960 --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/input/remove_nested_key_with_null.test @@ -0,0 +1,7 @@ +REMOVE NESTED KEY FROM MAP WITH NULL +--- +A: true +B: + foo: +--- +- [remove, [B, foo]] diff --git a/pkgs/yaml_edit/test/testdata/input/remove_nested_key_with_trailing_comma.test b/pkgs/yaml_edit/test/testdata/input/remove_nested_key_with_trailing_comma.test new file mode 100644 index 000000000..35ad66534 --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/input/remove_nested_key_with_trailing_comma.test @@ -0,0 +1,7 @@ +REMOVE NESTED KEY FROM MAP WITH TRAILING COMMA +--- +A: true +B: + bar: false # remove this comment +--- +- [remove, [B, bar]] diff --git a/pkgs/yaml_edit/test/testdata/input/respect_key_order.test b/pkgs/yaml_edit/test/testdata/input/respect_key_order.test new file mode 100644 index 000000000..dee95f4d8 --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/input/respect_key_order.test @@ -0,0 +1,6 @@ +RESPECTS THE KEY ORDER +--- +A: first +C: third +--- +- [update, [B], second] \ No newline at end of file diff --git a/pkgs/yaml_edit/test/testdata/input/spaces.test b/pkgs/yaml_edit/test/testdata/input/spaces.test new file mode 100644 index 000000000..d74fe73b1 --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/input/spaces.test @@ -0,0 +1,8 @@ +SPACE IN STRING - update a string that starts and ends with space +--- +a: ' space around me ' +c: 'hello world' +--- +- [update, ['b'], ' also spaced '] +- [update, ['d'], ' '] +- [update, ["\ne"], with newline] \ No newline at end of file diff --git a/pkgs/yaml_edit/test/testdata/input/special_keywords.test b/pkgs/yaml_edit/test/testdata/input/special_keywords.test new file mode 100644 index 000000000..0b3b81f51 --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/input/special_keywords.test @@ -0,0 +1,11 @@ +SPECIAL KEYWORDS +--- +~: null +false: false +No: No +true: true +--- +- [update, [null], tilde] +- [update, [false], false] +- [update, [No], no] +- [update, [true], True] \ No newline at end of file diff --git a/pkgs/yaml_edit/test/testdata/input/splice_list_in_nested_block_with_weird_spaces.test b/pkgs/yaml_edit/test/testdata/input/splice_list_in_nested_block_with_weird_spaces.test new file mode 100644 index 000000000..ed9b990bc --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/input/splice_list_in_nested_block_with_weird_spaces.test @@ -0,0 +1,13 @@ +SPLICE LIST IN A NESTED BLOCK LIST WITH WEIRD SPACES +--- +key: + - - bar1 + - bar2 + - - foo + - - baz +--- + - [splice, [key, 0], 0, 0, ['pre-bar1']] + - [splice, [key, 0], 2, 0, ['post-bar2']] + - [splice, [key, 2], 1, 0, ['post-baz']] + - [splice, [key, 2], 0, 0, ['pre-baz']] + - [splice, [key, 1], 0, 0, ['pre-foo']] \ No newline at end of file diff --git a/pkgs/yaml_edit/test/testdata/input/splice_list_in_nested_block_without_initial_spaces.test b/pkgs/yaml_edit/test/testdata/input/splice_list_in_nested_block_without_initial_spaces.test new file mode 100644 index 000000000..52c01b503 --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/input/splice_list_in_nested_block_without_initial_spaces.test @@ -0,0 +1,13 @@ +SPLICE LIST IN A NESTED BLOCK LIST WITHOUT INITIAL SPACES +--- +key: +- - bar1 + - bar2 +- - foo +- - baz +--- + - [splice, [key, 0], 0, 0, ['pre-bar1']] + - [splice, [key, 0], 2, 0, ['post-bar2']] + - [splice, [key, 2], 1, 0, ['post-baz']] + - [splice, [key, 2], 0, 0, ['pre-baz']] + - [splice, [key, 1], 0, 0, ['pre-foo']] diff --git a/pkgs/yaml_edit/test/testdata/input/splicelist_in_nested_block_list.test b/pkgs/yaml_edit/test/testdata/input/splicelist_in_nested_block_list.test new file mode 100644 index 000000000..da6c7c34a --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/input/splicelist_in_nested_block_list.test @@ -0,0 +1,13 @@ +SLICE LIST IN NESTED BLOCK LIST +--- +key: + - foo: + - - bar: + - - - false + - - - false + - - - false +--- +- [splice, [key], 0, 0, ['pre-foo']] +- [splice, [key, 1, 'foo', 0], 0, 1, ['test']] +- [splice, [key, 2], 0, 0, ['test']] +- [splice, [key], 4, 1, ['tail-foo']] diff --git a/pkgs/yaml_edit/test/testdata/output/allowed_characters_in_keys.golden b/pkgs/yaml_edit/test/testdata/output/allowed_characters_in_keys.golden new file mode 100644 index 000000000..3fd788565 --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/output/allowed_characters_in_keys.golden @@ -0,0 +1,26 @@ +test: test +--- +test: test +"a!\"#$%&'()*+,-.\/09:;<=>?@AZ[\\]^_`az{|}~": safe +--- +test: test +"a!\"#$%&'()*+,-.\/09:;<=>?@AZ[\\]^_`az{|}~": safe +?foo: safe question mark +--- +test: test +"a!\"#$%&'()*+,-.\/09:;<=>?@AZ[\\]^_`az{|}~": safe +?foo: safe question mark +:foo: safe colon +--- +test: test +"a!\"#$%&'()*+,-.\/09:;<=>?@AZ[\\]^_`az{|}~": safe +?foo: safe question mark +:foo: safe colon +-foo: safe dash +--- +test: test +"a!\"#$%&'()*+,-.\/09:;<=>?@AZ[\\]^_`az{|}~": safe +?foo: safe question mark +:foo: safe colon +-foo: safe dash +this is#not: a comment diff --git a/pkgs/yaml_edit/test/testdata/output/basic.golden b/pkgs/yaml_edit/test/testdata/output/basic.golden new file mode 100644 index 000000000..e200f97fb --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/output/basic.golden @@ -0,0 +1,3 @@ +YAML: YAML Ain't Markup Language +--- +YAML: hi diff --git a/pkgs/yaml_edit/test/testdata/output/basic_list.golden b/pkgs/yaml_edit/test/testdata/output/basic_list.golden new file mode 100644 index 000000000..c3a2b65d7 --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/output/basic_list.golden @@ -0,0 +1,29 @@ +- 0 +- 1 +- 2 +- 3 +--- +- 0 +- 2 +- 3 +--- +- 0 +- 2 +- 3 +- 4 +--- +- 0 +- 2 +- 5 +- 4 +--- +- 6 +- 0 +- 2 +- 5 +- 4 +--- +- 6 +- 0 +- 7 +- 8 diff --git a/pkgs/yaml_edit/test/testdata/output/ignore_key_order.golden b/pkgs/yaml_edit/test/testdata/output/ignore_key_order.golden new file mode 100644 index 000000000..93cc9dff1 --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/output/ignore_key_order.golden @@ -0,0 +1,8 @@ +Z: 1 +D: 2 +F: 3 +--- +Z: 1 +D: 2 +F: 3 +A: 4 diff --git a/pkgs/yaml_edit/test/testdata/output/issue_55.golden b/pkgs/yaml_edit/test/testdata/output/issue_55.golden new file mode 100644 index 000000000..f752a1438 --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/output/issue_55.golden @@ -0,0 +1,15 @@ +name: sample +version: 0.1.0 +environment: + sdk: ^3.0.0 +dependencies: +dev_dependencies: + retry: +--- +name: sample +version: 0.1.0 +environment: + sdk: ^3.0.0 +dependencies: +dev_dependencies: + {} diff --git a/pkgs/yaml_edit/test/testdata/output/nested_block_map.golden b/pkgs/yaml_edit/test/testdata/output/nested_block_map.golden new file mode 100644 index 000000000..d28bf6c1c --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/output/nested_block_map.golden @@ -0,0 +1,30 @@ +a: 1 +b: + d: 4 + e: 5 +c: 3 +--- +a: 1 +b: + d: 4 + e: 6 +c: 3 +--- +a: 1 +b: + d: 4 + e: + - 1 + - 2 + - 3 +c: 3 +--- +a: 1 +b: + d: 4 + e: + - 1 + - 2 + - 3 + f: 6 +c: 3 diff --git a/pkgs/yaml_edit/test/testdata/output/pubspec.golden b/pkgs/yaml_edit/test/testdata/output/pubspec.golden new file mode 100644 index 000000000..ffbe8ba43 --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/output/pubspec.golden @@ -0,0 +1,116 @@ +name: yaml_edit +description: A library for YAML manipulation with comment and whitespace preservation. +version: 1.0.0 + +homepage: https://github.com/google/dart-neats/tree/master/yaml_edit + +repository: https://github.com/google/dart-neats.git + +issue_tracker: https://github.com/google/dart-neats/labels/pkg:yaml_edit + +environment: + sdk: ">=2.4.0 <3.0.0" + +dependencies: + meta: ^1.1.8 # To annotate + # quiver_hashcode + quiver_hashcode: ^2.0.0 # For hashcodes + # yaml + yaml: ^2.2.1 # For YAML + +dev_dependencies: + +--- +name: yaml_edit +description: A library for YAML manipulation with comment and whitespace preservation. +version: 1.0.0 + +homepage: https://github.com/google/dart-neats/tree/master/yaml_edit + +repository: https://github.com/google/dart-neats.git + +issue_tracker: https://github.com/google/dart-neats/labels/pkg:yaml_edit + +environment: + sdk: ">=2.4.0 <3.0.0" + +dependencies: + meta: ^1.1.8 # To annotate + # quiver_hashcode + quiver_hashcode: ^2.0.0 # For hashcodes + # yaml + yaml: ^3.2.0 # For YAML + +dev_dependencies: + +--- +name: yaml_edit +description: A library for YAML manipulation with comment and whitespace preservation. +version: 1.0.0 + +homepage: https://github.com/google/dart-neats/tree/master/yaml_edit + +repository: https://github.com/google/dart-neats.git + +issue_tracker: https://github.com/google/dart-neats/labels/pkg:yaml_edit + +environment: + sdk: ">=2.4.0 <3.0.0" + +dependencies: + meta: ^1.1.8 # To annotate + # quiver_hashcode + quiver_hashcode: ^2.0.0 # For hashcodes + # yaml + retry: ^3.0.1 + yaml: ^3.2.0 # For YAML + +dev_dependencies: + +--- +name: yaml_edit +description: A library for YAML manipulation with comment and whitespace preservation. +version: 1.0.0 + +homepage: https://github.com/google/dart-neats/tree/master/yaml_edit + +repository: https://github.com/google/dart-neats.git + +issue_tracker: https://github.com/google/dart-neats/labels/pkg:yaml_edit + +environment: + sdk: ">=2.4.0 <3.0.0" + +dependencies: + # quiver_hashcode + quiver_hashcode: ^2.0.0 # For hashcodes + # yaml + retry: ^3.0.1 + yaml: ^3.2.0 # For YAML + +dev_dependencies: + +--- +name: yaml_edit +description: A library for YAML manipulation with comment and whitespace preservation. +version: 1.0.0 + +homepage: https://github.com/google/dart-neats/tree/master/yaml_edit + +repository: https://github.com/google/dart-neats.git + +issue_tracker: https://github.com/google/dart-neats/labels/pkg:yaml_edit + +environment: + sdk: ">=2.4.0 <3.0.0" + +dependencies: + # quiver_hashcode + quiver_hashcode: ^2.0.0 # For hashcodes + # yaml + retry: ^3.0.1 + yaml: ^3.2.0 # For YAML + +dev_dependencies: + test: ^1.14.4 + diff --git a/pkgs/yaml_edit/test/testdata/output/remove_block_list.golden b/pkgs/yaml_edit/test/testdata/output/remove_block_list.golden new file mode 100644 index 000000000..7aa388064 --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/output/remove_block_list.golden @@ -0,0 +1,36 @@ + - true + - test: + - foo: true + bar: + - baz: + - nested: + foo: +--- + - true + - {} + - foo: true + bar: + - baz: + - nested: + foo: +--- + - true + - {} + - foo: true + - baz: + - nested: + foo: +--- + - true + - {} + - foo: true + - {} + - nested: + foo: +--- + - true + - {} + - foo: true + - {} + - nested: + {} diff --git a/pkgs/yaml_edit/test/testdata/output/remove_flow_map.golden b/pkgs/yaml_edit/test/testdata/output/remove_flow_map.golden new file mode 100644 index 000000000..690dcab6e --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/output/remove_flow_map.golden @@ -0,0 +1,258 @@ +A: true +B: {foo: } +C: { + foo:,bar:true +} +D: { + foo: + ,bar:true +} +E: { + foo: # comment + ,bar:true +} +F: { + # comment + foo: + ,bar:true +} +G: { + # comment + foo: + # comment + ,bar:true +} +H: { + foo: # comment + , + bar:true +} +I: { + bar: true, foo: } +J: { foo : } +--- +A: true +B: {} +C: { + foo:,bar:true +} +D: { + foo: + ,bar:true +} +E: { + foo: # comment + ,bar:true +} +F: { + # comment + foo: + ,bar:true +} +G: { + # comment + foo: + # comment + ,bar:true +} +H: { + foo: # comment + , + bar:true +} +I: { + bar: true, foo: } +J: { foo : } +--- +A: true +B: {} +C: {bar:true +} +D: { + foo: + ,bar:true +} +E: { + foo: # comment + ,bar:true +} +F: { + # comment + foo: + ,bar:true +} +G: { + # comment + foo: + # comment + ,bar:true +} +H: { + foo: # comment + , + bar:true +} +I: { + bar: true, foo: } +J: { foo : } +--- +A: true +B: {} +C: {bar:true +} +D: {bar:true +} +E: { + foo: # comment + ,bar:true +} +F: { + # comment + foo: + ,bar:true +} +G: { + # comment + foo: + # comment + ,bar:true +} +H: { + foo: # comment + , + bar:true +} +I: { + bar: true, foo: } +J: { foo : } +--- +A: true +B: {} +C: {bar:true +} +D: {bar:true +} +E: {bar:true +} +F: { + # comment + foo: + ,bar:true +} +G: { + # comment + foo: + # comment + ,bar:true +} +H: { + foo: # comment + , + bar:true +} +I: { + bar: true, foo: } +J: { foo : } +--- +A: true +B: {} +C: {bar:true +} +D: {bar:true +} +E: {bar:true +} +F: {bar:true +} +G: { + # comment + foo: + # comment + ,bar:true +} +H: { + foo: # comment + , + bar:true +} +I: { + bar: true, foo: } +J: { foo : } +--- +A: true +B: {} +C: {bar:true +} +D: {bar:true +} +E: {bar:true +} +F: {bar:true +} +G: {bar:true +} +H: { + foo: # comment + , + bar:true +} +I: { + bar: true, foo: } +J: { foo : } +--- +A: true +B: {} +C: {bar:true +} +D: {bar:true +} +E: {bar:true +} +F: {bar:true +} +G: {bar:true +} +H: { + bar:true +} +I: { + bar: true, foo: } +J: { foo : } +--- +A: true +B: {} +C: {bar:true +} +D: {bar:true +} +E: {bar:true +} +F: {bar:true +} +G: {bar:true +} +H: { + bar:true +} +I: { + bar: true} +J: { foo : } +--- +A: true +B: {} +C: {bar:true +} +D: {bar:true +} +E: {bar:true +} +F: {bar:true +} +G: {bar:true +} +H: { + bar:true +} +I: { + bar: true} +J: {} diff --git a/pkgs/yaml_edit/test/testdata/output/remove_key.golden b/pkgs/yaml_edit/test/testdata/output/remove_key.golden new file mode 100644 index 000000000..48b86403e --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/output/remove_key.golden @@ -0,0 +1,4 @@ +foo: true +bar: false +--- +foo: true diff --git a/pkgs/yaml_edit/test/testdata/output/remove_key_with_trailing_comma.golden b/pkgs/yaml_edit/test/testdata/output/remove_key_with_trailing_comma.golden new file mode 100644 index 000000000..d85be3537 --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/output/remove_key_with_trailing_comma.golden @@ -0,0 +1,4 @@ +foo: true +bar: false # remove this comment +--- +foo: true diff --git a/pkgs/yaml_edit/test/testdata/output/remove_nested_key.golden b/pkgs/yaml_edit/test/testdata/output/remove_nested_key.golden new file mode 100644 index 000000000..011f1c1cf --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/output/remove_nested_key.golden @@ -0,0 +1,12 @@ +A: true +B: + foo: true + bar: true +--- +A: true +B: + bar: true +--- +A: true +B: + {} diff --git a/pkgs/yaml_edit/test/testdata/output/remove_nested_key_with_null.golden b/pkgs/yaml_edit/test/testdata/output/remove_nested_key_with_null.golden new file mode 100644 index 000000000..b0e771cdd --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/output/remove_nested_key_with_null.golden @@ -0,0 +1,7 @@ +A: true +B: + foo: +--- +A: true +B: + {} diff --git a/pkgs/yaml_edit/test/testdata/output/remove_nested_key_with_trailing_comma.golden b/pkgs/yaml_edit/test/testdata/output/remove_nested_key_with_trailing_comma.golden new file mode 100644 index 000000000..c93c46a97 --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/output/remove_nested_key_with_trailing_comma.golden @@ -0,0 +1,7 @@ +A: true +B: + bar: false # remove this comment +--- +A: true +B: + {} diff --git a/pkgs/yaml_edit/test/testdata/output/respect_key_order.golden b/pkgs/yaml_edit/test/testdata/output/respect_key_order.golden new file mode 100644 index 000000000..a20ecde81 --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/output/respect_key_order.golden @@ -0,0 +1,6 @@ +A: first +C: third +--- +A: first +B: second +C: third diff --git a/pkgs/yaml_edit/test/testdata/output/spaces.golden b/pkgs/yaml_edit/test/testdata/output/spaces.golden new file mode 100644 index 000000000..7ee33728f --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/output/spaces.golden @@ -0,0 +1,17 @@ +a: ' space around me ' +c: 'hello world' +--- +a: ' space around me ' +b: " also spaced " +c: 'hello world' +--- +a: ' space around me ' +b: " also spaced " +c: 'hello world' +d: " " +--- +"\ne": with newline +a: ' space around me ' +b: " also spaced " +c: 'hello world' +d: " " diff --git a/pkgs/yaml_edit/test/testdata/output/special_keywords.golden b/pkgs/yaml_edit/test/testdata/output/special_keywords.golden new file mode 100644 index 000000000..e0c179d4c --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/output/special_keywords.golden @@ -0,0 +1,24 @@ +~: null +false: false +No: No +true: true +--- +~: tilde +false: false +No: No +true: true +--- +~: tilde +false: false +No: No +true: true +--- +~: tilde +false: false +No: no +true: true +--- +~: tilde +false: false +No: no +true: true diff --git a/pkgs/yaml_edit/test/testdata/output/splice_list_in_nested_block_with_weird_spaces.golden b/pkgs/yaml_edit/test/testdata/output/splice_list_in_nested_block_with_weird_spaces.golden new file mode 100644 index 000000000..cfc4cc0ee --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/output/splice_list_in_nested_block_with_weird_spaces.golden @@ -0,0 +1,50 @@ +key: + - - bar1 + - bar2 + - - foo + - - baz +--- +key: + - - pre-bar1 + - bar1 + - bar2 + - - foo + - - baz +--- +key: + - - pre-bar1 + - bar1 + - post-bar2 + - bar2 + - - foo + - - baz +--- +key: + - - pre-bar1 + - bar1 + - post-bar2 + - bar2 + - - foo + - - baz + - post-baz +--- +key: + - - pre-bar1 + - bar1 + - post-bar2 + - bar2 + - - foo + - - pre-baz + - baz + - post-baz +--- +key: + - - pre-bar1 + - bar1 + - post-bar2 + - bar2 + - - pre-foo + - foo + - - pre-baz + - baz + - post-baz diff --git a/pkgs/yaml_edit/test/testdata/output/splice_list_in_nested_block_without_initial_spaces.golden b/pkgs/yaml_edit/test/testdata/output/splice_list_in_nested_block_without_initial_spaces.golden new file mode 100644 index 000000000..3454bbdb9 --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/output/splice_list_in_nested_block_without_initial_spaces.golden @@ -0,0 +1,50 @@ +key: +- - bar1 + - bar2 +- - foo +- - baz +--- +key: +- - pre-bar1 + - bar1 + - bar2 +- - foo +- - baz +--- +key: +- - pre-bar1 + - bar1 + - post-bar2 + - bar2 +- - foo +- - baz +--- +key: +- - pre-bar1 + - bar1 + - post-bar2 + - bar2 +- - foo +- - baz + - post-baz +--- +key: +- - pre-bar1 + - bar1 + - post-bar2 + - bar2 +- - foo +- - pre-baz + - baz + - post-baz +--- +key: +- - pre-bar1 + - bar1 + - post-bar2 + - bar2 +- - pre-foo + - foo +- - pre-baz + - baz + - post-baz diff --git a/pkgs/yaml_edit/test/testdata/output/splicelist_in_nested_block_list.golden b/pkgs/yaml_edit/test/testdata/output/splicelist_in_nested_block_list.golden new file mode 100644 index 000000000..0c9d7c5aa --- /dev/null +++ b/pkgs/yaml_edit/test/testdata/output/splicelist_in_nested_block_list.golden @@ -0,0 +1,40 @@ +key: + - foo: + - - bar: + - - - false + - - - false + - - - false +--- +key: + - pre-foo + - foo: + - - bar: + - - - false + - - - false + - - - false +--- +key: + - pre-foo + - foo: + - - test + - - - false + - - - false + - - - false +--- +key: + - pre-foo + - foo: + - - test + - - test + - - false + - - - false + - - - false +--- +key: + - pre-foo + - foo: + - - test + - - test + - - false + - - - false + - tail-foo diff --git a/pkgs/yaml_edit/test/update_test.dart b/pkgs/yaml_edit/test/update_test.dart new file mode 100644 index 000000000..01dfde0f1 --- /dev/null +++ b/pkgs/yaml_edit/test/update_test.dart @@ -0,0 +1,994 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:test/test.dart'; +import 'package:yaml/yaml.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + +import 'test_utils.dart'; + +void main() { + group('throws', () { + test('RangeError in list if index is negative', () { + final doc = YamlEditor("- YAML Ain't Markup Language"); + expect(() => doc.update([-1], 'test'), throwsRangeError); + }); + + test('RangeError in list if index is larger than list length', () { + final doc = YamlEditor("- YAML Ain't Markup Language"); + expect(() => doc.update([2], 'test'), throwsRangeError); + }); + + test('PathError in list if attempting to set a key of a scalar', () { + final doc = YamlEditor("- YAML Ain't Markup Language"); + expect(() => doc.update([0, 'a'], 'a'), throwsPathError); + }); + + test('PathError in list if using a non-integer as index', () { + final doc = YamlEditor("{ a: ['b', 'c'] }"); + expect(() => doc.update(['a', 'b'], 'x'), throwsPathError); + }); + }); + + group('works on top-level', () { + test('empty document', () { + final doc = YamlEditor(''); + doc.update([], 'replacement'); + + expect(doc.toString(), equals('replacement')); + expectYamlBuilderValue(doc, 'replacement'); + }); + + test('replaces string in document containing only a string', () { + final doc = YamlEditor('test'); + doc.update([], 'replacement'); + + expect(doc.toString(), equals('replacement')); + expectYamlBuilderValue(doc, 'replacement'); + }); + + test('replaces top-level string to map', () { + final doc = YamlEditor('test'); + doc.update([], {'a': 1}); + + expect(doc.toString(), equals('a: 1')); + expectYamlBuilderValue(doc, {'a': 1}); + }); + + test('replaces top-level list', () { + final doc = YamlEditor('- 1'); + doc.update([], 'replacement'); + + expect(doc.toString(), equals('replacement')); + expectYamlBuilderValue(doc, 'replacement'); + }); + + test('replaces top-level map', () { + final doc = YamlEditor('a: 1'); + doc.update([], 'replacement'); + + expect(doc.toString(), equals('replacement')); + expectYamlBuilderValue(doc, 'replacement'); + }); + + test('replaces top-level map with comment', () { + final doc = YamlEditor('a: 1 # comment'); + doc.update([], 'replacement'); + + expect(doc.toString(), equals('replacement # comment')); + expectYamlBuilderValue(doc, 'replacement'); + }); + }); + + group('replaces in', () { + group('block map', () { + test('(1)', () { + final doc = YamlEditor("YAML: YAML Ain't Markup Language"); + doc.update(['YAML'], 'test'); + + expect(doc.toString(), equals('YAML: test')); + expectYamlBuilderValue(doc, {'YAML': 'test'}); + }); + + test('(2)', () { + final doc = YamlEditor('test: test'); + doc.update(['test'], []); + + expect(doc.toString(), equals('test: []')); + expectYamlBuilderValue(doc, {'test': []}); + }); + + test('empty value', () { + final doc = YamlEditor('YAML:'); + doc.update(['YAML'], 'test'); + + expect(doc.toString(), equals('YAML: test')); + expectYamlBuilderValue(doc, {'YAML': 'test'}); + }); + + test('empty value (2)', () { + final doc = YamlEditor('YAML :'); + doc.update(['YAML'], 'test'); + + expect(doc.toString(), equals('YAML : test')); + expectYamlBuilderValue(doc, {'YAML': 'test'}); + }); + + test('with comment', () { + final doc = YamlEditor("YAML: YAML Ain't Markup Language # comment"); + doc.update(['YAML'], 'test'); + + expect(doc.toString(), equals('YAML: test # comment')); + expectYamlBuilderValue(doc, {'YAML': 'test'}); + }); + + test('nested', () { + final doc = YamlEditor(''' +a: 1 +b: + d: 4 + e: 5 +c: 3 +'''); + doc.update(['b', 'e'], 6); + + expect(doc.toString(), equals(''' +a: 1 +b: + d: 4 + e: 6 +c: 3 +''')); + + expectYamlBuilderValue(doc, { + 'a': 1, + 'b': {'d': 4, 'e': 6}, + 'c': 3 + }); + }); + + test('nested (2)', () { + final doc = YamlEditor(''' +a: 1 +b: {d: 4, e: 5} +c: 3 +'''); + doc.update(['b', 'e'], 6); + + expect(doc.toString(), equals(''' +a: 1 +b: {d: 4, e: 6} +c: 3 +''')); + expectYamlBuilderValue(doc, { + 'a': 1, + 'b': {'d': 4, 'e': 6}, + 'c': 3 + }); + }); + + test('nested (3)', () { + final doc = YamlEditor(''' +a: + b: 4 +'''); + doc.update(['a'], true); + + expect(doc.toString(), equals(''' +a: true +''')); + + expectYamlBuilderValue(doc, {'a': true}); + }); + + test('nested (4)', () { + final doc = YamlEditor(''' +a: 1 +'''); + doc.update([ + 'a' + ], [ + {'a': true, 'b': false} + ]); + + expectYamlBuilderValue(doc, { + 'a': [ + {'a': true, 'b': false} + ] + }); + }); + + test('nested (5)', () { + final doc = YamlEditor(''' +a: + - a: 1 + b: 2 + - null +'''); + doc.update(['a', 0], false); + expect(doc.toString(), equals(''' +a: + - false + + - null +''')); + expectYamlBuilderValue(doc, { + 'a': [false, null] + }); + }); + + test('nested (6)', () { + final doc = YamlEditor(''' +a: + - - 1 + - 2 + - null +'''); + doc.update(['a', 0], false); + expect(doc.toString(), equals(''' +a: + - false + + - null +''')); + expectYamlBuilderValue(doc, { + 'a': [false, null] + }); + }); + + test('nested (7)', () { + final doc = YamlEditor(''' +a: + - - 0 +b: false +'''); + doc.update(['a', 0], true); + + expect(doc.toString(), equals(''' +a: + - true + +b: false +''')); + }); + + test('nested (8)', () { + final doc = YamlEditor(''' +a: +b: false +'''); + doc.update(['a'], {'retry': '3.0.1'}); + + expect(doc.toString(), equals(''' +a: + retry: 3.0.1 +b: false +''')); + }); + + test('nested (9)', () { + final doc = YamlEditor(''' +# comment +a: # comment +# comment +'''); + doc.update(['a'], {'retry': '3.0.1'}); + + expect(doc.toString(), equals(''' +# comment +a: + retry: 3.0.1 # comment +# comment +''')); + }); + + test('nested scalar -> flow list', () { + final doc = YamlEditor(''' +a: 1 +b: + d: 4 + e: 5 +c: 3 +'''); + doc.update(['b', 'e'], [1, 2, 3]); + + expect(doc.toString(), equals(''' +a: 1 +b: + d: 4 + e: + - 1 + - 2 + - 3 +c: 3 +''')); + expectYamlBuilderValue(doc, { + 'a': 1, + 'b': { + 'd': 4, + 'e': [1, 2, 3] + }, + 'c': 3 + }); + }); + + test('nested block map -> scalar', () { + final doc = YamlEditor(''' +a: 1 +b: + d: 4 + e: 5 +c: 3 +'''); + doc.update(['b'], 2); + + expect(doc.toString(), equals(''' +a: 1 +b: 2 +c: 3 +''')); + expectYamlBuilderValue(doc, {'a': 1, 'b': 2, 'c': 3}); + }); + + test('nested block map -> scalar with comments', () { + final doc = YamlEditor(''' +a: 1 +b: + d: 4 + e: 5 + + +# comment +'''); + doc.update(['b'], 2); + + expect(doc.toString(), equals(''' +a: 1 +b: 2 + + +# comment +''')); + expectYamlBuilderValue(doc, { + 'a': 1, + 'b': 2, + }); + }); + + test('nested scalar -> block map', () { + final doc = YamlEditor(''' +a: 1 +b: + d: 4 + e: 5 +c: 3 +'''); + doc.update(['b', 'e'], {'x': 3, 'y': 4}); + + expect(doc.toString(), equals(''' +a: 1 +b: + d: 4 + e: + x: 3 + y: 4 +c: 3 +''')); + expectYamlBuilderValue(doc, { + 'a': 1, + 'b': { + 'd': 4, + 'e': {'x': 3, 'y': 4} + }, + 'c': 3 + }); + }); + + test('nested block map with comments', () { + final doc = YamlEditor(''' +a: 1 +b: + d: 4 + e: 5 # comment +c: 3 +'''); + doc.update(['b', 'e'], 6); + + expect(doc.toString(), equals(''' +a: 1 +b: + d: 4 + e: 6 # comment +c: 3 +''')); + expectYamlBuilderValue(doc, { + 'a': 1, + 'b': {'d': 4, 'e': 6}, + 'c': 3 + }); + }); + + test('nested block map with comments (2)', () { + final doc = YamlEditor(''' +a: 1 +b: + d: 4 # comment +# comment + e: 5 # comment +# comment +c: 3 +'''); + doc.update(['b', 'e'], 6); + + expect(doc.toString(), equals(''' +a: 1 +b: + d: 4 # comment +# comment + e: 6 # comment +# comment +c: 3 +''')); + expectYamlBuilderValue(doc, { + 'a': 1, + 'b': {'d': 4, 'e': 6}, + 'c': 3 + }); + }); + }); + + group('flow map', () { + test('(1)', () { + final doc = YamlEditor("{YAML: YAML Ain't Markup Language}"); + doc.update(['YAML'], 'test'); + + expect(doc.toString(), equals('{YAML: test}')); + expectYamlBuilderValue(doc, {'YAML': 'test'}); + }); + + test('(2)', () { + final doc = YamlEditor("{YAML: YAML Ain't Markup Language}"); + doc.update(['YAML'], 'd9]zH`FoYC/>]'); + + expect(doc.toString(), equals('{YAML: "d9]zH`FoYC\\/>]"}')); + expectYamlBuilderValue(doc, {'YAML': 'd9]zH`FoYC/>]'}); + }); + + test('empty value', () { + final doc = YamlEditor('{YAML: }'); + doc.update(['YAML'], 'test'); + + expect(doc.toString(), equals('{YAML: test}')); + expectYamlBuilderValue(doc, {'YAML': 'test'}); + }); + + test('empty value (2)', () { + final doc = YamlEditor('{YAML: , hi: bye}'); + doc.update(['YAML'], 'test'); + + expect(doc.toString(), equals('{YAML: test, hi: bye}')); + expectYamlBuilderValue(doc, {'YAML': 'test', 'hi': 'bye'}); + }); + + test('with spacing', () { + final doc = YamlEditor("{ YAML: YAML Ain't Markup Language , " + 'XML: Extensible Markup Language , ' + 'HTML: Hypertext Markup Language }'); + doc.update(['XML'], 'XML Markup Language'); + + expect( + doc.toString(), + equals("{ YAML: YAML Ain't Markup Language , " + 'XML: XML Markup Language, ' + 'HTML: Hypertext Markup Language }')); + expectYamlBuilderValue(doc, { + 'YAML': "YAML Ain't Markup Language", + 'XML': 'XML Markup Language', + 'HTML': 'Hypertext Markup Language' + }); + }); + }); + + group('block list', () { + test('(1)', () { + final doc = YamlEditor("- YAML Ain't Markup Language"); + doc.update([0], 'test'); + + expect(doc.toString(), equals('- test')); + expectYamlBuilderValue(doc, ['test']); + }); + + test('(2)', () { + final doc = YamlEditor(''' +- 1 +- +- 3 +'''); + doc.update([1], 2); + + expect(doc.toString(), equals(''' +- 1 +- 2 +- 3 +''')); + expectYamlBuilderValue(doc, [1, 2, 3]); + }); + + test('nested (1)', () { + final doc = YamlEditor("- YAML Ain't Markup Language"); + doc.update([0], [1, 2]); + + expect(doc.toString(), equals('- - 1\n - 2')); + expectYamlBuilderValue(doc, [ + [1, 2] + ]); + }); + + test('with comment', () { + final doc = YamlEditor("- YAML Ain't Markup Language # comment"); + doc.update([0], 'test'); + + expect(doc.toString(), equals('- test # comment')); + expectYamlBuilderValue(doc, ['test']); + }); + + test('with comment (2)', () { + final doc = YamlEditor(''' +- 1 +- # comment +- 3 +'''); + doc.update([1], 2); + + expect(doc.toString(), equals(''' +- 1 +- 2 # comment +- 3 +''')); + expectYamlBuilderValue(doc, [1, 2, 3]); + }); + + test('with comment and spaces', () { + final doc = YamlEditor("- YAML Ain't Markup Language # comment"); + doc.update([0], 'test'); + + expect(doc.toString(), equals('- test # comment')); + expectYamlBuilderValue(doc, ['test']); + }); + + test('nested (2)', () { + final doc = YamlEditor(''' +- 0 +- - 0 + - 1 + - 2 +- 2 +- 3 +'''); + doc.update([1, 1], 4); + expect(doc.toString(), equals(''' +- 0 +- - 0 + - 4 + - 2 +- 2 +- 3 +''')); + + expectYamlBuilderValue(doc, [ + 0, + [0, 4, 2], + 2, + 3 + ]); + }); + + test('nested (3)', () { + final doc = YamlEditor(''' +- 0 +- 1 +'''); + doc.update([0], {'item': 'Super Hoop', 'quantity': 1}); + doc.update([1], {'item': 'BasketBall', 'quantity': 4}); + expect(doc.toString(), equals(''' +- item: Super Hoop + quantity: 1 +- item: BasketBall + quantity: 4 +''')); + + expectYamlBuilderValue(doc, [ + {'item': 'Super Hoop', 'quantity': 1}, + {'item': 'BasketBall', 'quantity': 4} + ]); + }); + + test('nested list flow map -> scalar', () { + final doc = YamlEditor(''' +- 0 +- {a: 1, b: 2} +- 2 +- 3 +'''); + doc.update([1], 4); + expect(doc.toString(), equals(''' +- 0 +- 4 +- 2 +- 3 +''')); + expectYamlBuilderValue(doc, [0, 4, 2, 3]); + }); + + test('nested list-map-list-number update', () { + final doc = YamlEditor(''' +- 0 +- a: + - 1 + - 2 + - 3 +- 2 +- 3 +'''); + doc.update([1, 'a', 0], 15); + expect(doc.toString(), equals(''' +- 0 +- a: + - 15 + - 2 + - 3 +- 2 +- 3 +''')); + expectYamlBuilderValue(doc, [ + 0, + { + 'a': [15, 2, 3] + }, + 2, + 3 + ]); + }); + }); + + group('flow list', () { + test('(1)', () { + final doc = YamlEditor("[YAML Ain't Markup Language]"); + doc.update([0], 'test'); + + expect(doc.toString(), equals('[test]')); + expectYamlBuilderValue(doc, ['test']); + }); + + test('(2)', () { + final doc = YamlEditor("[YAML Ain't Markup Language]"); + doc.update([0], [1, 2, 3]); + + expect(doc.toString(), equals('[[1, 2, 3]]')); + expectYamlBuilderValue(doc, [ + [1, 2, 3] + ]); + }); + + /// We cannot have empty values in a flow list. + + test('with spacing (1)', () { + final doc = YamlEditor('[ 0 , 1 , 2 , 3 ]'); + doc.update([1], 4); + + expect(doc.toString(), equals('[ 0 , 4, 2 , 3 ]')); + expectYamlBuilderValue(doc, [0, 4, 2, 3]); + }); + }); + }); + + group('adds to', () { + group('flow map', () { + test('that is empty ', () { + final doc = YamlEditor('{}'); + doc.update(['a'], 1); + expect(doc.toString(), equals('{a: 1}')); + expectYamlBuilderValue(doc, {'a': 1}); + }); + + test('that is empty (2)', () { + final doc = YamlEditor(''' +- {} +- [] +'''); + doc.update([0, 'a'], [1]); + expect(doc.toString(), equals(''' +- {a: [1]} +- [] +''')); + expectYamlBuilderValue(doc, [ + { + 'a': [1] + }, + [] + ]); + }); + + test('(1)', () { + final doc = YamlEditor("{YAML: YAML Ain't Markup Language}"); + doc.update(['XML'], 'Extensible Markup Language'); + + expect( + doc.toString(), + equals( + "{YAML: YAML Ain't Markup Language, " + 'XML: Extensible Markup Language}', + ), + ); + expectYamlBuilderValue(doc, { + 'XML': 'Extensible Markup Language', + 'YAML': "YAML Ain't Markup Language", + }); + }); + }); + + group('block map', () { + test('(1)', () { + final doc = YamlEditor(''' +a: 1 +b: 2 +c: 3 +'''); + doc.update(['d'], 4); + expect(doc.toString(), equals(''' +a: 1 +b: 2 +c: 3 +d: 4 +''')); + expectYamlBuilderValue(doc, {'a': 1, 'b': 2, 'c': 3, 'd': 4}); + }); + + // Regression testing to ensure it works without leading whitespace + test('(2)', () { + final doc = YamlEditor('a: 1'); + doc.update(['b'], 2); + expect(doc.toString(), equals('''a: 1 +b: 2 +''')); + expectYamlBuilderValue(doc, {'a': 1, 'b': 2}); + }); + + test('(3)', () { + final doc = YamlEditor(''' +a: + aa: 1 + zz: 1 +'''); + doc.update([ + 'a', + 'bb' + ], { + 'aaa': {'dddd': 'c'}, + 'bbb': [0, 1, 2] + }); + + expect(doc.toString(), equals(''' +a: + aa: 1 + bb: + aaa: + dddd: c + bbb: + - 0 + - 1 + - 2 + zz: 1 +''')); + expectYamlBuilderValue(doc, { + 'a': { + 'aa': 1, + 'bb': { + 'aaa': {'dddd': 'c'}, + 'bbb': [0, 1, 2] + }, + 'zz': 1 + } + }); + }); + + test('(4)', () { + final doc = YamlEditor(''' +a: + aa: 1 + zz: 1 +'''); + doc.update([ + 'a', + 'bb' + ], [ + 0, + [1, 2], + {'aaa': 'b', 'bbb': 'c'} + ]); + + expect(doc.toString(), equals(''' +a: + aa: 1 + bb: + - 0 + - - 1 + - 2 + - aaa: b + bbb: c + zz: 1 +''')); + expectYamlBuilderValue(doc, { + 'a': { + 'aa': 1, + 'bb': [ + 0, + [1, 2], + {'aaa': 'b', 'bbb': 'c'} + ], + 'zz': 1 + } + }); + }); + + test('with complex keys', () { + final doc = YamlEditor(''' +? Sammy Sosa +? Ken Griff'''); + doc.update(['Mark McGwire'], null); + expect(doc.toString(), equals(''' +? Sammy Sosa +? Ken Griff +Mark McGwire: null +''')); + expectYamlBuilderValue( + doc, {'Sammy Sosa': null, 'Ken Griff': null, 'Mark McGwire': null}); + }); + + test('with trailing newline', () { + final doc = YamlEditor(''' +a: 1 +b: 2 +c: 3 + + +'''); + doc.update(['d'], 4); + expect(doc.toString(), equals(''' +a: 1 +b: 2 +c: 3 +d: 4 + + +''')); + expectYamlBuilderValue(doc, {'a': 1, 'b': 2, 'c': 3, 'd': 4}); + }); + + test('adds an empty map properly', () { + final doc = YamlEditor('a: b'); + doc.update(['key'], {}); + expectYamlBuilderValue(doc, {'a': 'b', 'key': {}}); + }); + + test('adds an empty map properly (2)', () { + final doc = YamlEditor('a: b'); + doc.update(['a'], {'key': {}}); + expectYamlBuilderValue(doc, { + 'a': {'key': {}} + }); + }); + + test('adds and preserves key order (ascending)', () { + final doc = YamlEditor(''' +a: 1 +b: 2 +c: 3 + + +'''); + + doc.update(['d'], 4); + expect(doc.toString(), equals(''' +a: 1 +b: 2 +c: 3 +d: 4 + + +''')); + }); + + test('adds at the end when no key order is present', () { + final doc = YamlEditor(''' +a: 1 +c: 2 +b: 3 +'''); + + doc.update(['d'], 4); + expect(doc.toString(), equals(''' +a: 1 +c: 2 +b: 3 +d: 4 +''')); + }); + }); + + group('empty starting document', () { + test('empty map', () { + final doc = YamlEditor(''); + doc.update([], {'key': {}}); + expectYamlBuilderValue(doc, {'key': {}}); + }); + + test('empty map (2)', () { + final doc = YamlEditor(''); + doc.update([], {}); + expectYamlBuilderValue(doc, {}); + }); + + test('empty map (3)', () { + final doc = YamlEditor(''); + doc.update( + [], + wrapAsYamlNode( + {'key': {}}, + collectionStyle: CollectionStyle.BLOCK, + ), + ); + expectYamlBuilderValue(doc, {'key': {}}); + }); + + test('empty map (4)', () { + final doc = YamlEditor(''); + doc.update( + [], + wrapAsYamlNode( + {}, + collectionStyle: CollectionStyle.BLOCK, + ), + ); + expectYamlBuilderValue(doc, {}); + }); + + test('empty list', () { + final doc = YamlEditor(''); + doc.update([], {'key': []}); + expectYamlBuilderValue(doc, {'key': []}); + }); + + test('empty list (2)', () { + final doc = YamlEditor(''); + doc.update([], []); + expectYamlBuilderValue(doc, []); + }); + + test('empty list (3)', () { + final doc = YamlEditor(''); + doc.update( + [], + wrapAsYamlNode( + {'key': []}, + collectionStyle: CollectionStyle.BLOCK, + ), + ); + expectYamlBuilderValue(doc, {'key': []}); + }); + + test('empty map (4)', () { + final doc = YamlEditor(''); + doc.update( + [], + wrapAsYamlNode( + [], + collectionStyle: CollectionStyle.BLOCK, + ), + ); + expectYamlBuilderValue(doc, []); + }); + }); + }); +} diff --git a/pkgs/yaml_edit/test/utils_test.dart b/pkgs/yaml_edit/test/utils_test.dart new file mode 100644 index 000000000..57f0b2a77 --- /dev/null +++ b/pkgs/yaml_edit/test/utils_test.dart @@ -0,0 +1,448 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:test/test.dart'; +import 'package:yaml/yaml.dart'; +import 'package:yaml_edit/src/utils.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + +import 'test_utils.dart'; + +void main() { + group('indentation', () { + test('returns 2 for empty strings', () { + final doc = YamlEditor(''); + expect(getIndentation(doc), equals(2)); + }); + + test('returns 2 for strings consisting only scalars', () { + final doc = YamlEditor('foo'); + expect(getIndentation(doc), equals(2)); + }); + + test('returns 2 if only top-level elements are present', () { + final doc = YamlEditor(''' +- 1 +- 2 +- 3'''); + expect(getIndentation(doc), equals(2)); + }); + + test('detects the indentation used in nested list', () { + final doc = YamlEditor(''' +- 1 +- 2 +- + - 3 + - 4'''); + expect(getIndentation(doc), equals(3)); + }); + + test('detects the indentation used in nested map', () { + final doc = YamlEditor(''' +a: 1 +b: 2 +c: + d: 4 + e: 5'''); + expect(getIndentation(doc), equals(3)); + }); + + test('detects the indentation used in nested map in list', () { + final doc = YamlEditor(''' +- 1 +- 2 +- + d: 4 + e: 5'''); + expect(getIndentation(doc), equals(4)); + }); + + test('detects the indentation used in nested map in list with complex keys', + () { + final doc = YamlEditor(''' +- 1 +- 2 +- + ? d + : 4'''); + expect(getIndentation(doc), equals(4)); + }); + + test('detects the indentation used in nested list in map', () { + final doc = YamlEditor(''' +a: 1 +b: 2 +c: + - 4 + - 5'''); + expect(getIndentation(doc), equals(2)); + }); + }); + + group('styling options', () { + group('update', () { + test('flow map with style', () { + final doc = YamlEditor("{YAML: YAML Ain't Markup Language}"); + doc.update(['YAML'], + wrapAsYamlNode('hi', scalarStyle: ScalarStyle.DOUBLE_QUOTED)); + + expect(doc.toString(), equals('{YAML: "hi"}')); + expectYamlBuilderValue(doc, {'YAML': 'hi'}); + }); + + test('prevents block scalars in flow map', () { + final doc = YamlEditor("{YAML: YAML Ain't Markup Language}"); + doc.update( + ['YAML'], wrapAsYamlNode('test', scalarStyle: ScalarStyle.FOLDED)); + + expect(doc.toString(), equals('{YAML: test}')); + expectYamlBuilderValue(doc, {'YAML': 'test'}); + }); + + test('wraps string in double-quotes if it contains dangerous characters', + () { + final doc = YamlEditor("{YAML: YAML Ain't Markup Language}"); + doc.update( + ['YAML'], wrapAsYamlNode('> test', scalarStyle: ScalarStyle.PLAIN)); + + expect(doc.toString(), equals('{YAML: "> test"}')); + expectYamlBuilderValue(doc, {'YAML': '> test'}); + }); + + test('list in map', () { + final doc = YamlEditor('''YAML: YAML Ain't Markup Language'''); + doc.update(['YAML'], + wrapAsYamlNode([1, 2, 3], collectionStyle: CollectionStyle.FLOW)); + + expect(doc.toString(), equals('YAML: [1, 2, 3]')); + expectYamlBuilderValue(doc, { + 'YAML': [1, 2, 3] + }); + }); + + test('nested map', () { + final doc = YamlEditor('''YAML: YAML Ain't Markup Language'''); + doc.update( + ['YAML'], + wrapAsYamlNode({'YAML': "YAML Ain't Markup Language"}, + collectionStyle: CollectionStyle.FLOW)); + + expect( + doc.toString(), equals("YAML: {YAML: YAML Ain't Markup Language}")); + expectYamlBuilderValue(doc, { + 'YAML': {'YAML': "YAML Ain't Markup Language"} + }); + }); + + test('nested list', () { + final doc = YamlEditor('- 0'); + doc.update( + [0], + wrapAsYamlNode([ + 1, + 2, + wrapAsYamlNode([3, 4], collectionStyle: CollectionStyle.FLOW), + 5 + ])); + + expect(doc.toString(), equals(''' +- - 1 + - 2 + - [3, 4] + - 5''')); + expectYamlBuilderValue(doc, [ + [ + 1, + 2, + [3, 4], + 5 + ] + ]); + }); + + test('different scalars in block list!', () { + final doc = YamlEditor('- 0'); + doc.update( + [0], + wrapAsYamlNode([ + wrapAsYamlNode('plain string', scalarStyle: ScalarStyle.PLAIN), + wrapAsYamlNode('folded string', scalarStyle: ScalarStyle.FOLDED), + wrapAsYamlNode('single-quoted string', + scalarStyle: ScalarStyle.SINGLE_QUOTED), + wrapAsYamlNode('literal string', + scalarStyle: ScalarStyle.LITERAL), + wrapAsYamlNode('double-quoted string', + scalarStyle: ScalarStyle.DOUBLE_QUOTED), + ])); + + expect(doc.toString(), equals(''' +- - plain string + - >- + folded string + - 'single-quoted string' + - |- + literal string + - "double-quoted string"''')); + expectYamlBuilderValue(doc, [ + [ + 'plain string', + 'folded string', + 'single-quoted string', + 'literal string', + 'double-quoted string', + ] + ]); + }); + + test('different scalars in block map!', () { + final doc = YamlEditor('strings: strings'); + doc.update( + ['strings'], + wrapAsYamlNode({ + 'plain': wrapAsYamlNode('string', scalarStyle: ScalarStyle.PLAIN), + 'folded': + wrapAsYamlNode('string', scalarStyle: ScalarStyle.FOLDED), + 'single-quoted': wrapAsYamlNode('string', + scalarStyle: ScalarStyle.SINGLE_QUOTED), + 'literal': + wrapAsYamlNode('string', scalarStyle: ScalarStyle.LITERAL), + 'double-quoted': wrapAsYamlNode('string', + scalarStyle: ScalarStyle.DOUBLE_QUOTED), + })); + + expect(doc.toString(), equals(''' +strings: + plain: string + folded: >- + string + single-quoted: 'string' + literal: |- + string + double-quoted: "string"''')); + expectYamlBuilderValue(doc, { + 'strings': { + 'plain': 'string', + 'folded': 'string', + 'single-quoted': 'string', + 'literal': 'string', + 'double-quoted': 'string', + } + }); + }); + + test('different scalars in flow list!', () { + final doc = YamlEditor('[0]'); + doc.update( + [0], + wrapAsYamlNode([ + wrapAsYamlNode('plain string', scalarStyle: ScalarStyle.PLAIN), + wrapAsYamlNode('folded string', scalarStyle: ScalarStyle.FOLDED), + wrapAsYamlNode('single-quoted string', + scalarStyle: ScalarStyle.SINGLE_QUOTED), + wrapAsYamlNode('literal string', + scalarStyle: ScalarStyle.LITERAL), + wrapAsYamlNode('double-quoted string', + scalarStyle: ScalarStyle.DOUBLE_QUOTED), + ])); + + expect( + doc.toString(), + equals( + '[[plain string, folded string, \'single-quoted string\', ' + 'literal string, "double-quoted string"]]', + ), + ); + expectYamlBuilderValue(doc, [ + [ + 'plain string', + 'folded string', + 'single-quoted string', + 'literal string', + 'double-quoted string', + ] + ]); + }); + + test('wraps non-printable strings in double-quotes in flow context', () { + final doc = YamlEditor('[0]'); + doc.update([0], '\x00\x07\x08\x0b\x0c\x0d\x1b\x85\xa0\u2028\u2029"'); + expect( + doc.toString(), equals('["\\0\\a\\b\\v\\f\\r\\e\\N\\_\\L\\P\\""]')); + expectYamlBuilderValue( + doc, ['\x00\x07\x08\x0b\x0c\x0d\x1b\x85\xa0\u2028\u2029"']); + }); + + test('wraps non-printable strings in double-quotes in block context', () { + final doc = YamlEditor('- 0'); + doc.update([0], '\x00\x07\x08\x0b\x0c\x0d\x1b\x85\xa0\u2028\u2029"'); + expect( + doc.toString(), equals('- "\\0\\a\\b\\v\\f\\r\\e\\N\\_\\L\\P\\""')); + expectYamlBuilderValue( + doc, ['\x00\x07\x08\x0b\x0c\x0d\x1b\x85\xa0\u2028\u2029"']); + }); + + test('generates folded strings properly', () { + final doc = YamlEditor(''); + doc.update( + [], wrapAsYamlNode('test\ntest', scalarStyle: ScalarStyle.FOLDED)); + expect(doc.toString(), equals('>-\n test\n\n test')); + }); + + test('rewrites folded strings properly', () { + final doc = YamlEditor(''' +- > + folded string +'''); + doc.update( + [0], wrapAsYamlNode('test\ntest', scalarStyle: ScalarStyle.FOLDED)); + expect(doc.toString(), equals(''' +- >- + test + + test +''')); + }); + + test('rewrites folded strings properly (1)', () { + final doc = YamlEditor(''' +- > + folded string'''); + doc.update( + [0], wrapAsYamlNode('test\ntest', scalarStyle: ScalarStyle.FOLDED)); + expect(doc.toString(), equals(''' +- >- + test + + test''')); + }); + + test('generates literal strings properly', () { + final doc = YamlEditor(''); + doc.update( + [], wrapAsYamlNode('test\ntest', scalarStyle: ScalarStyle.LITERAL)); + expect(doc.toString(), equals('|-\n test\n test')); + }); + + test('rewrites literal strings properly', () { + final doc = YamlEditor(''' +- | + literal string +'''); + doc.update([0], + wrapAsYamlNode('test\ntest', scalarStyle: ScalarStyle.LITERAL)); + expect(doc.toString(), equals(''' +- |- + test + test +''')); + }); + + test('prevents literal strings in flow maps, even if nested', () { + final doc = YamlEditor(''' +{1: 1} +'''); + doc.update([ + 1 + ], [ + wrapAsYamlNode('d9]zH`FoYC/>]', scalarStyle: ScalarStyle.LITERAL) + ]); + + expect(doc.toString(), equals(''' +{1: ["d9]zH`FoYC\\/>]"]} +''')); + expect((doc.parseAt([1, 0]) as YamlScalar).style, + equals(ScalarStyle.DOUBLE_QUOTED)); + }); + + test('prevents literal empty strings', () { + final doc = YamlEditor(''' +a: + c: 1 +'''); + doc.update([ + 'a' + ], { + 'f': wrapAsYamlNode('', scalarStyle: ScalarStyle.LITERAL), + 'g': 1 + }); + + expect(doc.toString(), equals(''' +a: + f: "" + g: 1 +''')); + }); + + test('prevents literal strings with leading spaces', () { + final doc = YamlEditor(''' +a: + c: 1 +'''); + doc.update([ + 'a' + ], { + 'f': wrapAsYamlNode(' a', scalarStyle: ScalarStyle.LITERAL), + 'g': 1 + }); + + expect(doc.toString(), equals(''' +a: + f: " a" + g: 1 +''')); + }); + + test( + 'flow collection structure does not get substringed when added to ' + 'block structure', () { + final doc = YamlEditor(''' +a: + - false +'''); + doc.prependToList(['a'], + wrapAsYamlNode([1234], collectionStyle: CollectionStyle.FLOW)); + expect(doc.toString(), equals(''' +a: + - [1234] + - false +''')); + expectYamlBuilderValue(doc, { + 'a': [ + [1234], + false + ] + }); + }); + }); + }); + + group('assertValidScalar', () { + test('does nothing with a boolean', () { + expect(() => assertValidScalar(true), returnsNormally); + }); + + test('does nothing with a number', () { + expect(() => assertValidScalar(1.12), returnsNormally); + }); + test('does nothing with infinity', () { + expect(() => assertValidScalar(double.infinity), returnsNormally); + }); + test('does nothing with a String', () { + expect(() => assertValidScalar('test'), returnsNormally); + }); + + test('does nothing with null', () { + expect(() => assertValidScalar(null), returnsNormally); + }); + + test('throws on map', () { + expect(() => assertValidScalar({'a': 1}), throwsArgumentError); + }); + + test('throws on list', () { + expect(() => assertValidScalar([1]), throwsArgumentError); + }); + }); +} diff --git a/pkgs/yaml_edit/test/windows_test.dart b/pkgs/yaml_edit/test/windows_test.dart new file mode 100644 index 000000000..50f79e743 --- /dev/null +++ b/pkgs/yaml_edit/test/windows_test.dart @@ -0,0 +1,228 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:test/test.dart'; +import 'package:yaml_edit/src/utils.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + +import 'test_utils.dart'; + +void main() { + group('windows line ending detection', () { + test('empty string gives not windows', () { + final doc = YamlEditor(''); + expect(getLineEnding(doc.toString()), equals('\n')); + }); + + test('accurately detects windows documents', () { + final doc = YamlEditor('\r\n'); + expect(getLineEnding(doc.toString()), equals('\r\n')); + }); + + test('accurately detects windows documents (2)', () { + final doc = YamlEditor(''' +a:\r + b:\r + - 1\r + - 2\r +c: 3\r +'''); + expect(getLineEnding(doc.toString()), equals('\r\n')); + }); + }); + + group('modification with windows line endings', () { + test('append element to simple block list ', () { + final doc = YamlEditor(''' +- 0\r +- 1\r +- 2\r +- 3\r +'''); + doc.appendToList([], [4, 5, 6]); + expect(doc.toString(), equals(''' +- 0\r +- 1\r +- 2\r +- 3\r +- - 4\r + - 5\r + - 6\r +''')); + expectYamlBuilderValue(doc, [ + 0, + 1, + 2, + 3, + [4, 5, 6] + ]); + }); + + test('update nested scalar -> flow list', () { + final doc = YamlEditor(''' +a: 1\r +b:\r + d: 4\r + e: 5\r +c: 3\r +'''); + doc.update(['b', 'e'], [1, 2, 3]); + + expect(doc.toString(), equals(''' +a: 1\r +b:\r + d: 4\r + e:\r + - 1\r + - 2\r + - 3\r +c: 3\r +''')); + expectYamlBuilderValue(doc, { + 'a': 1, + 'b': { + 'd': 4, + 'e': [1, 2, 3] + }, + 'c': 3 + }); + }); + + test('update in nested list flow map -> scalar', () { + final doc = YamlEditor(''' +- 0\r +- {a: 1, b: 2}\r +- 2\r +- 3\r +'''); + doc.update([1], 4); + expect(doc.toString(), equals(''' +- 0\r +- 4\r +- 2\r +- 3\r +''')); + expectYamlBuilderValue(doc, [0, 4, 2, 3]); + }); + + test('insert into a list with comments', () { + final doc = YamlEditor(''' +- 0 # comment a\r +- 2 # comment b\r +'''); + doc.insertIntoList([], 1, 1); + expect(doc.toString(), equals(''' +- 0 # comment a\r +- 1\r +- 2 # comment b\r +''')); + expectYamlBuilderValue(doc, [0, 1, 2]); + }); + + test('prepend into a list', () { + final doc = YamlEditor(''' +- 1\r +- 2\r +'''); + doc.prependToList([], [4, 5, 6]); + expect(doc.toString(), equals(''' +- - 4\r + - 5\r + - 6\r +- 1\r +- 2\r +''')); + expectYamlBuilderValue(doc, [ + [4, 5, 6], + 1, + 2 + ]); + }); + + test('remove from block list ', () { + final doc = YamlEditor(''' +- 0\r +- 1\r +- 2\r +- 3\r +'''); + doc.remove([1]); + expect(doc.toString(), equals(''' +- 0\r +- 2\r +- 3\r +''')); + expectYamlBuilderValue(doc, [0, 2, 3]); + }); + + test('remove from block list (2)', () { + final doc = YamlEditor(''' +- 0\r +'''); + doc.remove([0]); + expect(doc.toString(), equals(''' +[]\r +''')); + expectYamlBuilderValue(doc, []); + }); + + test('inserted nested map', () { + final doc = YamlEditor(''' +a:\r + b:\r +'''); + doc.update( + ['a', 'b'], + { + 'c': {'d': 'e'} + }, + ); + expect(doc.toString(), equals(''' +a:\r + b:\r + c:\r + d: e\r +''')); + }); + + test('remove from block map', () { + final doc = YamlEditor(''' +a: 1\r +b: 2\r +c: 3\r +'''); + doc.remove(['b']); + expect(doc.toString(), equals(''' +a: 1\r +c: 3\r +''')); + }); + + test('remove from block map (2)', () { + final doc = YamlEditor(''' +a: 1\r +'''); + doc.remove(['a']); + expect(doc.toString(), equals(''' +{}\r +''')); + expectYamlBuilderValue(doc, {}); + }); + + test('splice block list', () { + final doc = YamlEditor(''' +- 0\r +- 0\r +'''); + final nodes = doc.spliceList([], 0, 2, [0, 1, 2]); + expect(doc.toString(), equals(''' +- 0\r +- 1\r +- 2\r +''')); + + expectDeepEquals(nodes.toList(), [0, 0]); + }); + }); +} diff --git a/pkgs/yaml_edit/test/wrap_test.dart b/pkgs/yaml_edit/test/wrap_test.dart new file mode 100644 index 000000000..60237b97b --- /dev/null +++ b/pkgs/yaml_edit/test/wrap_test.dart @@ -0,0 +1,373 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// ignore_for_file: avoid_dynamic_calls + +import 'dart:io'; + +import 'package:test/test.dart'; +import 'package:yaml/yaml.dart'; +import 'package:yaml_edit/src/equality.dart'; +import 'package:yaml_edit/src/wrap.dart'; + +import 'test_utils.dart'; + +void main() { + group('wrapAsYamlNode', () { + group('checks for invalid scalars', () { + test('fails to wrap an invalid scalar', () { + expect(() => wrapAsYamlNode(File('test.dart')), throwsArgumentError); + }); + + test('fails to wrap an invalid map', () { + expect(() => wrapAsYamlNode({'a': File('test.dart')}), + throwsArgumentError); + }); + + test('fails to wrap an invalid list', () { + expect( + () => wrapAsYamlNode([ + 'a', + [File('test.dart')] + ]), + throwsArgumentError); + }); + + test('checks YamlScalar for invalid scalar value', () { + expect(() => wrapAsYamlNode(YamlScalar.wrap(File('test.dart'))), + throwsArgumentError); + }); + + test('checks YamlMap for deep invalid scalar value', () { + expect( + () => wrapAsYamlNode(YamlMap.wrap({ + 'a': {'b': File('test.dart')} + })), + throwsArgumentError); + }); + + test('checks YamlList for deep invalid scalar value', () { + expect( + () => wrapAsYamlNode(YamlList.wrap([ + 'a', + [File('test.dart')] + ])), + throwsArgumentError); + }); + }); + + test('wraps scalars', () { + final scalar = wrapAsYamlNode('foo'); + + expect((scalar as YamlScalar).style, equals(ScalarStyle.ANY)); + expect(scalar.value, equals('foo')); + }); + + test('wraps scalars with style', () { + final scalar = + wrapAsYamlNode('foo', scalarStyle: ScalarStyle.DOUBLE_QUOTED); + + expect((scalar as YamlScalar).style, equals(ScalarStyle.DOUBLE_QUOTED)); + expect(scalar.value, equals('foo')); + }); + + test('wraps lists', () { + final list = wrapAsYamlNode([ + [1, 2, 3], + { + 'foo': 'bar', + 'nested': [4, 5, 6] + }, + 'value' + ]); + + expect( + list, + equals([ + [1, 2, 3], + { + 'foo': 'bar', + 'nested': [4, 5, 6] + }, + 'value' + ])); + expect((list as YamlList).style, equals(CollectionStyle.ANY)); + expect(list[0].style, equals(CollectionStyle.ANY)); + expect(list[1].style, equals(CollectionStyle.ANY)); + }); + + test('wraps lists with collectionStyle', () { + final list = wrapAsYamlNode([ + [1, 2, 3], + { + 'foo': 'bar', + 'nested': [4, 5, 6] + }, + 'value' + ], collectionStyle: CollectionStyle.BLOCK); + + expect((list as YamlList).style, equals(CollectionStyle.BLOCK)); + expect(list[0].style, equals(CollectionStyle.BLOCK)); + expect(list[1].style, equals(CollectionStyle.BLOCK)); + }); + + test('wraps nested lists while preserving style', () { + final list = wrapAsYamlNode([ + wrapAsYamlNode([1, 2, 3], collectionStyle: CollectionStyle.FLOW), + wrapAsYamlNode({ + 'foo': 'bar', + 'nested': [4, 5, 6] + }, collectionStyle: CollectionStyle.FLOW), + 'value' + ], collectionStyle: CollectionStyle.BLOCK); + + expect((list as YamlList).style, equals(CollectionStyle.BLOCK)); + expect(list[0].style, equals(CollectionStyle.FLOW)); + expect(list[1].style, equals(CollectionStyle.FLOW)); + }); + + test('wraps maps', () { + final map = wrapAsYamlNode({ + 'list': [1, 2, 3], + 'map': { + 'foo': 'bar', + 'nested': [4, 5, 6] + }, + 'scalar': 'value' + }); + + expect( + map, + equals({ + 'list': [1, 2, 3], + 'map': { + 'foo': 'bar', + 'nested': [4, 5, 6] + }, + 'scalar': 'value' + })); + + expect((map as YamlMap).style, equals(CollectionStyle.ANY)); + }); + + test('wraps maps with collectionStyle', () { + final map = wrapAsYamlNode({ + 'list': [1, 2, 3], + 'map': { + 'foo': 'bar', + 'nested': [4, 5, 6] + }, + 'scalar': 'value' + }, collectionStyle: CollectionStyle.BLOCK); + + expect((map as YamlMap).style, equals(CollectionStyle.BLOCK)); + }); + + test('wraps nested maps while preserving style', () { + final map = wrapAsYamlNode({ + 'list': + wrapAsYamlNode([1, 2, 3], collectionStyle: CollectionStyle.FLOW), + 'map': wrapAsYamlNode({ + 'foo': 'bar', + 'nested': [4, 5, 6] + }, collectionStyle: CollectionStyle.BLOCK), + 'scalar': 'value' + }, collectionStyle: CollectionStyle.BLOCK); + + expect((map as YamlMap).style, equals(CollectionStyle.BLOCK)); + expect(map['list'].style, equals(CollectionStyle.FLOW)); + expect(map['map'].style, equals(CollectionStyle.BLOCK)); + }); + + test('works with YamlMap.wrap', () { + final map = wrapAsYamlNode({ + 'list': + wrapAsYamlNode([1, 2, 3], collectionStyle: CollectionStyle.FLOW), + 'map': YamlMap.wrap({ + 'foo': 'bar', + 'nested': [4, 5, 6] + }), + }, collectionStyle: CollectionStyle.BLOCK); + + expect((map as YamlMap).style, equals(CollectionStyle.BLOCK)); + expect(map['list'].style, equals(CollectionStyle.FLOW)); + expect(map['map'].style, equals(CollectionStyle.ANY)); + }); + }); + + test('applies collectionStyle recursively', () { + final list = wrapAsYamlNode([ + [1, 2, 3], + { + 'foo': 'bar', + 'nested': [4, 5, 6] + }, + ], collectionStyle: CollectionStyle.BLOCK); + + expect((list as YamlList).style, equals(CollectionStyle.BLOCK)); + expect(list[0].style, equals(CollectionStyle.BLOCK)); + expect(list[1].style, equals(CollectionStyle.BLOCK)); + expect(list[1]['nested'].style, equals(CollectionStyle.BLOCK)); + }); + + test('applies scalarStyle recursively', () { + final list = wrapAsYamlNode([ + ['a', 'b', 'c'], + { + 'foo': 'bar', + }, + 'hello', + ], scalarStyle: ScalarStyle.SINGLE_QUOTED); + + expect((list as YamlList).style, equals(CollectionStyle.ANY)); + final item1 = list.nodes[0] as YamlList; + final item2 = list.nodes[1] as YamlMap; + final item3 = list.nodes[2] as YamlScalar; + expect(item1.style, equals(CollectionStyle.ANY)); + expect(item2.style, equals(CollectionStyle.ANY)); + expect(item3.style, equals(ScalarStyle.SINGLE_QUOTED)); + + final item1entry1 = item1.nodes[0] as YamlScalar; + expect(item1entry1.style, equals(ScalarStyle.SINGLE_QUOTED)); + + final item2foo = item2.nodes['foo'] as YamlScalar; + expect(item2foo.style, equals(ScalarStyle.SINGLE_QUOTED)); + }); + + group('deepHashCode', () { + test('returns the same result for scalar and its value', () { + final hashCode1 = deepHashCode('foo'); + final hashCode2 = deepHashCode(wrapAsYamlNode('foo')); + + expect(hashCode1, equals(hashCode2)); + }); + + test('returns different results for different values', () { + final hashCode1 = deepHashCode('foo'); + final hashCode2 = deepHashCode(wrapAsYamlNode('bar')); + + expect(hashCode1, notEquals(hashCode2)); + }); + + test('returns the same result for YamlScalar with style and its value', () { + final hashCode1 = deepHashCode('foo'); + final hashCode2 = + deepHashCode(wrapAsYamlNode('foo', scalarStyle: ScalarStyle.LITERAL)); + + expect(hashCode1, equals(hashCode2)); + }); + + test( + 'returns the same result for two YamlScalars with same value but ' + 'different styles', () { + final hashCode1 = + deepHashCode(wrapAsYamlNode('foo', scalarStyle: ScalarStyle.PLAIN)); + final hashCode2 = + deepHashCode(wrapAsYamlNode('foo', scalarStyle: ScalarStyle.LITERAL)); + + expect(hashCode1, equals(hashCode2)); + }); + + test('returns the same result for list and its value', () { + final hashCode1 = deepHashCode([1, 2, 3]); + final hashCode2 = deepHashCode(wrapAsYamlNode([1, 2, 3])); + + expect(hashCode1, equals(hashCode2)); + }); + + test('returns the same result for list and the YamlList.wrap() value', () { + final hashCode1 = deepHashCode([ + 1, + [1, 2], + 3 + ]); + final hashCode2 = deepHashCode(YamlList.wrap([ + 1, + YamlList.wrap([1, 2]), + 3 + ])); + + expect(hashCode1, equals(hashCode2)); + }); + + test('returns the different results for different lists', () { + final hashCode1 = deepHashCode([1, 2, 3]); + final hashCode2 = deepHashCode([1, 2, 4]); + final hashCode3 = deepHashCode([1, 2, 3, 4]); + + expect(hashCode1, notEquals(hashCode2)); + expect(hashCode2, notEquals(hashCode3)); + expect(hashCode3, notEquals(hashCode1)); + }); + + test('returns the same result for YamlList with style and its value', () { + final hashCode1 = deepHashCode([1, 2, 3]); + final hashCode2 = deepHashCode( + wrapAsYamlNode([1, 2, 3], collectionStyle: CollectionStyle.FLOW)); + + expect(hashCode1, equals(hashCode2)); + }); + + test( + 'returns the same result for two YamlLists with same value but ' + 'different styles', () { + final hashCode1 = deepHashCode( + wrapAsYamlNode([1, 2, 3], collectionStyle: CollectionStyle.BLOCK)); + final hashCode2 = deepHashCode(wrapAsYamlNode([1, 2, 3])); + + expect(hashCode1, equals(hashCode2)); + }); + + test('returns the same result for a map and its value', () { + final hashCode1 = deepHashCode({'a': 1, 'b': 2}); + final hashCode2 = deepHashCode(wrapAsYamlNode({'a': 1, 'b': 2})); + + expect(hashCode1, equals(hashCode2)); + }); + + test('returns the same result for list and the YamlList.wrap() value', () { + final hashCode1 = deepHashCode({ + 'a': 1, + 'b': 2, + 'c': {'d': 4, 'e': 5} + }); + final hashCode2 = deepHashCode(YamlMap.wrap({ + 'a': 1, + 'b': 2, + 'c': YamlMap.wrap({'d': 4, 'e': 5}) + })); + + expect(hashCode1, equals(hashCode2)); + }); + + test('returns the different results for different maps', () { + final hashCode1 = deepHashCode({'a': 1, 'b': 2}); + final hashCode2 = deepHashCode({'a': 1, 'b': 3}); + final hashCode3 = deepHashCode({'a': 1, 'b': 2, 'c': 3}); + + expect(hashCode1, notEquals(hashCode2)); + expect(hashCode2, notEquals(hashCode3)); + expect(hashCode3, notEquals(hashCode1)); + }); + + test('returns the same result for YamlMap with style and its value', () { + final hashCode1 = deepHashCode({'a': 1, 'b': 2}); + final hashCode2 = deepHashCode(wrapAsYamlNode({'a': 1, 'b': 2}, + collectionStyle: CollectionStyle.FLOW)); + + expect(hashCode1, equals(hashCode2)); + }); + + test( + 'returns the same result for two YamlMaps with same value but ' + 'different styles', () { + final hashCode1 = deepHashCode(wrapAsYamlNode({'a': 1, 'b': 2}, + collectionStyle: CollectionStyle.BLOCK)); + final hashCode2 = deepHashCode(wrapAsYamlNode({'a': 1, 'b': 2}, + collectionStyle: CollectionStyle.FLOW)); + + expect(hashCode1, equals(hashCode2)); + }); + }); +}