Skip to content

Commit 123bc1f

Browse files
authored
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.
1 parent 667c699 commit 123bc1f

4 files changed

Lines changed: 182 additions & 23 deletions

File tree

pkgs/code_builder/CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
## 4.11.2-wip
1+
## 4.12.0-wip
22

3+
* Allow single quotes in strings passed to `literalString(raw:true)`. This
4+
argument no longer guarantees a raw string is used, but results will have the
5+
same behavior.
36
* Correct type annotations on nullable and generic variables created with
47
`declareVar`, `declareFinal`, and `declareConst`.
58

pkgs/code_builder/lib/src/specs/expression/literal.dart

Lines changed: 81 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,19 +41,92 @@ Expression literalNum(num value) => LiteralExpression._('$value');
4141

4242
/// Create a literal expression from a string [value].
4343
///
44-
/// **NOTE**: The string is always formatted `'<value>'`.
44+
/// Returns an expression for a string formatted `'<value>'`.
4545
///
46-
/// If [raw] is `true`, creates a raw String formatted `r'<value>'` and the
47-
/// value may not contain a single quote.
48-
/// Escapes single quotes and newlines in the value.
46+
/// If [raw] is `true` returns an expression that will evaluate to a String
47+
/// containing exactly the same content as [value]. The literal may use single
48+
/// or double quotes, and may not actually be marked raw, depending on the
49+
/// content. All disallowed characters are automatically escaped.
50+
///
51+
/// Passing `raw: true` is recommended and will become the only option in a
52+
/// future release.
4953
Expression literalString(String value, {bool raw = false}) {
50-
if (raw && value.contains('\'')) {
51-
throw ArgumentError('Cannot include a single quote in a raw string');
52-
}
54+
if (raw) return LiteralExpression._(_escapeString(value));
5355
final escaped = value.replaceAll('\'', '\\\'').replaceAll('\n', '\\n');
54-
return LiteralExpression._("${raw ? 'r' : ''}'$escaped'");
56+
return LiteralExpression._("'$escaped'");
5557
}
5658

59+
String _escapeString(String value) {
60+
final original = value;
61+
var hasSingleQuote = false;
62+
var hasDoubleQuote = false;
63+
var hasDollar = false;
64+
var hasBackslash = false;
65+
var canBeRaw = true;
66+
67+
value = value.replaceAllMapped(_escapeRegExp, (match) {
68+
final char = match[0]!;
69+
if (char == "'") {
70+
hasSingleQuote = true;
71+
return char;
72+
} else if (char == '"') {
73+
hasDoubleQuote = true;
74+
return char;
75+
} else if (char == r'$') {
76+
hasDollar = true;
77+
return char;
78+
} else if (char == r'\') {
79+
hasBackslash = true;
80+
return r'\\';
81+
}
82+
83+
canBeRaw = false;
84+
return _escapeMap[char] ?? _hexLiteral(char);
85+
});
86+
87+
if (canBeRaw && (hasDollar || hasBackslash)) {
88+
if (!hasSingleQuote) return "r'$original'";
89+
if (!hasDoubleQuote) return 'r"$original"';
90+
}
91+
92+
if (!hasDollar) {
93+
if (!hasSingleQuote) return "'$value'";
94+
if (!hasDoubleQuote) return '"$value"';
95+
}
96+
97+
value = value.replaceAll(_dollarQuoteRegexp, r'\');
98+
return "'$value'";
99+
}
100+
101+
/// Given single-character string, return the hex-escaped equivalent.
102+
String _hexLiteral(String input) {
103+
final value = input.runes.single
104+
.toRadixString(16)
105+
.toUpperCase()
106+
.padLeft(2, '0');
107+
return '\\x$value';
108+
}
109+
110+
final _dollarQuoteRegexp = RegExp(r"(?=[$'])");
111+
112+
/// A map from whitespace characters & `\` to their escape sequences.
113+
const _escapeMap = {
114+
'\b': r'\b', // 08 - backspace
115+
'\t': r'\t', // 09 - tab
116+
'\n': r'\n', // 0A - new line
117+
'\v': r'\v', // 0B - vertical tab
118+
'\f': r'\f', // 0C - form feed
119+
'\r': r'\r', // 0D - carriage return
120+
'\x7F': r'\x7F', // delete
121+
};
122+
123+
/// A [RegExp] that matches whitespace characters that must be escaped and
124+
/// single-quote, double-quote, and `$`
125+
final _escapeRegExp = RegExp('[\$\'"\\x00-\\x07\\x0E-\\x1F$_escapeMapRegexp]');
126+
127+
// _escapeMap.keys.map(_hexLiteral).join();
128+
const _escapeMapRegexp = r'\x08\x09\x0A\x0B\x0C\x0D\x7F\x5C';
129+
57130
/// Create a literal `...` operator for use when creating a Map literal.
58131
///
59132
/// *NOTE* This is used as a sentinel when constructing a `literalMap` or a

pkgs/code_builder/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: code_builder
2-
version: 4.11.2-wip
2+
version: 4.12.0-wip
33
description: A fluent, builder-based library for generating valid Dart code.
44
repository: https://github.com/dart-lang/tools/tree/main/pkgs/code_builder
55
issue_tracker: https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Acode_builder

pkgs/code_builder/test/specs/code/expression_test.dart

Lines changed: 96 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -61,24 +61,107 @@ void main() {
6161
});
6262
});
6363

