blob: 1b8564179d2f86b7142a66c73f8f9203aa82c55f [file] [log] [blame]
// 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<String> 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<int, String> 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<int, String> 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).
};