diff --git a/CHANGELOG.md b/CHANGELOG.md index cac3bd20..c426e262 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline * Generate a static `Model.schema` shorthand for `Model.$ack.modelSchema` on `@AckInfer()` models. +* Add explicit `description` options to `@AckModel` and `@AckField`. + A single-line `@description` doc tag is a fallback. Untagged prose does not + become schema data (#175). +* Escape control characters in generated string literals, including inferred + pattern values (#164). ## 1.6.2 diff --git a/docs/core-concepts/typesafe-schemas.mdx b/docs/core-concepts/typesafe-schemas.mdx index f9cbb956..cf3460c2 100644 --- a/docs/core-concepts/typesafe-schemas.mdx +++ b/docs/core-concepts/typesafe-schemas.mdx @@ -223,6 +223,32 @@ static final Profile Function(Map) fromJson = ProfileSchema.fromJson; ``` +Use `@AckModel(description: ...)` and `@AckField(description: ...)` to export +schema descriptions. A single-line `@description` tag in a `///` or `/** */` +doc comment is a fallback. The tag exports only text on its own line. Untagged +prose does not become schema data. Explicit annotation text takes precedence. +Blank or duplicate tags fail generation. `@description` is an Ack convention, +not a Dart annotation. + +A class description applies to its object schema and model codec. This includes +sealed union bases and branches. A field description applies at its property. +It does not change a nested model's own description. + +```dart +/// A task the person can complete. +@AckModel(description: 'A task the person can complete.') +final class Task with _$TaskAck { + const Task({required this.id, required this.title}); + + final String id; + + /// @description What to do. + final String title; +} + +TaskSchema.toJsonSchema()['description']; // 'A task the person can complete.' +``` + Use `@AckModel(schemaName: 'WireProfileSchema')` to override the exact public facade name. It must be a public UpperCamel identifier. The private backing name remains derived from the model class, and no public lower-camel alias is diff --git a/example/lib/class_first_models.ack.dart b/example/lib/class_first_models.ack.dart index 6387eb76..0a04aea6 100644 --- a/example/lib/class_first_models.ack.dart +++ b/example/lib/class_first_models.ack.dart @@ -14,14 +14,13 @@ final _catObject = Ack.object({ 'type': Ack.literal('cat').optional(), 'id': Ack.string(), 'lives': Ack.integer().min(1).max(9), -}); +}).describe('A cat with a limited number of lives.'); final _catWireSchema = Ack.preserveBoundary(_catObject); -final _catSchema = _catObject.codec( - decode: _$CatFromRuntime, - encode: _$CatToRuntime, -); +final _catSchema = _catObject + .codec(decode: _$CatFromRuntime, encode: _$CatToRuntime) + .describe('A cat with a limited number of lives.'); /// Parses, validates, and encodes [Cat] values. abstract final class CatSchema { @@ -277,21 +276,23 @@ Object? _ackDogToRuntimeBreed(String value) => value; final _petObject = Ack.discriminated( discriminatorKey: 'type', schemas: {'cat': _catObject, 'Dog': _dogObject}, -); +).describe('A pet, identified by its `type`.'); final _petWireSchema = Ack.preserveBoundary(_petObject); -final _petSchema = _petObject.codec( - decode: (value) => switch (value['type']) { - 'cat' => _$CatFromRuntime(value), - 'Dog' => _$DogFromRuntime(value), - final unknown => throw StateError('Unknown type: $unknown'), - }, - encode: (model) => switch (model) { - Cat() => _$CatToRuntime(model), - Dog() => _$DogToRuntime(model), - }, -); +final _petSchema = _petObject + .codec( + decode: (value) => switch (value['type']) { + 'cat' => _$CatFromRuntime(value), + 'Dog' => _$DogFromRuntime(value), + final unknown => throw StateError('Unknown type: $unknown'), + }, + encode: (model) => switch (model) { + Cat() => _$CatToRuntime(model), + Dog() => _$DogToRuntime(model), + }, + ) + .describe('A pet, identified by its `type`.'); /// Parses, validates, and encodes [Pet] values. abstract final class PetSchema { @@ -352,17 +353,18 @@ mixin _$PetAck { } final _accountObject = Ack.object({ - 'display_name': Ack.string().minLength(2), + 'display_name': Ack.string() + .minLength(2) + .describe('The name shown to other users.'), 'website': Ack.uri().optional().nullable(), 'role': Ack.string().withDefault('member'), -}); +}).describe('A signed-in user\'s account.'); final _accountWireSchema = Ack.preserveBoundary(_accountObject); -final _accountSchema = _accountObject.codec( - decode: _$AccountFromRuntime, - encode: _$AccountToRuntime, -); +final _accountSchema = _accountObject + .codec(decode: _$AccountFromRuntime, encode: _$AccountToRuntime) + .describe('A signed-in user\'s account.'); /// Parses, validates, and encodes [Account] values. abstract final class AccountSchema { diff --git a/example/lib/class_first_models.dart b/example/lib/class_first_models.dart index 372636f7..6c07f3e9 100644 --- a/example/lib/class_first_models.dart +++ b/example/lib/class_first_models.dart @@ -4,7 +4,11 @@ import 'package:ack_annotations/ack_annotations.dart'; part 'class_first_models.ack.dart'; part 'class_first_models.ack.g.dart'; -@AckModel(caseStyle: AckCaseStyle.snake) +/// A signed-in user's account. +@AckModel( + caseStyle: AckCaseStyle.snake, + description: "A signed-in user's account.", +) final class Account with _$AccountAck { const Account({ required this.displayName, @@ -12,7 +16,9 @@ final class Account with _$AccountAck { this.role = 'member', }); + /// The name shown to other users. @MinLength(2) + @AckField(description: 'The name shown to other users.') final String displayName; final Uri? website; final String role; @@ -20,6 +26,8 @@ final class Account with _$AccountAck { static final fromJson = AccountSchema.fromJson; } +/// A pet, identified by its `type`. +/// @description A pet, identified by its `type`. @AckModel(discriminatorKey: 'type') sealed class Pet with _$PetAck { const Pet({required this.id}); @@ -27,6 +35,8 @@ sealed class Pet with _$PetAck { final String id; } +/// A cat with a limited number of lives. +/// @description A cat with a limited number of lives. @AckModel(discriminatorValue: 'cat') final class Cat extends Pet with _$CatAck { const Cat({required super.id, required this.lives}); diff --git a/example/test/class_first_models_test.dart b/example/test/class_first_models_test.dart index 08461d6f..32e7dd07 100644 --- a/example/test/class_first_models_test.dart +++ b/example/test/class_first_models_test.dart @@ -15,6 +15,24 @@ void main() { expect(AccountSchema.wireSchema, isNotNull); }); + test('explicit descriptions and tags become schema descriptions', () { + final jsonSchema = AccountSchema.toJsonSchema(); + final properties = jsonSchema['properties'] as Map; + + expect(jsonSchema['description'], "A signed-in user's account."); + expect(AccountSchema.schema.description, "A signed-in user's account."); + expect( + (properties['display_name'] as Map)['description'], + 'The name shown to other users.', + ); + expect(PetSchema.schema.description, 'A pet, identified by its `type`.'); + expect( + CatSchema.schema.description, + 'A cat with a limited number of lives.', + ); + expect(DogSchema.schema.description, isNull); + }); + test('class-first sealed models dispatch through a discriminator', () { final pet = PetSchema.parse({'type': 'cat', 'id': 'c1', 'lives': 9}); diff --git a/llms.txt b/llms.txt index 9a47cd04..ae2e3da4 100644 --- a/llms.txt +++ b/llms.txt @@ -117,6 +117,10 @@ classes use `@AckModel(discriminatorKey: ...)`; same-library concrete branches are included automatically. Use `@Optional()` or `@Required()` to override inferred key presence, `@NotNull()` to reject an explicit JSON `null` without requiring the key, and `@AckField(schema: ...)` for custom field codecs. +Use `@AckModel(description: ...)` and `@AckField(description: ...)` to export +schema descriptions. A single-line `@description` doc tag is a fallback. +Untagged prose does not export. A field description applies at its property +without changing a nested model's own description. Deprecated `AckFieldPresence` remains available during migration. Unknown properties use `unknownProperties: AckUnknownPropertyPolicy.` (`reject` by default). Raw `Ack.object(..., additionalProperties: true)` validation preserves extras; diff --git a/packages/ack_annotations/CHANGELOG.md b/packages/ack_annotations/CHANGELOG.md index 890424c4..0fce69a7 100644 --- a/packages/ack_annotations/CHANGELOG.md +++ b/packages/ack_annotations/CHANGELOG.md @@ -2,6 +2,8 @@ ### Added +* `@AckModel(description: ...)` and `@AckField(description: ...)` set schema + descriptions. Explicit values take precedence over `@description` doc tags. * Constraint and field annotations can target function parameters. * `@Url()`, `@Uuid()`, and `@Date()` are string format annotations. * `format_annotations.dart` exports `@Uri()` and `@DateTime()` for prefixed diff --git a/packages/ack_annotations/README.md b/packages/ack_annotations/README.md index a0036b58..a0cc1696 100644 --- a/packages/ack_annotations/README.md +++ b/packages/ack_annotations/README.md @@ -64,7 +64,11 @@ final class Account with _$AccountAck { This generates the public `AccountSchema` facade plus validated `toJson`, `safeToJson`, `copyWith`, equality, and `toString` implementations. -Field documentation becomes the field schema's description. Format +Use `@AckModel(description: ...)` and `@AckField(description: ...)` for schema +descriptions. A single-line `@description` documentation tag is a fallback. +Untagged prose does not become schema data. Explicit annotation text takes +precedence over the tag. A field description applies to its property without +changing a nested model's description. Format annotations such as `@Url()`, `@Uuid()`, and `@Date()` constrain string fields. Import `package:ack_annotations/format_annotations.dart` with a prefix for `@formats.Uri()` and `@formats.DateTime()`. The prefix preserves diff --git a/packages/ack_annotations/lib/src/ack_field.dart b/packages/ack_annotations/lib/src/ack_field.dart index 98f5edc0..54d62feb 100644 --- a/packages/ack_annotations/lib/src/ack_field.dart +++ b/packages/ack_annotations/lib/src/ack_field.dart @@ -51,7 +51,9 @@ final class NotNull { /// /// [schema] must be a const tear-off of a top-level function returning an Ack /// schema. The generator validates the declaration and follows its expression. -/// A no-op `@AckField()` is rejected. +/// [description] describes this property, not a nested model's schema. +/// It takes precedence over an `@description` doc tag. A no-op `@AckField()` +/// is rejected. /// /// Prefer `@Optional()` and `@Required()` for key presence. [presence] is /// deprecated and will be removed in 2.0.0. @@ -60,6 +62,7 @@ final class AckField { /// Creates a field annotation. const AckField({ this.schema, + this.description, @Deprecated( 'Use @Optional() or @Required(). AckField.presence will be removed in ' '2.0.0.', @@ -70,6 +73,9 @@ final class AckField { /// Top-level schema-function tear-off followed by `ack_generator`. final Object Function()? schema; + /// Schema description for this property. + final String? description; + /// Presence override applied after constructor inference. @Deprecated( 'Use @Optional() or @Required(). AckField.presence will be removed in 2.0.0.', diff --git a/packages/ack_annotations/lib/src/ack_model.dart b/packages/ack_annotations/lib/src/ack_model.dart index f04a6df5..dbb78bcf 100644 --- a/packages/ack_annotations/lib/src/ack_model.dart +++ b/packages/ack_annotations/lib/src/ack_model.dart @@ -31,6 +31,7 @@ final class AckModel { /// Creates a class-first Ack model annotation. const AckModel({ this.schemaName, + this.description, this.caseStyle = AckCaseStyle.none, this.discriminatorKey, this.discriminatorValue, @@ -68,6 +69,10 @@ final class AckModel { /// `Schema`, preserving the model class's written capitalization. final String? schemaName; + /// Schema description. This takes precedence over an `@description` doc tag. + /// Untagged documentation does not become a schema description. + final String? description; + /// Naming convention applied to JSON field keys. final AckCaseStyle caseStyle; diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index c5c921ef..acdf79c2 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -26,14 +26,21 @@ lint rules and coverage reports cover only hand-written source. * Generated constructors, parsing and JSON members, `$ack`, and class-first facades now have doc comments. +* Schema descriptions come only from `description:` or an `@description` doc + tag. This replaces the unreleased development behavior that exported every + field doc comment. Untagged prose and `//` comments never become schema + data. Shared `AckSchemaInference.applyDescription` uses the same rule. ### Added * `@AckInfer()` models declare `static final schema` as a shorthand for `$ack.modelSchema`. The generator omits this member when `schema` conflicts with a field, discriminator, or annotated declaration. -* Class-first generation uses field documentation as schema descriptions and - supports the new string format annotations. +* Class-first generation supports explicit `@AckModel` and `@AckField` + descriptions, plus single-line `@description` doc tags. Explicit annotation + values take precedence. Class descriptions apply to the wire object and + model codec; field descriptions apply only to that property. +* Class-first generation supports the new string format annotations. * `inference.dart` exports shared type and constraint inference for other generators. The class-first generator uses this library. diff --git a/packages/ack_generator/README.md b/packages/ack_generator/README.md index be91d445..9f711f74 100644 --- a/packages/ack_generator/README.md +++ b/packages/ack_generator/README.md @@ -90,13 +90,19 @@ void main() { } ``` -Field documentation becomes an Ack schema description. The generator shares -its type and constraint inference through +Use `@AckModel(description: ...)` and `@AckField(description: ...)` for schema +descriptions. A single-line `@description` tag in a `///` or `/** */` doc +comment is a fallback. The tag exports only text on its own line. Untagged +prose never becomes schema data. Duplicate or blank tags fail generation. +Annotation text takes precedence over a tag. A class description applies to +both the object schema and model codec, including union bases and branches. +A field description applies at its property without changing a nested model. +The generator shares its type and constraint inference through `package:ack_generator/inference.dart`. A second generator can use `AckSchemaInference.inferType`, `applyConstraints`, and `applyDescription`. The caller resolves application model types and import prefixes. For function parameters, read the documentation comment from the source AST and pass it as -`sourceComment` to `applyDescription`. +`sourceComment` to `applyDescription`. That method uses the same tag-only rule. ## Schema support 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 68a48b61..2b24254b 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 @@ -13,11 +13,13 @@ import 'package:source_gen/source_gen.dart'; import '../json/helper_names.dart'; import '../models/schema_model_graph.dart'; import '../inference/schema_inference.dart'; +import '../utils/doc_comment_utils.dart'; import 'generated_companion_visibility.dart'; import '../utils/string_literal.dart'; typedef _ModelOptions = ({ String? schemaName, + String? description, String caseStyle, String? discriminatorKey, String? discriminatorValue, @@ -395,6 +397,7 @@ final class ClassModelGraphBuilder { _options(branch) ?? ( schemaName: null, + description: null, caseStyle: baseOptions.caseStyle, discriminatorKey: null, discriminatorValue: null, @@ -437,6 +440,7 @@ final class ClassModelGraphBuilder { runtimeRef: AckExternalTypeRef(name: base.name!), discriminatorKey: discriminatorKey, branches: branches, + description: _classDescription(base, baseOptions), ), ); } @@ -662,6 +666,7 @@ final class ClassModelGraphBuilder { unionId: unionId, discriminatorKey: discriminatorKey, discriminatorValue: discriminatorValue, + description: _classDescription(element, options), ); _graph.complete(node); return node; @@ -1435,10 +1440,12 @@ final class ClassModelGraphBuilder { .objectValue .getField('index')! .toIntValue()!; - if (schemaMissing && presenceIndex == 0) { + if (schemaMissing && + reader.read('description').isNull && + presenceIndex == 0) { throw InvalidGenerationSource( '${field.enclosingElement.name}.${field.name} @AckField() is a ' - 'no-op; set schema or presence.', + 'no-op; set schema, description, or presence.', element: field, ); } @@ -1741,6 +1748,7 @@ final class ClassModelGraphBuilder { const styles = ['none', 'snake', 'kebab', 'pascal', 'screamingSnake']; return ( schemaName: _nullableString(reader, 'schemaName'), + description: _nullableString(reader, 'description'), caseStyle: styles[caseIndex], discriminatorKey: _nullableString(reader, 'discriminatorKey'), discriminatorValue: _nullableString(reader, 'discriminatorValue'), @@ -1754,6 +1762,29 @@ final class ClassModelGraphBuilder { ); } + String? _classDescription(ClassElement element, _ModelOptions options) { + final tag = _descriptionTag(element); + final explicit = options.description; + if (explicit != null && explicit.trim().isEmpty) { + throw InvalidGenerationSource( + '${element.name} @AckModel.description must not be blank.', + element: element, + ); + } + return explicit ?? tag; + } + + String? _descriptionTag(Element element) { + try { + return parseDescriptionTag(element.documentationComment); + } on FormatException catch (error) { + throw InvalidGenerationSource( + '${element.name} ${error.message}', + element: element, + ); + } + } + String? _nullableString(ConstantReader reader, String name) { final value = reader.read(name); return value.isNull ? null : value.stringValue; 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 bbebdbd0..f863af32 100644 --- a/packages/ack_generator/lib/src/builders/class_model_emitter.dart +++ b/packages/ack_generator/lib/src/builders/class_model_emitter.dart @@ -149,7 +149,7 @@ final class AckClassModelEmitter { final $rawName = ${_ack('Ack')}.discriminated( discriminatorKey: ${dartStringLiteral(node.discriminatorKey)}, schemas: {${schemaEntries.join(', ')}}, -); +)${_describe(node.description)}; final $wireName = ${_ack('Ack')}.preserveBoundary($rawName); @@ -163,7 +163,7 @@ final ${metadata.backingName} = $rawName.codec<${node.className}>( encode: (model) => switch (model) { ${encodeCases.join(',\n ')}, }, -);''') +)${_describe(node.description)};''') ..writeln() ..writeln(_facade(node.className, metadata, wireName: wireName)) ..writeln() @@ -189,9 +189,16 @@ final ${metadata.backingName} = $rawName.codec<${node.className}>( final $backingName = $expression.codec<${node.className}>( decode: ${ackClassFromRuntimeName(node.className)}, encode: ${ackClassToRuntimeName(node.className)}, -);'''; +)${_describe(node.description)};'''; } + /// Explicit class descriptions describe the wire object and model codec. + /// + /// A codec does not inherit its input schema's description, so describing + /// only the object would leave `XSchema.schema.description` unset. + String _describe(String? description) => + description == null ? '' : '.describe(${dartStringLiteral(description)})'; + String _facade( String className, AckClassModelMetadata metadata, { @@ -261,7 +268,8 @@ abstract final class ${metadata.facadeName} { final additional = node.allowsUnknownProperties ? ', additionalProperties: true' : ''; - return '${_ack('Ack')}.object({${entries.join(', ')}}$additional)'; + return '${_ack('Ack')}.object({${entries.join(', ')}}$additional)' + '${_describe(node.description)}'; } String _fromRuntimeFunction(AckObjectModelNode node) { diff --git a/packages/ack_generator/lib/src/inference/schema_inference.dart b/packages/ack_generator/lib/src/inference/schema_inference.dart index 7eb997f1..a44727ec 100644 --- a/packages/ack_generator/lib/src/inference/schema_inference.dart +++ b/packages/ack_generator/lib/src/inference/schema_inference.dart @@ -90,6 +90,10 @@ final class AckSchemaInference { annotations.UniqueItems, inPackage: 'ack_annotations', ); + static const _ackField = TypeChecker.typeNamed( + annotations.AckField, + inPackage: 'ack_annotations', + ); /// Infers a schema expression for [type]. /// @@ -269,7 +273,7 @@ final class AckSchemaInference { return output; } - /// Adds documentation as an Ack description. + /// Adds an explicit Ack description or a tagged documentation description. /// /// Analyzer does not attach parameter doc comments to parameter elements. /// A function generator passes the comment from its source AST. @@ -278,9 +282,29 @@ final class AckSchemaInference { Element declaration, { String? sourceComment, }) { - final description = parseDocComment( - sourceComment ?? declaration.documentationComment, - ); + final String? tag; + try { + tag = parseDescriptionTag( + sourceComment ?? declaration.documentationComment, + ); + } on FormatException catch (error) { + throw InvalidGenerationSource( + '${declaration.name} ${error.message}', + element: declaration, + ); + } + final annotation = _ackField.firstAnnotationOfExact(declaration); + final value = annotation == null + ? null + : ConstantReader(annotation).read('description'); + final explicit = value == null || value.isNull ? null : value.stringValue; + if (explicit != null && explicit.trim().isEmpty) { + throw InvalidGenerationSource( + '${declaration.name} @AckField.description must not be blank.', + element: declaration, + ); + } + final description = explicit ?? tag; return description == null ? schema : '$schema.describe(${dartStringLiteral(description)})'; diff --git a/packages/ack_generator/lib/src/utils/doc_comment_utils.dart b/packages/ack_generator/lib/src/utils/doc_comment_utils.dart index 0392f9e4..427ae07c 100644 --- a/packages/ack_generator/lib/src/utils/doc_comment_utils.dart +++ b/packages/ack_generator/lib/src/utils/doc_comment_utils.dart @@ -1,49 +1,70 @@ -/// Utilities for parsing Dart documentation comments. +/// Utilities for reading Ack tags from resolved Dart documentation comments. library; -/// Parses a Dart documentation comment into a clean description string. +/// Reads the text on one `@description` line in a Dart documentation comment. /// -/// Supports `///` and `/** ... */` doc comments, including multi-line variants, -/// and returns cleaned text as a single string. -/// -/// Returns `null` if the comment is empty or cannot be parsed. -/// -/// Example: -/// ```dart -/// final description = parseDocComment('/// User name field'); -/// // Returns: 'User name field' -/// ``` -String? parseDocComment(String? docComment) { - if (docComment == null || docComment.isEmpty) { - return null; - } +/// Ordinary prose and tags inside fenced code blocks do not become schema +/// descriptions. The tag must start a `///` or `/** */` documentation line. +/// A blank or duplicate tag is ambiguous and causes a [FormatException]. +String? parseDescriptionTag(String? docComment) { + if (docComment == null || docComment.isEmpty) return null; final normalized = docComment.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); + final rawLines = normalized.split('\n'); + final isLineComment = normalized.trimLeft().startsWith('///'); + final isBlockComment = normalized.trimLeft().startsWith('/**'); + if (!isLineComment && !isBlockComment) return null; - // Handle /// style comments (check startsWith to avoid false matches) - if (normalized.startsWith('///')) { - final lines = normalized - .split('\n') - .map((line) => line.replaceFirst(RegExp(r'^\s*///\s?'), '')) - .where((line) => line.isNotEmpty) - .toList(); - - if (lines.isEmpty) return null; - return lines.join(' ').trim(); + String cleanLine(String line, int index) { + var value = line.trimLeft(); + if (isLineComment) { + if (!value.startsWith('///')) return ''; + value = value.substring(3); + } else { + if (index == 0) { + value = value.substring(3); + } else if (value.startsWith('*')) { + value = value.substring(1); + } + if (index == rawLines.length - 1 && value.endsWith('*/')) { + value = value.substring(0, value.length - 2); + } + } + return value.trimLeft(); } - // Handle /** */ style comments - if (normalized.startsWith('/**')) { - final content = normalized - .replaceFirst(RegExp(r'^/\*\*\s*'), '') - .replaceFirst(RegExp(r'\s*\*/$'), '') - .split('\n') - .map((line) => line.replaceFirst(RegExp(r'^\s*\*\s?'), '')) - .where((line) => line.isNotEmpty) - .join(' ') - .trim(); - - return content.isEmpty ? null : content; + String? description; + String? fence; + for (var index = 0; index < rawLines.length; index++) { + final line = cleanLine(rawLines[index], index); + final fenceMatch = RegExp(r'^(`{3,}|~{3,})').firstMatch(line); + if (fenceMatch != null) { + final marker = fenceMatch.group(1)!; + if (fence == null) { + fence = marker; + continue; + } + // A closing fence is a bare marker; a line with an info string such + // as ```dart stays inside the open fence. + final closes = + marker[0] == fence[0] && + marker.length >= fence.length && + line.substring(marker.length).trim().isEmpty; + if (closes) fence = null; + continue; + } + if (fence != null || !RegExp(r'^@description(?:\s|$)').hasMatch(line)) { + continue; + } + final text = line.substring('@description'.length).trim(); + if (text.isEmpty) { + throw const FormatException( + '@description requires text on the same documentation line.', + ); + } + if (description != null) { + throw const FormatException('Duplicate @description tags.'); + } + description = text; } - - return null; + return description; } diff --git a/packages/ack_generator/test/integration/class_first_runtime_build_test.dart b/packages/ack_generator/test/integration/class_first_runtime_build_test.dart index f2957fb7..865e4443 100644 --- a/packages/ack_generator/test/integration/class_first_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/class_first_runtime_build_test.dart @@ -507,6 +507,68 @@ final class ImplementingChild with _$ImplementingChildAck implements Parent { final String? note; } +/// A task the person can complete. +/// @description This tag has lower priority. +@AckModel(description: 'A task the person can complete.') +final class Task with _$TaskAck { + const Task({required this.id, required this.title}); + + final String id; + + /// What to do. + /// @description What to do. + final String title; +} + +/** + * A board that groups + * related tasks. + * @description A board that groups related tasks. + */ +@AckModel() +final class Board with _$BoardAck { + const Board({required this.focus, required this.backlog, this.pinned}); + + /// The task to do next. + /// @description This tag has lower priority. + @AckField(description: 'The task to do next.') + final Task focus; + final List backlog; + final Task? pinned; +} + +@AckModel() +final class Undocumented with _$UndocumentedAck { + const Undocumented({required this.value}); + + /// Ordinary field prose does not become schema data. + final String value; +} + +/// A shape to draw. +/// @description A shape to draw. +@AckModel(discriminatorKey: 'kind') +sealed class Shape with _$ShapeAck { + const Shape(); +} + +/// A circle, sized by its radius. +@AckModel( + discriminatorValue: 'circle', + description: 'A circle, sized by its radius.', +) +final class Circle extends Shape with _$CircleAck { + const Circle({required this.radius}); + + final double radius; +} + +final class Square extends Shape with _$SquareAck { + const Square({required this.side}); + + final double side; +} + @AckInfer() final legacySchema = Ack.object({'enabled': Ack.boolean()}); @@ -523,6 +585,8 @@ final class PlainJson { File( p.join(temporary.path, 'test', 'runtime_test.dart'), ).writeAsStringSync(r''' +import 'dart:convert'; + import 'package:ack_class_first_runtime/alpha.dart' as alpha; import 'package:ack_class_first_runtime/beta.dart' as beta; import 'package:ack_class_first_runtime/coexist.dart'; @@ -1021,6 +1085,62 @@ void main() { expect(PetSchema.safeParse({'id': 'c2', 'lives': 7}).isFail, isTrue); }); + test('explicit descriptions and tags describe class-first object schemas', () { + const task = 'A task the person can complete.'; + expect(TaskSchema.toJsonSchema()['description'], task); + expect(TaskSchema.schema.description, task); + expect(TaskSchema.wireSchema.description, task); + expect(TaskSchema.toSchemaModel().toJsonSchema()['description'], task); + final taskProperties = + TaskSchema.toJsonSchema()['properties'] as Map; + expect( + (taskProperties['title'] as Map)['description'], + 'What to do.', + ); + expect( + (taskProperties['id'] as Map)['description'], + isNull, + ); + + expect( + BoardSchema.schema.description, + 'A board that groups related tasks.', + ); + final board = BoardSchema.toJsonSchema(); + final boardProperties = board['properties'] as Map; + expect( + (boardProperties['focus'] as Map)['description'], + 'The task to do next.', + ); + expect( + ((boardProperties['backlog'] as Map)['items'] + as Map)['description'], + task, + ); + expect(jsonEncode(boardProperties['pinned']), contains(task)); + + expect(UndocumentedSchema.toJsonSchema().containsKey('description'), isFalse); + expect(UndocumentedSchema.schema.description, isNull); + expect(UndocumentedSchema.wireSchema.description, isNull); + }); + + test('union base and branch descriptions describe their schemas', () { + expect(ShapeSchema.schema.description, 'A shape to draw.'); + expect(ShapeSchema.toJsonSchema()['description'], 'A shape to draw.'); + expect(CircleSchema.schema.description, 'A circle, sized by its radius.'); + expect(SquareSchema.schema.description, isNull); + final branches = (ShapeSchema.toJsonSchema()['anyOf'] as List) + .cast>(); + expect(branches.map((branch) => branch['description']), [ + 'A circle, sized by its radius.', + null, + ]); + expect( + ShapeSchema.parse({'kind': 'circle', 'radius': 2}), + const Circle(radius: 2), + ); + }); + test('prefixed same-named imported types preserve identity', () { final pair = ImportedPairSchema.parse({'left': 'a', 'right': 2}); expect(pair.left, isA()); diff --git a/packages/ack_generator/test/integration/class_model_emitter_test.dart b/packages/ack_generator/test/integration/class_model_emitter_test.dart index 02508b77..8ad4772a 100644 --- a/packages/ack_generator/test/integration/class_model_emitter_test.dart +++ b/packages/ack_generator/test/integration/class_model_emitter_test.dart @@ -21,13 +21,26 @@ Future _build( ); } +/// Matches generated [code] regardless of formatter line breaks. +/// +/// Whitespace and trailing commas are ignored on both sides. +Matcher _containsCode(String code) => predicate( + (output) => _compact(output).contains(_compact(code)), + 'contains code `$code`', +); + +String _compact(String code) => code + .replaceAll(RegExp(r'\s+'), '') + .replaceAll(',)', ')') + .replaceAll(',}', '}'); + const _imports = ''' import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; '''; void main() { - test('class-first field docs describe the inferred schema', () async { + test('a field description tag describes the inferred schema', () async { await _build( { 'documented.dart': @@ -41,6 +54,7 @@ final class Documented with _\$DocumentedAck { const Documented({required this.title}); /// The item title. + /// @description The item title. final String title; } ''', @@ -53,6 +67,182 @@ final class Documented with _\$DocumentedAck { ); }); + test('description tags describe the object and its codec', () async { + const task = r"'A task the person\'s \$team can complete.'"; + await _build( + { + 'documented.dart': + ''' +$_imports +part 'documented.ack.dart'; +part 'documented.ack.g.dart'; + +/// A task the person's \$team can complete. +/// @description A task the person's \$team can complete. +/// +/// Shown in the task list. +@AckModel() +final class Task with _\$TaskAck { + const Task({required this.title}); + + /// What to do. + /// @description What to do. + final String title; +} + +/** + * A note + * without fields. + * @description A note without fields. + */ +@AckModel() +final class Note with _\$NoteAck { + const Note(); +} +''', + }, + outputs: { + 'test_pkg|lib/documented.ack.dart': decodedMatches( + allOf( + _containsCode( + 'final _taskObject = Ack.object({' + "'title': Ack.string().describe('What to do.')," + '}).describe($task);', + ), + _containsCode( + 'final _taskSchema = _taskObject.codec(' + r'decode: _$TaskFromRuntime, encode: _$TaskToRuntime,' + ').describe($task);', + ), + _containsCode( + "final _noteObject = Ack.object({}).describe('A note without " + "fields.');", + ), + _containsCode( + r'encode: _$NoteToRuntime,' + ").describe('A note without fields.');", + ), + ), + ), + }, + ); + }); + + test('untagged prose and line comments emit no description', () async { + await _build( + { + 'plain.dart': + ''' +$_imports +part 'plain.ack.dart'; +part 'plain.ack.g.dart'; + +/// Ordinary class prose is not schema data. +@AckModel() +final class Plain with _\$PlainAck { + const Plain({required this.title, this.note}); + + /// Ordinary field prose is not schema data. + final String title; + + // @description Line comments are not documentation. + final String? note; +} +''', + }, + outputs: { + 'test_pkg|lib/plain.ack.dart': decodedMatches( + isNot(contains('.describe(')), + ), + }, + ); + }); + + test('explicit descriptions escape control characters', () async { + await _build( + { + 'escaped.dart': + ''' +$_imports +part 'escaped.ack.dart'; +part 'escaped.ack.g.dart'; + +@AckModel(description: 'First\\nsecond') +final class Escaped with _\$EscapedAck { + const Escaped({required this.value}); + + @AckField(description: 'A\\tB') + final String value; +} +''', + }, + outputs: { + 'test_pkg|lib/escaped.ack.dart': decodedMatches( + allOf([ + contains(r".describe('First\u000asecond')"), + contains(r".describe('A\u0009B')"), + ]), + ), + }, + ); + }); + + test('union bases and branches carry their own description tags', () async { + await _build( + { + 'shapes.dart': + ''' +$_imports +part 'shapes.ack.dart'; +part 'shapes.ack.g.dart'; + +/// A shape to draw. +/// @description A shape to draw. +@AckModel(discriminatorKey: 'kind') +sealed class Shape with _\$ShapeAck { + const Shape(); +} + +/// A circle. +/// @description A circle. +@AckModel(discriminatorValue: 'circle') +final class Circle extends Shape with _\$CircleAck { + const Circle({required this.radius}); + + final double radius; +} + +final class Square extends Shape with _\$SquareAck { + const Square({required this.side}); + + final double side; +} +''', + }, + outputs: { + 'test_pkg|lib/shapes.ack.dart': decodedMatches( + allOf( + _containsCode("'radius': Ack.double(),}).describe('A circle.');"), + _containsCode( + r'encode: _$CircleToRuntime,' + ").describe('A circle.');", + ), + _containsCode("'side': Ack.double(),});"), + _containsCode(r'encode: _$SquareToRuntime,);'), + _containsCode( + "schemas: {'circle': _circleObject, 'Square': _squareObject}," + ").describe('A shape to draw.');", + ), + _containsCode( + r'Square() => _$SquareToRuntime(model),},' + ").describe('A shape to draw.');", + ), + ), + ), + }, + ); + }); + test('class-first format annotations constrain string fields', () async { await _build( { @@ -302,7 +492,7 @@ final class Color { AckSchema colorSchema() => Ack.string().codec( decode: Color.new, encode: (color) => color.value, -); +).describe('Authored color.'); AckSchema, Map> scoresSchema() => Ack.object({}, additionalProperties: true).codec>( @@ -324,6 +514,7 @@ final class Record with _\$RecordAck { required this.names, }); + /// Untagged field prose does not replace the authored description. @AckField(schema: colorSchema) final Color color; @AckField(schema: scoresSchema) @@ -340,6 +531,7 @@ final class Record with _\$RecordAck { 'test_pkg|lib/types.ack.dart': decodedMatches( allOf([ contains("'color': colorSchema()"), + isNot(contains("'color': colorSchema().describe(")), contains("'scores': scoresSchema()"), contains("'role': Ack.enumValues(Role.values)"), contains("'createdAt': Ack.datetime()"), diff --git a/packages/ack_generator/test/integration/class_model_graph_test.dart b/packages/ack_generator/test/integration/class_model_graph_test.dart index 44affe4f..65017476 100644 --- a/packages/ack_generator/test/integration/class_model_graph_test.dart +++ b/packages/ack_generator/test/integration/class_model_graph_test.dart @@ -83,6 +83,59 @@ final class _\$UserCopyWith {} }, ); + test('rejects blank annotation descriptions', () async { + await _expectFailure( + ''' +@AckModel(description: ' ') +final class Item with _\$ItemAck { + const Item({required this.value}); + final String value; +} +''', + ['Item @AckModel.description must not be blank.'], + ); + await _expectFailure( + ''' +@AckModel() +final class Item with _\$ItemAck { + const Item({required this.value}); + @AckField(description: '') + final String value; +} +''', + ['value @AckField.description must not be blank.'], + ); + }); + + test('rejects duplicate class description tags at the class', () async { + await _expectFailure( + ''' +/// @description First. +/// @description Second. +@AckModel() +final class Item with _\$ItemAck { + const Item({required this.value}); + final String value; +} +''', + ['Item', 'Duplicate @description tags.'], + ); + }); + + test('rejects a blank field description tag at the field', () async { + await _expectFailure( + ''' +@AckModel() +final class Item with _\$ItemAck { + const Item({required this.value}); + /// @description + final String value; +} +''', + ['value', '@description requires text'], + ); + }); + test('rejects numeric sugar on a String field', () async { await _expectFailure( ''' diff --git a/packages/ack_generator/test/integration/inference_library_test.dart b/packages/ack_generator/test/integration/inference_library_test.dart index 9c9d344a..e278e4b1 100644 --- a/packages/ack_generator/test/integration/inference_library_test.dart +++ b/packages/ack_generator/test/integration/inference_library_test.dart @@ -52,7 +52,8 @@ void main() { await readerWriter.testing.loadIsolateSources(); await testBuilder( const _InferenceProbe( - sourceComment: '/// The item title.\r\n/// It appears on screen.', + sourceComment: + '/// The item title.\r\n/// @description It appears on screen.\r\n/// More prose.', ), { 'test_pkg|lib/widget.dart': ''' @@ -69,7 +70,34 @@ void widget({ readerWriter: readerWriter, outputs: { 'test_pkg|lib/widget.probe': decodedMatches( - "Ack.string().minLength(2).describe('The item title. It appears on screen.')", + "Ack.string().minLength(2).describe('It appears on screen.')", + ), + }, + ); + }); + + test('an explicit parameter description overrides its source tag', () async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + await testBuilder( + const _InferenceProbe( + sourceComment: '/// @description Tag text.\n/// Following prose.', + ), + { + 'test_pkg|lib/widget.dart': ''' +import 'package:ack_annotations/ack_annotations.dart'; + +void widget({ + @AckField(description: 'Explicit text.') + required String title, +}) {} +''', + }, + generateFor: const {'test_pkg|lib/widget.dart'}, + readerWriter: readerWriter, + outputs: { + 'test_pkg|lib/widget.probe': decodedMatches( + "Ack.string().describe('Explicit text.')", ), }, ); diff --git a/packages/ack_generator/test/src/doc_comment_utils_test.dart b/packages/ack_generator/test/src/doc_comment_utils_test.dart new file mode 100644 index 00000000..e29d679a --- /dev/null +++ b/packages/ack_generator/test/src/doc_comment_utils_test.dart @@ -0,0 +1,59 @@ +import 'package:ack_generator/src/utils/doc_comment_utils.dart'; +import 'package:test/test.dart'; + +void main() { + test('only a description tag becomes schema text', () { + expect(parseDescriptionTag('/// Ordinary prose.'), isNull); + expect( + parseDescriptionTag( + '/// Ordinary prose.\r\n' + '/// @description The schema text.\r\n' + '/// More ordinary prose.', + ), + 'The schema text.', + ); + expect( + parseDescriptionTag('/**\r\n * @description Block text.\r\n */'), + 'Block text.', + ); + }); + + test('tags in fenced examples and inline prose do not export', () { + expect( + parseDescriptionTag( + '/// An example with @description in prose.\n' + '/// ```dart\n' + '/// @description Example text.\n' + '/// ```\n' + '/// @description Exported text.', + ), + 'Exported text.', + ); + expect( + parseDescriptionTag('/**\n * ~~~\n * @description Example.\n * ~~~\n */'), + isNull, + ); + expect( + parseDescriptionTag( + '/// ```\n' + '/// ```dart\n' + '/// @description Still inside the fence.\n' + '/// ```', + ), + isNull, + ); + }); + + test('blank and duplicate tags are invalid', () { + expect( + () => parseDescriptionTag('/// @description'), + throwsA(isA()), + ); + expect( + () => parseDescriptionTag( + '/// @description First.\n/// @description Second.', + ), + throwsA(isA()), + ); + }); +}