Skip to content

Allow single quotes in "raw" string literals#2357

Merged
natebosch merged 11 commits into
mainfrom
always-escape-string-literals
Apr 15, 2026
Merged

Allow single quotes in "raw" string literals#2357
natebosch merged 11 commits into
mainfrom
always-escape-string-literals

Conversation

@natebosch
Copy link
Copy Markdown
Member

@natebosch natebosch commented Apr 2, 2026

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.

Closes #2351
Closes #2350

I cannot find evidence of use case for writing a string literal that
includes interpolation. Drop the generality and update the doc to
describe a behavior where the content of the resulting string always
matches the content of the argument. Use an implementation from
`package:source_helper` that attempts to choose an idiomatic delimiter
and falls back to single quotes with every character escaped.

Bump major version since this is technically breaking. Most team usages
should be able to use an expanded constraint.
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 2, 2026

Package publishing

Package Version Status Publish tag (post-merge)
package:bazel_worker 1.1.5 already published at pub.dev
package:benchmark_harness 2.4.0 already published at pub.dev
package:boolean_selector 2.1.2 already published at pub.dev
package:browser_launcher 1.2.0-wip WIP (no publish necessary)
package:cli_config 0.2.1-wip WIP (no publish necessary)
package:cli_util 0.5.0-wip WIP (no publish necessary)
package:clock 1.1.3-wip WIP (no publish necessary)
package:code_builder 4.12.0-wip WIP (no publish necessary)
package:coverage 1.15.0 already published at pub.dev
package:csslib 1.0.2 already published at pub.dev
package:extension_discovery 2.1.0 already published at pub.dev
package:file 7.0.2-wip WIP (no publish necessary)
package:file_testing 3.1.0-wip WIP (no publish necessary)
package:glob 2.1.3 already published at pub.dev
package:graphs 2.4.0-wip WIP (no publish necessary)
package:html 0.15.7-wip WIP (no publish necessary)
package:io 1.1.0-wip WIP (no publish necessary)
package:json_rpc_2 4.1.0 already published at pub.dev
package:markdown 7.3.1 already published at pub.dev
package:mime 2.1.0-wip WIP (no publish necessary)
package:oauth2 2.0.5 already published at pub.dev
package:package_config 2.3.0-wip WIP (no publish necessary)
package:pool 1.5.3-wip WIP (no publish necessary)
package:process 5.0.5 (error) pubspec version (5.0.5) and changelog (5.0.6-wip) don't agree
package:pub_semver 2.2.0 already published at pub.dev
package:pubspec_parse 1.6.0-wip WIP (no publish necessary)
package:source_map_stack_trace 2.1.3-wip WIP (no publish necessary)
package:source_maps 0.10.14-wip WIP (no publish necessary)
package:source_span 1.10.2 already published at pub.dev
package:sse 4.2.0 already published at pub.dev
package:stack_trace 1.12.2-wip (error) pubspec version (1.12.2-wip) and changelog (1.12.2-dev) don't agree
package:stream_channel 2.1.4 already published at pub.dev
package:stream_transform 2.1.2-wip WIP (no publish necessary)
package:string_scanner 1.4.2-wip WIP (no publish necessary)
package:term_glyph 1.2.3-wip WIP (no publish necessary)
package:test_reflective_loader 0.6.0 ready to publish test_reflective_loader-v0.6.0
package:timing 1.0.2 already published at pub.dev
package:unified_analytics 8.0.14 already published at pub.dev
package:watcher 1.2.2-wip WIP (no publish necessary)
package:yaml 3.1.4-wip WIP (no publish necessary)
package:yaml_edit 2.2.4 already published at pub.dev

Documentation at https://github.com/dart-lang/ecosystem/wiki/Publishing-automation.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 2, 2026

PR Health

License Headers ✔️
// Copyright (c) 2026, 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.

Files
no missing headers

All source files should start with a license header.