64-
test('should emit a String', () {
65-
expect(literalString(r'$monkey'), equalsDart(r"'$monkey'"));
66-
});
64+
group('literalString legacy', () {
65+
test('should emit a String', () {
66+
expect(literalString(r'$monkey'), equalsDart(r"'$monkey'"));
67+
});
6768

68-
test('should emit a raw String', () {
69-
expect(literalString(r'$monkey', raw: true), equalsDart(r"r'$monkey'"));
70-
});
69+
test('should emit a raw String', () {
70+
expect(literalString(r'$monkey', raw: true), equalsDart(r"r'$monkey'"));
71+
});
7172

72-
test('should escape single quotes in a String', () {
73-
expect(literalString(r"don't"), equalsDart(r"'don\'t'"));
74-
});
73+
test('should escape single quotes in a String', () {
74+
expect(literalString(r"don't"), equalsDart(r"'don\'t'"));
75+
});
7576

76-
test('does not allow single quote in raw string', () {
77-
expect(() => literalString(r"don't", raw: true), throwsArgumentError);
77+
test('should escape a newline in a string', () {
78+
expect(literalString('some\nthing'), equalsDart(r"'some\nthing'"));
79+
});
7880
});
7981

80-
test('should escape a newline in a string', () {
81-
expect(literalString('some\nthing'), equalsDart(r"'some\nthing'"));
82+
group('literalString raw', () {
83+
test('should emit a simple string', () {
84+
expect(literalString(raw: true, 'foo'), equalsDart(r"'foo'"));
85+
});
86+
87+
test('should emit an empty string', () {
88+
expect(literalString(raw: true, ''), equalsDart("''"));
89+
});
90+
91+
test('should use double quotes for just a single quote', () {
92+
expect(literalString(raw: true, "'"), equalsDart('"\'"'));
93+
});
94+
95+
test('should use single quotes for just a double quote', () {
96+
expect(literalString(raw: true, '"'), equalsDart("'\"'"));
97+
});
98+
99+
test('should use raw string for a single backslash', () {
100+
expect(literalString(raw: true, '\\'), equalsDart("r'\\'"));
101+
});
102+
103+
test('should emit unicode characters', () {
104+
expect(literalString(raw: true, '😊'), equalsDart("'😊'"));
105+
});
106+
107+
test('should escape a carriage return in a string', () {
108+
expect(
109+
literalString(raw: true, 'some\rthing'),
110+
equalsDart(r"'some\rthing'"),
111+
);
112+
});
113+
114+
test('should use raw string for backslashes', () {
115+
expect(literalString(raw: true, r'a\tb'), equalsDart("r'a\\tb'"));
116+
});
117+
118+
test('should use double quotes if it contains single quotes', () {
119+
expect(literalString(raw: true, "don't"), equalsDart('"don\'t"'));
120+
});
121+
122+
test('should use single quotes if it contains double quotes', () {
123+
expect(
124+
literalString(raw: true, 'foo "bar"'),
125+
equalsDart('\'foo "bar"\''),
126+
);
127+
});
128+
129+
test('should escape single quotes if it contains both quotes', () {
130+
expect(
131+
literalString(raw: true, 'don\'t "bar"'),
132+
equalsDart('\'don\\\'t "bar"\''),
133+
);
134+
});
135+
136+
test('should use raw single quotes for dollar signs if possible', () {
137+
expect(literalString(raw: true, r'$foo'), equalsDart(r"r'$foo'"));
138+
});
139+
140+
test('should use raw double quotes for dollar signs and single quotes '
141+
'if possible', () {
142+
expect(
143+
literalString(raw: true, r"don't $foo"),
144+
equalsDart('r"don\'t \$foo"'),
145+
);
146+
});
147+
148+
test('should escape if it contains dollar, single, and double quotes', () {
149+
expect(
150+
literalString(raw: true, 'don\'t "bar" \$foo'),
151+
equalsDart('\'don\\\'t "bar" \\\$foo\''),
152+
);
153+
});
154+
155+
test('should escape control characters', () {
156+
expect(literalString(raw: true, 'foo\nbar'), equalsDart('\'foo\\nbar\''));
157+
});
158+
159+
test('should escape control characters and dollar signs', () {
160+
expect(
161+
literalString(raw: true, 'foo\n\$bar'),
162+
equalsDart('\'foo\\n\\\$bar\''),
163+
);
164+
});
82165
});
83166

84167
test('should emit a && expression', () {

0 commit comments

Comments
 (0)