diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index 5a86a819..c5c921ef 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -37,6 +37,12 @@ * `inference.dart` exports shared type and constraint inference for other generators. The class-first generator uses this library. +### Fixed + +* Escape line terminators and other control characters in every generated + string literal. `@Pattern`, discriminator, and key values containing a + newline or carriage return now produce valid Dart source. + ## 1.6.2 ### Changed diff --git a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart index bc17c781..68a48b61 100644 --- a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart @@ -14,6 +14,7 @@ import '../json/helper_names.dart'; import '../models/schema_model_graph.dart'; import '../inference/schema_inference.dart'; import 'generated_companion_visibility.dart'; +import '../utils/string_literal.dart'; typedef _ModelOptions = ({ String? schemaName, @@ -614,7 +615,7 @@ final class ClassModelGraphBuilder { isDiscriminator: isDiscriminator, ); var schema = isDiscriminator && discriminatorValue != null - ? '${_ack('Ack')}.literal(${_literal(discriminatorValue)})' + ? '${_ack('Ack')}.literal(${dartStringLiteral(discriminatorValue)})' : await _fieldSchema(field, futureType: futureType); schema = _inference.applyDescription(schema, field); schema = _applyPresence( @@ -1940,14 +1941,6 @@ final class ClassModelGraphBuilder { return prefix == null || prefix.isEmpty ? symbol : '$prefix.$symbol'; } - String _literal(String value) { - final escaped = value - .replaceAll(r'\', r'\\') - .replaceAll("'", r"\'") - .replaceAll(r'$', r'\$'); - return "'$escaped'"; - } - AckSchemaId _id(ClassElement element) => AckSchemaId( libraryUri: library.element.uri, declarationName: element.name!, diff --git a/packages/ack_generator/lib/src/builders/class_model_emitter.dart b/packages/ack_generator/lib/src/builders/class_model_emitter.dart index 8bccd45d..bbebdbd0 100644 --- a/packages/ack_generator/lib/src/builders/class_model_emitter.dart +++ b/packages/ack_generator/lib/src/builders/class_model_emitter.dart @@ -1,6 +1,7 @@ import '../json/helper_names.dart'; import '../models/schema_model_graph.dart'; import 'data_class_emitter.dart'; +import '../utils/string_literal.dart'; /// Emits class-first schema codecs and top-level JSON/runtime glue. final class AckClassModelEmitter { @@ -106,10 +107,10 @@ final class AckClassModelEmitter { for (final entry in node.branches.entries) { final branch = nodes[entry.value]!; schemaEntries.add( - '${_literal(entry.key)}: ${ackClassRawObjectName(branch.className)}', + '${dartStringLiteral(entry.key)}: ${ackClassRawObjectName(branch.className)}', ); decodeCases.add( - '${_literal(entry.key)} => ' + '${dartStringLiteral(entry.key)} => ' '${ackClassFromRuntimeName(branch.className)}(value)', ); } @@ -146,14 +147,14 @@ final class AckClassModelEmitter { output ..writeln(''' final $rawName = ${_ack('Ack')}.discriminated( - discriminatorKey: ${_literal(node.discriminatorKey)}, + discriminatorKey: ${dartStringLiteral(node.discriminatorKey)}, schemas: {${schemaEntries.join(', ')}}, ); final $wireName = ${_ack('Ack')}.preserveBoundary($rawName); final ${metadata.backingName} = $rawName.codec<${node.className}>( - decode: (value) => switch (value[${_literal(node.discriminatorKey)}]) { + decode: (value) => switch (value[${dartStringLiteral(node.discriminatorKey)}]) { ${decodeCases.join(',\n ')}, final unknown => throw StateError( 'Unknown ${node.discriminatorKey}: \$unknown', @@ -253,9 +254,9 @@ abstract final class ${metadata.facadeName} { if (node.discriminatorKey case final key?) if (node.discriminatorValue case final value?) if (!node.fields.any((field) => field.jsonKey == key)) - '${_literal(key)}: ${_ack('Ack')}.literal(${_literal(value)}).optional()', + '${dartStringLiteral(key)}: ${_ack('Ack')}.literal(${dartStringLiteral(value)}).optional()', for (final field in node.fields) - '${_literal(field.jsonKey)}: ${field.schemaExpression}', + '${dartStringLiteral(field.jsonKey)}: ${field.schemaExpression}', ]; final additional = node.allowsUnknownProperties ? ', additionalProperties: true' @@ -279,7 +280,7 @@ ${node.className} $function(Map value) { const declared = ${_keySet(declared)}; return $helper({ ...value, - ${_literal(captureJsonKey)}: Map.fromEntries( + ${dartStringLiteral(captureJsonKey)}: Map.fromEntries( value.entries.where((entry) => !declared.contains(entry.key)), ), }); @@ -313,10 +314,10 @@ Map $function(${node.className} model) => ..add('final result = {...$jsonHelper(model)};') ..addAll([ if (captureJsonKey != null) - 'result.remove(${_literal(captureJsonKey)});', + 'result.remove(${dartStringLiteral(captureJsonKey)});', for (final field in requiredNulls) 'if (model.${field.dartName} == null) { ' - 'result[${_literal(field.jsonKey)}] = null; }', + 'result[${dartStringLiteral(field.jsonKey)}] = null; }', ]); final entries = [ @@ -325,7 +326,7 @@ Map $function(${node.className} model) => ' if (!declared.contains(entry.key)) entry.key: entry.value', '...result', if (discriminatorKey != null && discriminatorValue != null) - '${_literal(discriminatorKey)}: ${_literal(discriminatorValue)}', + '${dartStringLiteral(discriminatorKey)}: ${dartStringLiteral(discriminatorValue)}', ]; lines.add('return {${entries.join(',\n ')}};'); return ''' @@ -456,7 +457,7 @@ Map $function(${node.className} model) { }; String _keySet(Set keys) => - '{${keys.map(_literal).join(', ')}}'; + '{${keys.map(dartStringLiteral).join(', ')}}'; AckClassModelMetadata _metadata(AckModelGraph graph, AckModelNode node) { final metadata = graph.classMetadataFor(node.id); @@ -470,12 +471,4 @@ Map $function(${node.className} model) { final prefix = ackPrefix; return prefix == null || prefix.isEmpty ? symbol : '$prefix.$symbol'; } - - String _literal(String value) { - final escaped = value - .replaceAll(r'\', r'\\') - .replaceAll("'", r"\'") - .replaceAll(r'$', r'\$'); - return "'$escaped'"; - } } diff --git a/packages/ack_generator/lib/src/builders/model_emitter.dart b/packages/ack_generator/lib/src/builders/model_emitter.dart index 26e259ce..deb7d2df 100644 --- a/packages/ack_generator/lib/src/builders/model_emitter.dart +++ b/packages/ack_generator/lib/src/builders/model_emitter.dart @@ -3,6 +3,7 @@ import 'package:code_builder/code_builder.dart'; import '../json/helper_names.dart'; import '../models/schema_model_graph.dart'; import 'data_class_emitter.dart'; +import '../utils/string_literal.dart'; /// Emits immutable model declarations solely from a normalized model graph. final class AckModelEmitter { @@ -172,7 +173,7 @@ final class AckModelEmitter { for (final entry in node.branches.entries) { final branch = nodes[entry.value]!; cases.add( - '${_literal(entry.key)} => ${branch.className}._fromAckRuntime(value)', + '${dartStringLiteral(entry.key)} => ${branch.className}._fromAckRuntime(value)', ); } return Class( @@ -223,7 +224,7 @@ final class AckModelEmitter { ), ) ..body = Code(''' -return switch (value[${_literal(node.discriminatorKey)}]) { +return switch (value[${dartStringLiteral(node.discriminatorKey)}]) { ${cases.join(',\n ')}, final unknown => throw StateError( 'Unknown ${node.discriminatorKey}: \$unknown', @@ -255,7 +256,7 @@ return switch (value[${_literal(node.discriminatorKey)}]) { if (node.additionalProperties) _additionalPropertiesField(), _adapter( node, - '${union.id.declarationName}.effectiveBranch(${_literal(value)})', + '${union.id.declarationName}.effectiveBranch(${dartStringLiteral(value)})', ), if (_exposesSchemaShorthand( declarationName: union.id.declarationName, @@ -278,7 +279,7 @@ return switch (value[${_literal(node.discriminatorKey)}]) { ..name = discriminator ..returns = refer('String') ..lambda = true - ..body = Code(_literal(value)), + ..body = Code(dartStringLiteral(value)), ), ..._valueMembers(node.className, _objectDataClass(node)), _objectFromRuntime( @@ -289,7 +290,7 @@ return switch (value[${_literal(node.discriminatorKey)}]) { _objectToRuntime( node, fields: fields, - leadingEntries: {discriminator: _literal(value)}, + leadingEntries: {discriminator: dartStringLiteral(value)}, isOverride: true, ), ..._fieldBridges(fields), @@ -765,7 +766,7 @@ return $helper({ if (!needsBlock) { final entries = [ for (final entry in leadingEntries.entries) - '${_literal(entry.key)}: ${entry.value}', + '${dartStringLiteral(entry.key)}: ${entry.value}', '...$helper(this)', ]; m @@ -782,7 +783,7 @@ return $helper({ for (final field in requiredNulls) { lines.add( 'if (${field.dartName} == null) {' - ' result[${_literal(field.jsonKey)}] = null;' + ' result[${dartStringLiteral(field.jsonKey)}] = null;' ' }', ); } @@ -791,7 +792,7 @@ return $helper({ 'for (final entry in additionalProperties.entries)\n' ' if (!declared.contains(entry.key)) entry.key: entry.value', for (final entry in leadingEntries.entries) - '${_literal(entry.key)}: ${entry.value}', + '${dartStringLiteral(entry.key)}: ${entry.value}', '...result', ]; lines.add( @@ -1022,7 +1023,7 @@ return $helper({ }) => {...additionalKeys, for (final field in fields) field.jsonKey}; String _declaredKeysLiteral(Set keys) => - '{${keys.map(_literal).join(', ')}}'; + '{${keys.map(dartStringLiteral).join(', ')}}'; AckInferRef _nonNullable(AckInferRef type) => switch (type) { AckNullableTypeRef(:final inner) => inner, @@ -1087,14 +1088,6 @@ return $helper({ return prefix == null || prefix.isEmpty ? symbol : '$prefix.$symbol'; } - String _literal(String value) { - final escaped = value - .replaceAll(r'\', r'\\') - .replaceAll("'", r"\'") - .replaceAll(r'$', r'\$'); - return "'$escaped'"; - } - static const _runtimeMapType = 'Map'; static const _runtimeMapLiteral = ''; static const _objectJsonType = 'Map'; diff --git a/packages/ack_generator/lib/src/inference/schema_inference.dart b/packages/ack_generator/lib/src/inference/schema_inference.dart index 9c87fcd2..7eb997f1 100644 --- a/packages/ack_generator/lib/src/inference/schema_inference.dart +++ b/packages/ack_generator/lib/src/inference/schema_inference.dart @@ -7,6 +7,7 @@ import 'package:analyzer/dart/element/type.dart'; import 'package:source_gen/source_gen.dart'; import '../utils/doc_comment_utils.dart'; +import '../utils/string_literal.dart'; /// Infers Ack schema expressions from resolved Dart types and annotations. /// @@ -185,7 +186,7 @@ final class AckSchemaInference { } else if (_pattern.isExactlyType(valueType)) { _require(declaration, type, '@Pattern', isString, 'String field'); output = - '$output.matches(${_literal(value.getField('pattern')!.toStringValue()!)})'; + '$output.matches(${dartStringLiteral(value.getField('pattern')!.toStringValue()!)})'; } else if (_email.isExactlyType(valueType)) { _require(declaration, type, '@Email', isString, 'String field'); output = '$output.email()'; @@ -282,7 +283,7 @@ final class AckSchemaInference { ); return description == null ? schema - : '$schema.describe(${_literal(description)})'; + : '$schema.describe(${dartStringLiteral(description)})'; } String? _scalar(InterfaceType type) { @@ -319,14 +320,6 @@ final class AckSchemaInference { return (number.toIntValue() ?? number.toDoubleValue())!.toString(); } - String _literal(String value) { - final escaped = value - .replaceAll(r'\', r'\\') - .replaceAll("'", r"\'") - .replaceAll(r'$', r'\$'); - return "'$escaped'"; - } - void _require( Element declaration, DartType type, diff --git a/packages/ack_generator/lib/src/utils/string_literal.dart b/packages/ack_generator/lib/src/utils/string_literal.dart new file mode 100644 index 00000000..db5d5827 --- /dev/null +++ b/packages/ack_generator/lib/src/utils/string_literal.dart @@ -0,0 +1,16 @@ +/// Returns [value] as a single-quoted Dart string literal. +/// +/// Escapes backslashes, quotes, and `$`, and writes control characters and +/// Unicode line separators as `\uXXXX`, so generated source stays valid and +/// keeps the original string value. +String dartStringLiteral(String value) { + final escaped = value + .replaceAll(r'\', r'\\') + .replaceAll("'", r"\'") + .replaceAll(r'$', r'\$') + .replaceAllMapped(RegExp(r'[\x00-\x1f

]'), (match) { + final hex = match[0]!.codeUnitAt(0).toRadixString(16).padLeft(4, '0'); + return '\\u$hex'; + }); + return "'$escaped'"; +} diff --git a/packages/ack_generator/test/integration/inference_literal_test.dart b/packages/ack_generator/test/integration/inference_literal_test.dart new file mode 100644 index 00000000..99205a06 --- /dev/null +++ b/packages/ack_generator/test/integration/inference_literal_test.dart @@ -0,0 +1,103 @@ +import 'package:ack_generator/inference.dart'; +import 'package:analyzer/dart/analysis/utilities.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:test/test.dart'; + +final class _PatternProbe implements Builder { + const _PatternProbe(); + + @override + Map> get buildExtensions => const { + '.dart': ['.probe'], + }; + + @override + Future build(BuildStep step) async { + final library = await step.resolver.libraryFor(step.inputId); + final parameter = library.topLevelFunctions + .singleWhere((function) => function.name == 'widget') + .formalParameters + .single; + final expression = const AckSchemaInference().applyConstraints( + 'Ack.string()', + parameter, + parameter.type, + ); + await step.writeAsString( + step.inputId.changeExtension('.probe'), + expression, + ); + } +} + +final class _StringValues extends RecursiveAstVisitor { + final values = []; + + @override + void visitSimpleStringLiteral(SimpleStringLiteral node) { + values.add(node.value); + } +} + +void main() { + final cases = <({String name, String literal, String expected})>[ + ( + name: 'regular expression escapes', + literal: r"r'^\d+\.\d+$'", + expected: r'^\d+\.\d+$', + ), + ( + name: 'line endings', + literal: r"'first\nsecond\r\nthird'", + expected: 'first\nsecond\r\nthird', + ), + ( + name: 'quotes, backslashes, and interpolation markers', + literal: r"""'quote\' slash\\ dollar\$ \${notInterpolation}'""", + expected: "quote' slash\\ dollar\$ \${notInterpolation}", + ), + ( + name: 'control characters and Unicode line separators', + literal: r"'\u0000\t\b\f\u001f\u2028\u2029'", + expected: '\u0000\t\b\f\u001f\u2028\u2029', + ), + ]; + + for (final example in cases) { + test('pattern literal preserves ${example.name}', () async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + await testBuilder( + const _PatternProbe(), + { + 'test_pkg|lib/widget.dart': + ''' +import 'package:ack_annotations/ack_annotations.dart'; + +void widget({@Pattern(${example.literal}) required String value}) {} +''', + }, + generateFor: const {'test_pkg|lib/widget.dart'}, + readerWriter: readerWriter, + outputs: { + 'test_pkg|lib/widget.probe': decodedMatches( + predicate((expression) { + final parsed = parseString( + content: 'final schema = $expression;', + throwIfDiagnostics: false, + ); + expect(parsed.errors, isEmpty); + final strings = _StringValues(); + parsed.unit.accept(strings); + expect(strings.values, [example.expected]); + return true; + }, 'valid Dart with the original annotation value'), + ), + }, + ); + }); + } +} diff --git a/packages/ack_generator/test/src/string_literal_test.dart b/packages/ack_generator/test/src/string_literal_test.dart new file mode 100644 index 00000000..1d0b9246 --- /dev/null +++ b/packages/ack_generator/test/src/string_literal_test.dart @@ -0,0 +1,31 @@ +import 'package:ack_generator/src/utils/string_literal.dart'; +import 'package:analyzer/dart/analysis/utilities.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:test/test.dart'; + +String _parsedValue(String literal) { + final unit = parseString(content: 'const value = $literal;').unit; + final variable = (unit.declarations.single as TopLevelVariableDeclaration) + .variables + .variables + .single; + return (variable.initializer! as SimpleStringLiteral).value; +} + +void main() { + test('literals round-trip through the Dart parser', () { + const values = [ + 'plain', + "quote' slash\\ dollar\$ \${notInterpolation}", + 'first\nsecond\r\nthird', + '\u0000\t\b\f\u001f\u007f

', + ]; + for (final value in values) { + expect(_parsedValue(dartStringLiteral(value)), value); + } + }); + + test('line terminators are escaped, not written raw', () { + expect(dartStringLiteral('a\nb\rc'), r"'a\u000ab\u000dc'"); + }); +}