Unrelated files missing license headers
Files
pkgs/bazel_worker/benchmark/benchmark.dart
pkgs/coverage/lib/src/coverage_options.dart
pkgs/html/example/main.dart
pkgs/pubspec_parse/test/git_uri_test.dart
pkgs/watcher/test/custom_watcher_factory_test.dart

This check can be disabled by tagging the PR with skip-license-check.

Unused Dependencies ⚠️
Package Status
code_builder
❗ Show Issues
These packages may be unused, or you may be using assets from these packages:
* build
* source_gen

For details on how to fix these, see dependency_validator.

This check can be disabled by tagging the PR with skip-unused-dependencies-check.

Changelog Entry ✔️
Package Changed Files

Changes to files need to be accounted for in their respective changelogs.

This check can be disabled by tagging the PR with skip-changelog-check.

Coverage ⚠️
File Coverage
pkgs/code_builder/lib/src/specs/expression/literal.dart 💔 78 % ⬇️ 3 %

This check for test coverage is informational (issues shown here will not fail the PR).

This check can be disabled by tagging the PR with skip-coverage-check.

Breaking changes ✔️
Package Change Current Version New Version Needed Version Looking good?
code_builder None 4.11.1 4.12.0-wip 4.12.0-wip ✔️

This check can be disabled by tagging the PR with skip-breaking-check.

API leaks ✔️

The following packages contain symbols visible in the public API, but not exported by the library. Export these symbols or remove them from your publicly visible API.

Package Leaked API symbol Leaking sources

This check can be disabled by tagging the PR with skip-leaking-check.

@natebosch
Copy link
Copy Markdown
Member Author

Ugh, I forgot that code_builder is one of the hard ones to rev versions.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a breaking change to literalString, removing the raw parameter in favor of an automated escaping mechanism that handles single and double quotes, dollar signs, and control characters. The package version is updated to 5.0.0-wip. Review feedback highlights logic errors in the newly added test cases regarding the expected output for strings with dollar signs. Suggestions were also provided to avoid variable shadowing in the escaping logic and to optimize performance by defining the escape map regular expression as a constant.

Comment thread pkgs/code_builder/test/specs/code/expression_test.dart Outdated
Comment thread pkgs/code_builder/test/specs/code/expression_test.dart Outdated
Comment thread pkgs/code_builder/lib/src/specs/expression/literal.dart Outdated
Comment thread pkgs/code_builder/lib/src/specs/expression/literal.dart Outdated
Avoid a dependency cycle with `package:build_runner` during the major
version bump. We won't be able to regenerate `built_value` classes until
we update build runner for compatibility
@natebosch
Copy link
Copy Markdown
Member Author

@davidmorgan - this change will impact mockito. Currently that package works around the existing behavior. I don't think a true incremental migration is very feasible - we want to retain the literalString name for this behavior.

https://github.com/dart-lang/build/blob/37396be8c572d28010efde9c42f9a9b7c0ded822/builder_pkgs/mockito/lib/src/builder.dart#L2131-L2134

Since mockito is google3 SoT I plan to:

  • land this change
  • manually sync to google3 in advance of the SDK sync. Migrate mockito in the same CL and change dep to ^5.0.0
  • sync to the SDK - will have no effect
  • publish code_builder
  • sync mockito to dart-lang/build. Expand the dep from package:build_runner to '>=4.2.0 <6.0.0'

Do you have any concerns?

@natebosch
Copy link
Copy Markdown
Member Author

After a brief discussion, I'm now leaning towards making this an incremental migration. It will require two major version bumps, but it will allow compatibility with google3 during all phases.

  • Change the behavior of literalString(raw: true) to do the full escaping, not necessarily resulting in an actual raw string. Non-breaking version bump.
  • Roll the package.
  • Update mockito (and any other internal use) to always pass raw: true. Remove extra logic for escaping $.
  • Publish (non-breaking release)
  • Change the behavior of literalString() to match literalString(raw: true). Breaking version bump. Deprecate the argument.
  • Roll the package.
  • Update internal uses to remove the raw: true arguments.
  • Publish (breaking release)
  • Full drop the raw: argument. Breaking version bump.

