Allow single quotes in "raw" string literals (#2357)
Closes #2352
Closes #2350
The raw string behavior is more useful for codegen, but it disallows
single quotes in the content which is too limiting. Expand the behavior
to always create an allowed string literal with exactly the same content
as the argument and allow single quotes. The literal is no longer
guaranteed to be an actual raw string tagged with `r`, but there are no
behavior differences and this matches the intent for the argument.
A future breaking change will change the default behavior of the method
to match this new `raw: true` behavior. Changing the current behavior
with the argument allows for an incremental migration. There are not
dependencies on the existing behavior which guarantees the `r` prefix. A
subsequent breaking change will remove the argument altogether.
diff --git a/pkgs/code_builder/CHANGELOG.md b/pkgs/code_builder/CHANGELOG.md
index da167ef..8721305 100644
--- a/pkgs/code_builder/CHANGELOG.md
+++ b/pkgs/code_builder/CHANGELOG.md
@@ -1,5 +1,8 @@
-## 4.11.2-wip
+## 4.12.0-wip
+* Allow single quotes in strings passed to `literalString(raw:true)`. This
+ argument no longer guarantees a raw string is used, but results will have the
+ same behavior.
* Correct type annotations on nullable and generic variables created with
`declareVar`, `declareFinal`, and `declareConst`.
diff --git a/pkgs/code_builder/lib/src/specs/expression/literal.dart b/pkgs/code_builder/lib/src/specs/expression/literal.dart
index d4b687a..a12ec30 100644
--- a/pkgs/code_builder/lib/src/specs/expression/literal.dart
+++ b/pkgs/code_builder/lib/src/specs/expression/literal.dart
@@ -41,19 +41,92 @@
/// Create a literal expression from a string [value].
///
-/// **NOTE**: The string is always formatted `'<value>'`.
+/// Returns an expression for a string formatted `'<value>'`.
///
-/// If [raw] is `true`, creates a raw String formatted `r'<value>'` and the
-/// value may not contain a single quote.
-/// Escapes single quotes and newlines in the value.
+/// If [raw] is `true` returns an expression that will evaluate to a String
+/// containing exactly the same content as [value]. The literal may use single
+/// or double quotes, and may not actually be marked raw, depending on the
+/// content. All disallowed characters are automatically escaped.
+///
+/// Passing `raw: true` is recommended and will become the only option in a
+/// future release.
Expression literalString(String value, {bool raw = false}) {
- if (raw && value.contains('\'')) {
- throw ArgumentError('Cannot include a single quote in a raw string');
- }
+ if (raw) return LiteralExpression._(_escapeString(value));
final escaped = value.replaceAll('\'', '\\\'').replaceAll('\n', '\\n');
- return LiteralExpression._("${raw ? 'r' : ''}'$escaped'");
+ return LiteralExpression._("'$escaped'");
}
+String _escapeString(String value) {
+ final original = value;
+ var hasSingleQuote = false;
+ var hasDoubleQuote = false;
+ var hasDollar = false;
+ var hasBackslash = false;
+ var canBeRaw = true;
+
+ value = value.replaceAllMapped(_escapeRegExp, (match) {
+ final char = match[0]!;
+ if (char == "'") {
+ hasSingleQuote = true;
+ return char;
+ } else if (char == '"') {
+ hasDoubleQuote = true;
+ return char;
+ } else if (char == r'$') {
+ hasDollar = true;
+ return char;
+ } else if (char == r'\') {
+ hasBackslash = true;
+ return r'\\';
+ }
+
+ canBeRaw = false;
+ return _escapeMap[char] ?? _hexLiteral(char);
+ });
+
+ if (canBeRaw && (hasDollar || hasBackslash)) {
+ if (!hasSingleQuote) return "r'$original'";
+ if (!hasDoubleQuote) return 'r"$original"';
+ }
+
+ if (!hasDollar) {
+ if (!hasSingleQuote) return "'$value'";
+ if (!hasDoubleQuote) return '"$value"';
+ }
+
+ value = value.replaceAll(_dollarQuoteRegexp, r'\');
+ return "'$value'";
+}
+
+/// Given single-character string, return the hex-escaped equivalent.
+String _hexLiteral(String input) {
+ final value = input.runes.single
+ .toRadixString(16)
+ .toUpperCase()
+ .padLeft(2, '0');
+ return '\\x$value';
+}
+
+final _dollarQuoteRegexp = RegExp(r"(?=[$'])");
+
+/// A map from whitespace characters & `\` to their escape sequences.
+const _escapeMap = {
+ '\b': r'\b', // 08 - backspace
+ '\t': r'\t', // 09 - tab
+ '\n': r'\n', // 0A - new line
+ '\v': r'\v', // 0B - vertical tab
+ '\f': r'\f', // 0C - form feed
+ '\r': r'\r', // 0D - carriage return
+ '\x7F': r'\x7F', // delete
+};
+
+/// A [RegExp] that matches whitespace characters that must be escaped and
+/// single-quote, double-quote, and `$`
+final _escapeRegExp = RegExp('[\$\'"\\x00-\\x07\\x0E-\\x1F$_escapeMapRegexp]');
+
+// _escapeMap.keys.map(_hexLiteral).join();
+const _escapeMapRegexp = r'\x08\x09\x0A\x0B\x0C\x0D\x7F\x5C';
+
/// Create a literal `...` operator for use when creating a Map literal.
///
/// *NOTE* This is used as a sentinel when constructing a `literalMap` or a
diff --git a/pkgs/code_builder/pubspec.yaml b/pkgs/code_builder/pubspec.yaml
index 5dd293e..515b2e6 100644
--- a/pkgs/code_builder/pubspec.yaml
+++ b/pkgs/code_builder/pubspec.yaml
@@ -1,5 +1,5 @@
name: code_builder
-version: 4.11.2-wip
+version: 4.12.0-wip
description: A fluent, builder-based library for generating valid Dart code.
repository: https://github.com/dart-lang/tools/tree/main/pkgs/code_builder
issue_tracker: https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Acode_builder
diff --git a/pkgs/code_builder/test/specs/code/expression_test.dart b/pkgs/code_builder/test/specs/code/expression_test.dart
index 53f2ba5..6ac8857 100644
--- a/pkgs/code_builder/test/specs/code/expression_test.dart
+++ b/pkgs/code_builder/test/specs/code/expression_test.dart
@@ -61,24 +61,107 @@
});
});
- test('should emit a String', () {
- expect(literalString(r'$monkey'), equalsDart(r"'$monkey'"));
+ group('literalString legacy', () {
+ test('should emit a String', () {
+ expect(literalString(r'$monkey'), equalsDart(r"'$monkey'"));
+ });
+
+ test('should emit a raw String', () {
+ expect(literalString(r'$monkey', raw: true), equalsDart(r"r'$monkey'"));
+ });
+
+ test('should escape single quotes in a String', () {
+ expect(literalString(r"don't"), equalsDart(r"'don\'t'"));
+ });
+
+ test('should escape a newline in a string', () {
+ expect(literalString('some\nthing'), equalsDart(r"'some\nthing'"));
+ });
});
- test('should emit a raw String', () {
- expect(literalString(r'$monkey', raw: true), equalsDart(r"r'$monkey'"));
- });
+ group('literalString raw', () {
+ test('should emit a simple string', () {
+ expect(literalString(raw: true, 'foo'), equalsDart(r"'foo'"));
+ });
- test('should escape single quotes in a String', () {
- expect(literalString(r"don't"), equalsDart(r"'don\'t'"));
- });
+ test('should emit an empty string', () {
+ expect(literalString(raw: true, ''), equalsDart("''"));
+ });
- test('does not allow single quote in raw string', () {
- expect(() => literalString(r"don't", raw: true), throwsArgumentError);
- });
+ test('should use double quotes for just a single quote', () {
+ expect(literalString(raw: true, "'"), equalsDart('"\'"'));
+ });
- test('should escape a newline in a string', () {
- expect(literalString('some\nthing'), equalsDart(r"'some\nthing'"));
+ test('should use single quotes for just a double quote', () {
+ expect(literalString(raw: true, '"'), equalsDart("'\"'"));
+ });
+
+ test('should use raw string for a single backslash', () {
+ expect(literalString(raw: true, '\\'), equalsDart("r'\\'"));
+ });
+
+ test('should emit unicode characters', () {
+ expect(literalString(raw: true, '😊'), equalsDart("'😊'"));
+ });
+
+ test('should escape a carriage return in a string', () {
+ expect(
+ literalString(raw: true, 'some\rthing'),
+ equalsDart(r"'some\rthing'"),
+ );
+ });
+
+ test('should use raw string for backslashes', () {
+ expect(literalString(raw: true, r'a\tb'), equalsDart("r'a\\tb'"));
+ });
+
+ test('should use double quotes if it contains single quotes', () {
+ expect(literalString(raw: true, "don't"), equalsDart('"don\'t"'));
+ });
+
+ test('should use single quotes if it contains double quotes', () {
+ expect(
+ literalString(raw: true, 'foo "bar"'),
+ equalsDart('\'foo "bar"\''),
+ );
+ });
+
+ test('should escape single quotes if it contains both quotes', () {
+ expect(
+ literalString(raw: true, 'don\'t "bar"'),
+ equalsDart('\'don\\\'t "bar"\''),
+ );
+ });
+
+ test('should use raw single quotes for dollar signs if possible', () {
+ expect(literalString(raw: true, r'$foo'), equalsDart(r"r'$foo'"));
+ });
+
+ test('should use raw double quotes for dollar signs and single quotes '
+ 'if possible', () {
+ expect(
+ literalString(raw: true, r"don't $foo"),
+ equalsDart('r"don\'t \$foo"'),
+ );
+ });
+
+ test('should escape if it contains dollar, single, and double quotes', () {
+ expect(
+ literalString(raw: true, 'don\'t "bar" \$foo'),
+ equalsDart('\'don\\\'t "bar" \\\$foo\''),
+ );
+ });
+
+ test('should escape control characters', () {
+ expect(literalString(raw: true, 'foo\nbar'), equalsDart('\'foo\\nbar\''));
+ });
+
+ test('should escape control characters and dollar signs', () {
+ expect(
+ literalString(raw: true, 'foo\n\$bar'),
+ equalsDart('\'foo\\n\\\$bar\''),
+ );
+ });
});
test('should emit a && expression', () {