@natebosch natebosch changed the title Fully escape all string literals Allow single quotes in "raw" string literals Apr 4, 2026
@natebosch natebosch marked this pull request as ready for review April 4, 2026 00:10
@natebosch natebosch requested a review from a team as a code owner April 4, 2026 00:10
@natebosch natebosch requested a review from kevmoo April 4, 2026 00:10
@kevmoo
Copy link
Copy Markdown
Member

kevmoo commented Apr 4, 2026

/gemini review

(just to bother @natebosch )

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the literalString function to support single quotes when raw: true is used, implementing a logic that chooses between single/double quotes and raw/non-raw formats. It includes a version bump to 4.12.0-wip and comprehensive tests. Feedback suggests removing a redundant backslash entry in the escape map and using a more robust Unicode escape format for hex literals.

'\f': r'\f', // 0C - form feed
'\r': r'\r', // 0D - carriage return
'\x7F': r'\x7F', // delete
r'\': r'\\', // backslash
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The entry for the backslash character in _escapeMap is redundant because backslashes are explicitly handled in the replaceAllMapped callback within _escapeString (lines 75-78). Removing this entry would improve maintainability and avoid confusion about how backslashes are processed.

Comment on lines +99 to +105
String _hexLiteral(String input) {
final value = input.runes.single
.toRadixString(16)
.toUpperCase()
.padLeft(2, '0');
return '\\x$value';
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The _hexLiteral function currently uses \x and pads to 2 digits, which is only valid for 8-bit characters (up to \xFF). While the current _escapeRegExp only matches characters within this range, this implementation is fragile if the regex is expanded in the future to include non-ASCII characters. Consider using the more robust \u{...} format which supports all Unicode code points in Dart.

String _hexLiteral(String input) {
  final value = input.runes.single
      .toRadixString(16)
      .toUpperCase();
  return '\\u{$value}';
}

@kevmoo
Copy link
Copy Markdown
Member

kevmoo commented Apr 5, 2026

I tried this w/ my json_serializable migration and it an issue with $ strings.

After hacking I got this diff which made things work.

diff --git a/pkgs/code_builder/lib/src/specs/expression/literal.dart b/pkgs/code_builder/lib/src/specs/expression/literal.dart
index 1ec0136a..98ee4121 100644
--- a/pkgs/code_builder/lib/src/specs/expression/literal.dart
+++ b/pkgs/code_builder/lib/src/specs/expression/literal.dart
@@ -48,7 +48,9 @@ Expression literalNum(num value) => LiteralExpression._('$value');
 /// or double quotes, and may not actually be marked raw, depending on the
 /// content. All disallowed characters are automatically escaped.
 Expression literalString(String value, {bool raw = false}) {
-  if (raw) return LiteralExpression._(_escapeString(value));
+  if (raw || value.contains(r'$')) {
+    return LiteralExpression._(_escapeString(value));
+  }
   final escaped = value.replaceAll('\'', '\\\'').replaceAll('\n', '\\n');
   return LiteralExpression._("'$escaped'");
 }
diff --git a/pkgs/code_builder/test/specs/code/expression_test.dart b/pkgs/code_builder/test/specs/code/expression_test.dart
index 6ac88571..46643f9a 100644
--- a/pkgs/code_builder/test/specs/code/expression_test.dart
+++ b/pkgs/code_builder/test/specs/code/expression_test.dart
@@ -30,6 +30,9 @@ void main() {
       test('string values', () {
         expect(literal('foo'), equalsDart("'foo'"));
       });
+      test('string values containing \$ should use raw strings', () {
+        expect(literal(r'$schema'), equalsDart(r"r'$schema'"));
+      });
       test('list values', () {
         expect(literal([1]), equalsDart('[1]'));
       });
@@ -63,7 +66,7 @@ void main() {
 
   group('literalString legacy', () {
     test('should emit a String', () {
-      expect(literalString(r'$monkey'), equalsDart(r"'$monkey'"));
+      expect(literalString(r'$monkey'), equalsDart(r"r'$monkey'"));
     });
 
     test('should emit a raw String', () {

@davidmorgan
Copy link
Copy Markdown
Contributor

@davidmorgan - this change will impact mockito. Currently that package works around the existing behavior. I don't think a true incremental migration is very feasible - we want to retain the literalString name for this behavior.

https://github.com/dart-lang/build/blob/37396be8c572d28010efde9c42f9a9b7c0ded822/builder_pkgs/mockito/lib/src/builder.dart#L2131-L2134

Since mockito is google3 SoT I plan to:

  • land this change
  • manually sync to google3 in advance of the SDK sync. Migrate mockito in the same CL and change dep to ^5.0.0
  • sync to the SDK - will have no effect
  • publish code_builder
  • sync mockito to dart-lang/build. Expand the dep from package:build_runner to '>=4.2.0 <6.0.0'

Do you have any concerns?

Mockito is not google3-first any more, it's synced as needed github->google3 by me.

The script that build_runner generates is much simpler than it used to be, I'm not sure the dependency onto code_builder is carrying its weight any more. Would it make sense for me to look at removing the dep before you start on code_builder releases?

I'm not super convinced about the idea a code_builder breaking change for something so small, we want to compile arbitrary sets of builders together based on user config and this will get in the way. How about literalString2 + deprecate literalString, and pile up more such changes before deciding to do a breaking change?

@natebosch
Copy link
Copy Markdown
Member Author

I tried this w/ my json_serializable migration and it an issue with $ strings.

After hacking I got this diff which made things work.

That change works exactly against the incremental migration goals.
#2357 (comment)

We want to keep the behavior of raw: false the same so it does not break any existing code. During the migration call sites will start to all pass raw: true to get the intended final behavior. The first major version bump will break the behavior of raw: false, but keep the behavior of raw: true. During this migration call sites will remove the raw argument. The second major version bump will remove the argument.

How about literalString2 + deprecate literalString, and pile up more such changes before deciding to do a breaking change?

I have already adjusted the PR to an incremental migration approach. Luckily we can avoid the ugly naming.
See #2357 (comment)

I'll keep that in mind and update mockito in the build repo after this starts landing.

We can discuss timeline for the major version bump once that PR is ready. Impacted packages can use wide bounds across the two migrations code_builder: '>=4.11.0 <6.0.0', then code_builder: '>=5.0.0 <7.0.0', which will keep larger sets of builders cross-compatible. I typically would not be inclined to artificially delay the migration further than whatever it ends up taking to get things landed ourselves, but in this case I think the cost of delay is small so it should be fine letting the major version bumps wait a bit.

@davidmorgan
Copy link
Copy Markdown
Contributor

Sounds good, thanks.

I went ahead and removed the code_builder dep from build_runner, working on rolling into google3. So code_builders and individual builders are more free to do as they like :)

@natebosch natebosch merged commit 123bc1f into main Apr 15, 2026
17 checks passed
@natebosch natebosch deleted the always-escape-string-literals branch April 15, 2026 23:19
natebosch added a commit that referenced this pull request Apr 23, 2026
In #2357 we added more complex escaping to allow creating safe string
literals even when they may include single quote characters. The new
implementation didn't prefer raw strings by default so use existing uses
changed behavior and some tests which hardcode expected generated output
a non-behavior impacting diff appears.

Prefer to use actual prefixed raw strings when there are no single
quotes. Existing callers passing strings allowed by the old
implementation will not be impacted by the change. In the next steps of
the migration calling sites will only see a behavior difference as they
change their arguments. In the end all tests will be migrated to the
code path which doesn't prefer raw prefixed strings, but the incremental
changes will only impact tests when library code is changing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants