From 5e7e9127f0927729aef718377f8c177ca64cc991 Mon Sep 17 00:00:00 2001 From: Sam Rawlins Date: Fri, 24 Jul 2020 07:19:24 -0700 Subject: [PATCH 1/3] Process directives after macro-processing; fixes #1945 --- lib/src/model/comment_processable.dart | 576 ++++++++++++++++++++++ lib/src/model/model_element.dart | 564 +-------------------- lib/src/model/package_graph.dart | 6 +- test/model_special_cases_test.dart | 12 + testing/test_package/bin/print_macro.dart | 10 + testing/test_package/dartdoc_options.yaml | 3 + testing/test_package/lib/example.dart | 28 +- 7 files changed, 642 insertions(+), 557 deletions(-) create mode 100644 lib/src/model/comment_processable.dart create mode 100644 testing/test_package/bin/print_macro.dart diff --git a/lib/src/model/comment_processable.dart b/lib/src/model/comment_processable.dart new file mode 100644 index 0000000000..faa656f859 --- /dev/null +++ b/lib/src/model/comment_processable.dart @@ -0,0 +1,576 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:crypto/crypto.dart'; +import 'package:dartdoc/src/logging.dart'; +import 'package:dartdoc/src/model/model.dart'; +import 'package:dartdoc/src/render/model_element_renderer.dart'; +import 'package:dartdoc/src/utils.dart'; +import 'package:dartdoc/src/warnings.dart'; + +import 'package:path/path.dart' as path; + +final _templatePattern = RegExp( + r'[ ]*{@template\s+(.+?)}([\s\S]+?){@endtemplate}[ ]*\n?', + multiLine: true); +final _htmlPattern = RegExp( + r'[ ]*{@inject-html\s*}([\s\S]+?){@end-inject-html}[ ]*\n?', + multiLine: true); + +/// Matches all tool directives (even some invalid ones). This is so +/// we can give good error messages if the directive is malformed, instead of +/// just silently emitting it as-is. +final _basicToolPattern = RegExp( + r'[ ]*{@tool\s+([^}]+)}\n?([\s\S]+?)\n?{@end-tool}[ ]*\n?', + multiLine: true); + +final _examplePattern = RegExp(r'{@example\s+([^}]+)}'); + +/// Features for processing directives in a documentation comment. +/// +/// [processCommentWithoutTools] and [processComment] are the primary +/// entrypoints. +mixin CommentProcessable on Documentable, Warnable, Locatable, SourceCodeMixin { + /// Process [documentationComment], performing various actions based on + /// `{@}`-style directives, except `{@tool}`, returning the processed result. + String processCommentWithoutTools(String documentationComment) { + var docs = stripComments(documentationComment); + docs = processCommentDirectives(docs); + return docs; + } + + /// Process [documentationComment], performing various actions based on + /// `{@}`-style directives, returning the processed result. + Future processComment(String documentationComment) async { + var docs = stripComments(documentationComment); + // Must evaluate tools first, in case they insert any other directives. + docs = await _evaluateTools(docs); + docs = processCommentDirectives(docs); + return docs; + } + + String processCommentDirectives(String docs) { + if (!docs.contains('{@')) { + return docs; + } + docs = _injectExamples(docs); + docs = _injectYouTube(docs); + docs = _injectAnimations(docs); + docs = _stripMacroTemplatesAndAddToIndex(docs); + docs = _stripHtmlAndAddToIndex(docs); + return docs; + } + + String get _sourceFileName => element.source.fullName; + + String get _fullyQualifiedNameWithoutLibrary => + // Remember, periods are legal in library names. + fullyQualifiedName.replaceFirst('${library.fullyQualifiedName}.', ''); + + ModelElementRenderer get _modelElementRenderer => + packageGraph.rendererFactory.modelElementRenderer; + + /// Replace {@tool ...}{@end-tool} in API comments with the + /// output of an external tool. + /// + /// Looks for tools invocations, looks up their bound executables in the + /// options, and executes them with the source comment material as input, + /// returning the output of the tool. If a named tool isn't configured in the + /// options file, then it will not be executed, and dartdoc will quit with an + /// error. + /// + /// Tool command line arguments are passed to the tool, with the token + /// `$INPUT` replaced with the absolute path to a temporary file containing + /// the content for the tool to read and produce output from. If the tool + /// doesn't need any input, then no `$INPUT` is needed. + /// + /// Nested tool directives will not be evaluated, but tools may generate other + /// directives in their output and those will be evaluated. + /// + /// Syntax: + /// + /// {@tool TOOL [Tool arguments]} + /// Content to send to tool. + /// {@end-tool} + /// + /// Examples: + /// + /// In `dart_options.yaml`: + /// + /// ```yaml + /// dartdoc: + /// tools: + /// # Prefixes the given input with "## " + /// # Path is relative to project root. + /// prefix: "bin/prefix.dart" + /// # Prints the date + /// date: "/bin/date" + /// ``` + /// + /// In code: + /// + /// _This:_ + /// + /// {@tool prefix $INPUT} + /// Content to send to tool. + /// {@end-tool} + /// {@tool date --iso-8601=minutes --utc} + /// {@end-tool} + /// + /// _Produces:_ + /// + /// ## Content to send to tool. + /// 2018-09-18T21:15+00:00 + Future _evaluateTools(String rawDocs) async { + if (!config.allowTools) { + return rawDocs; + } + var invocationIndex = 0; + return await _replaceAllMappedAsync(rawDocs, _basicToolPattern, + (basicMatch) async { + var args = _splitUpQuotedArgs(basicMatch[1]).toList(); + // Tool name must come first. + if (args.isEmpty) { + warn(PackageWarning.toolError, + message: 'Must specify a tool to execute for the @tool directive.'); + return Future.value(''); + } + // Count the number of invocations of tools in this dartdoc block, + // so that tools can differentiate different blocks from each other. + invocationIndex++; + return await config.tools.runner.run( + args, + (String message) async => + warn(PackageWarning.toolError, message: message), + content: basicMatch[2], + environment: { + 'SOURCE_LINE': characterLocation?.lineNumber.toString(), + 'SOURCE_COLUMN': characterLocation?.columnNumber.toString(), + 'SOURCE_PATH': + (_sourceFileName == null || package?.packagePath == null) + ? null + : path.relative(_sourceFileName, from: package.packagePath), + 'PACKAGE_PATH': package?.packagePath, + 'PACKAGE_NAME': package?.name, + 'LIBRARY_NAME': library?.fullyQualifiedName, + 'ELEMENT_NAME': _fullyQualifiedNameWithoutLibrary, + 'INVOCATION_INDEX': invocationIndex.toString(), + 'PACKAGE_INVOCATION_INDEX': + (package.toolInvocationIndex++).toString(), + }..removeWhere((key, value) => value == null)); + }); + } + + /// Replace {@example ...} in API comments with the content of named file. + /// + /// Syntax: + /// + /// {@example PATH [region=NAME] [lang=NAME]} + /// + /// If PATH is `dir/file.ext` and region is `r` then we'll look for the file + /// named `dir/file-r.ext.md`, relative to the project root directory of the + /// project for which the docs are being generated. + /// + /// Examples: (escaped in this comment to show literal values in dartdoc's + /// dartdoc) + /// + /// {@example examples/angular/quickstart/web/main.dart} + /// {@example abc/def/xyz_component.dart region=template lang=html} + /// + String _injectExamples(String rawdocs) { + final dirPath = package.packageMeta.dir.path; + return rawdocs.replaceAllMapped(_examplePattern, (match) { + var args = _getExampleArgs(match[1]); + if (args == null) { + // Already warned about an invalid parameter if this happens. + return ''; + } + var lang = + args['lang'] ?? path.extension(args['src']).replaceFirst('.', ''); + + var replacement = match[0]; // default to fully matched string. + + var fragmentFile = File(path.join(dirPath, args['file'])); + if (fragmentFile.existsSync()) { + replacement = fragmentFile.readAsStringSync(); + if (lang.isNotEmpty) { + replacement = replacement.replaceFirst('```', '```$lang'); + } + } else { + // TODO(jcollins-g): move this to Package.warn system + var filePath = element.source.fullName.substring(dirPath.length + 1); + + logWarning( + 'warning: ${filePath}: @example file not found, ${fragmentFile.path}'); + } + return replacement; + }); + } + + /// Helper for [_injectExamples] used to process `@example` arguments. + /// + /// Returns a map of arguments. The first unnamed argument will have key + /// 'src'. The computed file path, constructed from 'src' and 'region' will + /// have key 'file'. + Map _getExampleArgs(String argsAsString) { + var parser = ArgParser(); + parser.addOption('lang'); + parser.addOption('region'); + var results = _parseArgs(argsAsString, parser, 'example'); + if (results == null) { + return null; + } + + // Extract PATH and fix the path separators. + var src = results.rest.isEmpty + ? '' + : results.rest.first.replaceAll('/', Platform.pathSeparator); + var args = { + 'src': src, + 'lang': results['lang'], + 'region': results['region'] ?? '', + }; + + // Compute 'file' from region and src. + final fragExtension = '.md'; + var file = src + fragExtension; + var region = args['region'] ?? ''; + if (region.isNotEmpty) { + var dir = path.dirname(src); + var basename = path.basenameWithoutExtension(src); + var ext = path.extension(src); + file = path.join(dir, '$basename-$region$ext$fragExtension'); + } + args['file'] = config.examplePathPrefix == null + ? file + : path.join(config.examplePathPrefix, file); + return args; + } + + /// Matches all youtube directives (even some invalid ones). This is so + /// we can give good error messages if the directive is malformed, instead of + /// just silently emitting it as-is. + static final _basicYouTubePattern = RegExp(r'''{@youtube\s+([^}]+)}'''); + + /// Matches YouTube IDs from supported YouTube URLs. + static final _validYouTubeUrlPattern = + RegExp('https://www\.youtube\.com/watch\\?v=([^&]+)\$'); + + /// Replace {@youtube ...} in API comments with some HTML to embed + /// a YouTube video. + /// + /// Syntax: + /// + /// {@youtube WIDTH HEIGHT URL} + /// + /// Example: + /// + /// {@youtube 560 315 https://www.youtube.com/watch?v=oHg5SJYRHA0} + /// + /// Which will embed a YouTube player into the page that plays the specified + /// video. + /// + /// The width and height must be positive integers specifying the dimensions + /// of the video in pixels. The height and width are used to calculate the + /// aspect ratio of the video; the video is always rendered to take up all + /// available horizontal space to accommodate different screen sizes on + /// desktop and mobile. + /// + /// The video URL must have the following format: + /// https://www.youtube.com/watch?v=oHg5SJYRHA0. This format can usually be + /// found in the address bar of the browser when viewing a YouTube video. + String _injectYouTube(String rawDocs) { + return rawDocs.replaceAllMapped(_basicYouTubePattern, (basicMatch) { + var parser = ArgParser(); + var args = _parseArgs(basicMatch[1], parser, 'youtube'); + if (args == null) { + // Already warned about an invalid parameter if this happens. + return ''; + } + var positionalArgs = args.rest.sublist(0); + if (positionalArgs.length != 3) { + warn(PackageWarning.invalidParameter, + message: 'Invalid @youtube directive, "${basicMatch[0]}"\n' + 'YouTube directives must be of the form "{@youtube WIDTH ' + 'HEIGHT URL}"'); + return ''; + } + + var width = int.tryParse(positionalArgs[0]); + if (width == null || width <= 0) { + warn(PackageWarning.invalidParameter, + message: 'A @youtube directive has an invalid width, ' + '"${positionalArgs[0]}". The width must be a positive ' + 'integer.'); + } + + var height = int.tryParse(positionalArgs[1]); + if (height == null || height <= 0) { + warn(PackageWarning.invalidParameter, + message: 'A @youtube directive has an invalid height, ' + '"${positionalArgs[1]}". The height must be a positive ' + 'integer.'); + } + + var url = _validYouTubeUrlPattern.firstMatch(positionalArgs[2]); + if (url == null) { + warn(PackageWarning.invalidParameter, + message: 'A @youtube directive has an invalid URL: ' + '"${positionalArgs[2]}". Supported YouTube URLs have the ' + 'following format: ' + 'https://www.youtube.com/watch?v=oHg5SJYRHA0.'); + return ''; + } + var youTubeId = url.group(url.groupCount); + var aspectRatio = (height / width * 100).toStringAsFixed(2); + + return _modelElementRenderer.renderYoutubeUrl(youTubeId, aspectRatio); + }); + } + + /// Matches all animation directives (even some invalid ones). This is so + /// we can give good error messages if the directive is malformed, instead of + /// just silently emitting it as-is. + final _basicAnimationPattern = RegExp(r'''{@animation\s+([^}]+)}'''); + + /// Matches valid javascript identifiers. + final _validIdPattern = RegExp(r'^[a-zA-Z_]\w*$'); + + /// Replace {@animation ...} in API comments with some HTML to + /// manage an MPEG 4 video as an animation. + /// + /// Syntax: + /// + /// {@animation WIDTH HEIGHT URL [id=ID]} + /// + /// Example: + /// + /// {@animation 300 300 https://example.com/path/to/video.mp4 id="my_video"} + /// + /// Which will render the HTML necessary for embedding a simple click-to-play + /// HTML5 video player with no controls that has an HTML id of "my_video". + /// + /// The optional ID should be a unique id that is a valid JavaScript + /// identifier, and will be used as the id for the video tag. If no ID is + /// supplied, then a unique identifier (starting with "animation_") will be + /// generated. + /// + /// The width and height must be integers specifying the dimensions of the + /// video file in pixels. + String _injectAnimations(String rawDocs) { + // Make sure we have a set to keep track of used IDs for this href. + package.usedAnimationIdsByHref[href] ??= {}; + + String getUniqueId(String base) { + var animationIdCount = 1; + var id = '$base$animationIdCount'; + // We check for duplicate IDs so that we make sure not to collide with + // user-supplied ids on the same page. + while (package.usedAnimationIdsByHref[href].contains(id)) { + animationIdCount++; + id = '$base$animationIdCount'; + } + return id; + } + + return rawDocs.replaceAllMapped(_basicAnimationPattern, (basicMatch) { + var parser = ArgParser(); + parser.addOption('id'); + var args = _parseArgs(basicMatch[1], parser, 'animation'); + if (args == null) { + // Already warned about an invalid parameter if this happens. + return ''; + } + final positionalArgs = args.rest.sublist(0); + String uniqueId; + var wasDeprecated = false; + if (positionalArgs.length == 4) { + // Supports the original form of the animation tag for backward + // compatibility. + uniqueId = positionalArgs.removeAt(0); + wasDeprecated = true; + } else if (positionalArgs.length == 3) { + uniqueId = args['id'] ?? getUniqueId('animation_'); + } else { + warn(PackageWarning.invalidParameter, + message: 'Invalid @animation directive, "${basicMatch[0]}"\n' + 'Animation directives must be of the form "{@animation WIDTH ' + 'HEIGHT URL [id=ID]}"'); + return ''; + } + + if (!_validIdPattern.hasMatch(uniqueId)) { + warn(PackageWarning.invalidParameter, + message: 'An animation has an invalid identifier, "$uniqueId". The ' + 'identifier can only contain letters, numbers and underscores, ' + 'and must not begin with a number.'); + return ''; + } + if (package.usedAnimationIdsByHref[href].contains(uniqueId)) { + warn(PackageWarning.invalidParameter, + message: 'An animation has a non-unique identifier, "$uniqueId". ' + 'Animation identifiers must be unique.'); + return ''; + } + package.usedAnimationIdsByHref[href].add(uniqueId); + + int width; + try { + width = int.parse(positionalArgs[0]); + } on FormatException { + warn(PackageWarning.invalidParameter, + message: 'An animation has an invalid width ($uniqueId), ' + '"${positionalArgs[0]}". The width must be an integer.'); + return ''; + } + + int height; + try { + height = int.parse(positionalArgs[1]); + } on FormatException { + warn(PackageWarning.invalidParameter, + message: 'An animation has an invalid height ($uniqueId), ' + '"${positionalArgs[1]}". The height must be an integer.'); + return ''; + } + + Uri movieUrl; + try { + movieUrl = Uri.parse(positionalArgs[2]); + } on FormatException catch (e) { + warn(PackageWarning.invalidParameter, + message: 'An animation URL could not be parsed ($uniqueId): ' + '${positionalArgs[2]}\n$e'); + return ''; + } + var overlayId = '${uniqueId}_play_button_'; + + // Only warn about deprecation if some other warning didn't occur. + if (wasDeprecated) { + warn(PackageWarning.deprecated, + message: + 'Deprecated form of @animation directive, "${basicMatch[0]}"\n' + 'Animation directives are now of the form "{@animation ' + 'WIDTH HEIGHT URL [id=ID]}" (id is an optional ' + 'parameter)'); + } + + return _modelElementRenderer.renderAnimation( + uniqueId, width, height, movieUrl, overlayId); + }); + } + + /// Parse and remove {@template ...} in API comments and store them + /// in the index on the package. + /// + /// Syntax: + /// + /// {@template NAME} + /// The contents of the macro + /// {@endtemplate} + /// + String _stripMacroTemplatesAndAddToIndex(String rawDocs) { + return rawDocs.replaceAllMapped(_templatePattern, (match) { + var name = match[1].trim(); + var content = match[2].trim(); + packageGraph.addMacro(name, content); + return '{@macro $name}'; + }); + } + + /// Parse and remove {@inject-html ...} in API comments and store + /// them in the index on the package, replacing them with a SHA1 hash of the + /// contents, where the HTML will be re-injected after Markdown processing of + /// the rest of the text is complete. + /// + /// Syntax: + /// + /// {@inject-html} + ///

The HTML to inject.

+ /// {@end-inject-html} + /// + String _stripHtmlAndAddToIndex(String rawDocs) { + if (!config.injectHtml) return rawDocs; + return rawDocs.replaceAllMapped(_htmlPattern, (match) { + var fragment = match[1]; + var digest = sha1.convert(fragment.codeUnits).toString(); + packageGraph.addHtmlFragment(digest, fragment); + // The newlines are so that Markdown will pass this through without + // touching it. + return '\n$digest\n'; + }); + } + + /// Helper to process arguments given as a (possibly quoted) string. + /// + /// First, this will split the given [argsAsString] into separate arguments + /// with [_splitUpQuotedArgs] it then parses the resulting argument list + /// normally with [argParser] and returns the result. + ArgResults _parseArgs( + String argsAsString, ArgParser argParser, String directiveName) { + var args = _splitUpQuotedArgs(argsAsString, convertToArgs: true); + try { + return argParser.parse(args); + } on ArgParserException catch (e) { + warn(PackageWarning.invalidParameter, + message: 'The {@$directiveName ...} directive was called with ' + 'invalid parameters. $e'); + return null; + } + } + + static Future _replaceAllMappedAsync(String string, Pattern exp, + Future Function(Match match) replace) async { + var replaced = StringBuffer(); + var currentIndex = 0; + for (var match in exp.allMatches(string)) { + var prefix = match.input.substring(currentIndex, match.start); + currentIndex = match.end; + replaced..write(prefix)..write(await replace(match)); + } + replaced.write(string.substring(currentIndex)); + return replaced.toString(); + } + + /// Regexp to take care of splitting arguments, and handling the quotes + /// around arguments, if any. + /// + /// Match group 1 is the "foo=" (or "--foo=") part of the option, if any. + /// Match group 2 contains the quote character used (which is discarded). + /// Match group 3 is a quoted arg, if any, without the quotes. + /// Match group 4 is the unquoted arg, if any. + static final RegExp _argPattern = RegExp(r'([a-zA-Z\-_0-9]+=)?' // option name + r'(?:' // Start a new non-capture group for the two possibilities. + r'''(["'])((?:\\{2})*|(?:.*?[^\\](?:\\{2})*))\2|''' // with quotes. + r'([^ ]+))'); // without quotes. + + /// Helper to process arguments given as a (possibly quoted) string. + /// + /// First, this will split the given [argsAsString] into separate arguments, + /// taking any quoting (either ' or " are accepted) into account, including + /// handling backslash-escaped quotes. + /// + /// Then, it will prepend "--" to any args that start with an identifier + /// followed by an equals sign, allowing the argument parser to treat any + /// "foo=bar" argument as "--foo=bar". It does handle quoted args like + /// "foo='bar baz'" too, returning just bar (without quotes) for the foo + /// value. + static Iterable _splitUpQuotedArgs(String argsAsString, + {bool convertToArgs = false}) { + final Iterable matches = _argPattern.allMatches(argsAsString); + // Remove quotes around args, and if [convertToArgs] is true, then for any + // args that look like assignments (start with valid option names followed + // by an equals sign), add a "--" in front so that they parse as options. + return matches.map((Match match) { + var option = ''; + if (convertToArgs && match[1] != null && !match[1].startsWith('-')) { + option = '--'; + } + if (match[2] != null) { + // This arg has quotes, so strip them. + return '$option${match[1] ?? ''}${match[3] ?? ''}${match[4] ?? ''}'; + } + return '$option${match[0]}'; + }); + } +} diff --git a/lib/src/model/model_element.dart b/lib/src/model/model_element.dart index 953c3159a3..c19737b0f1 100644 --- a/lib/src/model/model_element.dart +++ b/lib/src/model/model_element.dart @@ -8,19 +8,16 @@ library dartdoc.models; import 'dart:async'; import 'dart:collection' show UnmodifiableListView; import 'dart:convert'; -import 'dart:io'; import 'package:analyzer/dart/element/element.dart'; import 'package:analyzer/source/line_info.dart'; import 'package:analyzer/src/dart/element/element.dart'; import 'package:analyzer/src/dart/element/member.dart' show ExecutableMember, Member, ParameterMember; -import 'package:args/args.dart'; import 'package:collection/collection.dart'; -import 'package:crypto/crypto.dart'; import 'package:dartdoc/src/dartdoc_options.dart'; import 'package:dartdoc/src/element_type.dart'; -import 'package:dartdoc/src/logging.dart'; +import 'package:dartdoc/src/model/comment_processable.dart'; import 'package:dartdoc/src/model/feature_set.dart'; import 'package:dartdoc/src/model/model.dart'; import 'package:dartdoc/src/model_utils.dart' as utils; @@ -29,7 +26,6 @@ import 'package:dartdoc/src/render/parameter_renderer.dart'; import 'package:dartdoc/src/render/source_code_renderer.dart'; import 'package:dartdoc/src/source_linker.dart'; import 'package:dartdoc/src/tuple.dart'; -import 'package:dartdoc/src/utils.dart'; import 'package:dartdoc/src/warnings.dart'; import 'package:meta/meta.dart'; import 'package:path/path.dart' as path; @@ -71,33 +67,8 @@ int byFeatureOrdering(String a, String b) { /// fragment. final needsPrecacheRegExp = RegExp(r'{@(template|tool|inject-html)'); -final templateRegExp = RegExp( - r'[ ]*{@template\s+(.+?)}([\s\S]+?){@endtemplate}[ ]*\n?', - multiLine: true); -final htmlRegExp = RegExp( - r'[ ]*{@inject-html\s*}([\s\S]+?){@end-inject-html}[ ]*\n?', - multiLine: true); final htmlInjectRegExp = RegExp(r'([a-f0-9]+)'); -// Matches all tool directives (even some invalid ones). This is so -// we can give good error messages if the directive is malformed, instead of -// just silently emitting it as-is. -final basicToolRegExp = RegExp( - r'[ ]*{@tool\s+([^}]+)}\n?([\s\S]+?)\n?{@end-tool}[ ]*\n?', - multiLine: true); - -/// Regexp to take care of splitting arguments, and handling the quotes -/// around arguments, if any. -/// -/// Match group 1 is the "foo=" (or "--foo=") part of the option, if any. -/// Match group 2 contains the quote character used (which is discarded). -/// Match group 3 is a quoted arg, if any, without the quotes. -/// Match group 4 is the unquoted arg, if any. -final RegExp argMatcher = RegExp(r'([a-zA-Z\-_0-9]+=)?' // option name - r'(?:' // Start a new non-capture group for the two possibilities. - r'''(["'])((?:\\{2})*|(?:.*?[^\\](?:\\{2})*))\2|''' // with quotes. - r'([^ ]+))'); // without quotes. - final macroRegExp = RegExp(r'{@macro\s+([^}]+)}'); // TODO(jcollins-g): Implement resolution per ECMA-408 4th edition, page 39 #22. @@ -158,7 +129,8 @@ abstract class ModelElement extends Canonicalization Nameable, SourceCodeMixin, Indexable, - FeatureSet + FeatureSet, + CommentProcessable implements Comparable, Documentable { final Element _element; @@ -172,9 +144,6 @@ abstract class ModelElement extends Canonicalization UnmodifiableListView _parameters; String _linkedName; - String _fullyQualifiedName; - String _fullyQualifiedNameWithoutLibrary; - // TODO(jcollins-g): make _originalMember optional after dart-lang/sdk#15101 // is fixed. ModelElement( @@ -599,22 +568,19 @@ abstract class ModelElement extends Canonicalization /// to find docs, if the current class doesn't have docs /// for this element. List get computeDocumentationFrom { - List docFrom; - if (documentationComment == null && - canOverride() && + _canOverride && this is Inheritable && (this as Inheritable).overriddenElement != null) { - docFrom = (this as Inheritable).overriddenElement.documentationFrom; + return (this as Inheritable).overriddenElement.documentationFrom; } else if (this is Inheritable && (this as Inheritable).isInherited) { var thisInheritable = (this as Inheritable); var fromThis = ModelElement.fromElement( element, thisInheritable.definingEnclosingContainer.packageGraph); - docFrom = fromThis.documentationFrom; + return fromThis.documentationFrom; } else { - docFrom = [this]; + return [this]; } - return docFrom; } String _buildDocumentationLocal() => _buildDocumentationBaseSync(); @@ -633,12 +599,7 @@ abstract class ModelElement extends Canonicalization if (config.dropTextFrom.contains(element.library.name)) { _rawDocs = ''; } else { - _rawDocs = documentationComment ?? ''; - _rawDocs = stripComments(_rawDocs) ?? ''; - _rawDocs = _injectExamples(_rawDocs); - _rawDocs = _injectYouTube(_rawDocs); - _rawDocs = _injectAnimations(_rawDocs); - _rawDocs = _stripHtmlAndAddToIndex(_rawDocs); + _rawDocs = processCommentWithoutTools(documentationComment ?? ''); } _rawDocs = buildDocumentationAddition(_rawDocs); return _rawDocs; @@ -653,15 +614,7 @@ abstract class ModelElement extends Canonicalization if (config.dropTextFrom.contains(element.library.name)) { _rawDocs = ''; } else { - _rawDocs = documentationComment ?? ''; - _rawDocs = stripComments(_rawDocs) ?? ''; - // Must evaluate tools first, in case they insert any other directives. - _rawDocs = await _evaluateTools(_rawDocs); - _rawDocs = _injectExamples(_rawDocs); - _rawDocs = _injectYouTube(_rawDocs); - _rawDocs = _injectAnimations(_rawDocs); - _rawDocs = _stripMacroTemplatesAndAddToIndex(_rawDocs); - _rawDocs = _stripHtmlAndAddToIndex(_rawDocs); + _rawDocs = await processComment(documentationComment ?? ''); } _rawDocs = buildDocumentationAddition(_rawDocs); return _rawDocs; @@ -688,8 +641,8 @@ abstract class ModelElement extends Canonicalization Library _canonicalLibrary; - // _canonicalLibrary can be null so we can't check against null to see whether - // we tried to compute it before. + // [_canonicalLibrary] can be null so we can't check against null to see + // whether we tried to compute it before. bool _canonicalLibraryIsSet = false; @override @@ -846,6 +799,8 @@ abstract class ModelElement extends Canonicalization String get filePath; + String _fullyQualifiedName; + /// Returns the fully qualified name. /// /// For example: libraryName.className.methodName @@ -854,6 +809,7 @@ abstract class ModelElement extends Canonicalization return (_fullyQualifiedName ??= _buildFullyQualifiedName()); } + String _fullyQualifiedNameWithoutLibrary; String get fullyQualifiedNameWithoutLibrary { // Remember, periods are legal in library names. _fullyQualifiedNameWithoutLibrary ??= @@ -1153,7 +1109,7 @@ abstract class ModelElement extends Canonicalization _sourceCodeRenderer.renderSourceCode(super.sourceCode); } - bool canOverride() => + bool get _canOverride => element is ClassMemberElement || element is PropertyAccessorElement; @override @@ -1198,369 +1154,6 @@ abstract class ModelElement extends Canonicalization return _modelElementRenderer.renderLinkedName(this); } - /// Replace {@example ...} in API comments with the content of named file. - /// - /// Syntax: - /// - /// {@example PATH [region=NAME] [lang=NAME]} - /// - /// If PATH is `dir/file.ext` and region is `r` then we'll look for the file - /// named `dir/file-r.ext.md`, relative to the project root directory of the - /// project for which the docs are being generated. - /// - /// Examples: (escaped in this comment to show literal values in dartdoc's - /// dartdoc) - /// - /// {@example examples/angular/quickstart/web/main.dart} - /// {@example abc/def/xyz_component.dart region=template lang=html} - /// - String _injectExamples(String rawdocs) { - final dirPath = package.packageMeta.dir.path; - var exampleRE = RegExp(r'{@example\s+([^}]+)}'); - return rawdocs.replaceAllMapped(exampleRE, (match) { - var args = _getExampleArgs(match[1]); - if (args == null) { - // Already warned about an invalid parameter if this happens. - return ''; - } - var lang = - args['lang'] ?? path.extension(args['src']).replaceFirst('.', ''); - - var replacement = match[0]; // default to fully matched string. - - var fragmentFile = File(path.join(dirPath, args['file'])); - if (fragmentFile.existsSync()) { - replacement = fragmentFile.readAsStringSync(); - if (lang.isNotEmpty) { - replacement = replacement.replaceFirst('```', '```$lang'); - } - } else { - // TODO(jcollins-g): move this to Package.warn system - var filePath = element.source.fullName.substring(dirPath.length + 1); - - logWarning( - 'warning: ${filePath}: @example file not found, ${fragmentFile.path}'); - } - return replacement; - }); - } - - static Future _replaceAllMappedAsync(String string, Pattern exp, - Future Function(Match match) replace) async { - var replaced = StringBuffer(); - var currentIndex = 0; - for (var match in exp.allMatches(string)) { - var prefix = match.input.substring(currentIndex, match.start); - currentIndex = match.end; - replaced..write(prefix)..write(await replace(match)); - } - replaced.write(string.substring(currentIndex)); - return replaced.toString(); - } - - /// Replace {@tool ...}{@end-tool} in API comments with the - /// output of an external tool. - /// - /// Looks for tools invocations, looks up their bound executables in the - /// options, and executes them with the source comment material as input, - /// returning the output of the tool. If a named tool isn't configured in the - /// options file, then it will not be executed, and dartdoc will quit with an - /// error. - /// - /// Tool command line arguments are passed to the tool, with the token - /// `$INPUT` replaced with the absolute path to a temporary file containing - /// the content for the tool to read and produce output from. If the tool - /// doesn't need any input, then no `$INPUT` is needed. - /// - /// Nested tool directives will not be evaluated, but tools may generate other - /// directives in their output and those will be evaluated. - /// - /// Syntax: - /// - /// {@tool TOOL [Tool arguments]} - /// Content to send to tool. - /// {@end-tool} - /// - /// Examples: - /// - /// In `dart_options.yaml`: - /// - /// ```yaml - /// dartdoc: - /// tools: - /// # Prefixes the given input with "## " - /// # Path is relative to project root. - /// prefix: "bin/prefix.dart" - /// # Prints the date - /// date: "/bin/date" - /// ``` - /// - /// In code: - /// - /// _This:_ - /// - /// {@tool prefix $INPUT} - /// Content to send to tool. - /// {@end-tool} - /// {@tool date --iso-8601=minutes --utc} - /// {@end-tool} - /// - /// _Produces:_ - /// - /// ## Content to send to tool. - /// 2018-09-18T21:15+00:00 - Future _evaluateTools(String rawDocs) async { - if (config.allowTools) { - var invocationIndex = 0; - return await _replaceAllMappedAsync(rawDocs, basicToolRegExp, - (basicMatch) async { - var args = _splitUpQuotedArgs(basicMatch[1]).toList(); - // Tool name must come first. - if (args.isEmpty) { - warn(PackageWarning.toolError, - message: - 'Must specify a tool to execute for the @tool directive.'); - return Future.value(''); - } - // Count the number of invocations of tools in this dartdoc block, - // so that tools can differentiate different blocks from each other. - invocationIndex++; - return await config.tools.runner.run( - args, - (String message) async => - warn(PackageWarning.toolError, message: message), - content: basicMatch[2], - environment: { - 'SOURCE_LINE': characterLocation?.lineNumber.toString(), - 'SOURCE_COLUMN': characterLocation?.columnNumber.toString(), - 'SOURCE_PATH': (sourceFileName == null || - package?.packagePath == null) - ? null - : path.relative(sourceFileName, from: package.packagePath), - 'PACKAGE_PATH': package?.packagePath, - 'PACKAGE_NAME': package?.name, - 'LIBRARY_NAME': library?.fullyQualifiedName, - 'ELEMENT_NAME': fullyQualifiedNameWithoutLibrary, - 'INVOCATION_INDEX': invocationIndex.toString(), - 'PACKAGE_INVOCATION_INDEX': - (package.toolInvocationIndex++).toString(), - }..removeWhere((key, value) => value == null)); - }); - } else { - return rawDocs; - } - } - - /// Replace {@youtube ...} in API comments with some HTML to embed - /// a YouTube video. - /// - /// Syntax: - /// - /// {@youtube WIDTH HEIGHT URL} - /// - /// Example: - /// - /// {@youtube 560 315 https://www.youtube.com/watch?v=oHg5SJYRHA0} - /// - /// Which will embed a YouTube player into the page that plays the specified - /// video. - /// - /// The width and height must be positive integers specifying the dimensions - /// of the video in pixels. The height and width are used to calculate the - /// aspect ratio of the video; the video is always rendered to take up all - /// available horizontal space to accommodate different screen sizes on - /// desktop and mobile. - /// - /// The video URL must have the following format: - /// https://www.youtube.com/watch?v=oHg5SJYRHA0. This format can usually be - /// found in the address bar of the browser when viewing a YouTube video. - String _injectYouTube(String rawDocs) { - // Matches all youtube directives (even some invalid ones). This is so - // we can give good error messages if the directive is malformed, instead of - // just silently emitting it as-is. - var basicAnimationRegExp = RegExp(r'''{@youtube\s+([^}]+)}'''); - - // Matches YouTube IDs from supported YouTube URLs. - var validYouTubeUrlRegExp = - RegExp('https://www\.youtube\.com/watch\\?v=([^&]+)\$'); - - return rawDocs.replaceAllMapped(basicAnimationRegExp, (basicMatch) { - var parser = ArgParser(); - var args = _parseArgs(basicMatch[1], parser, 'youtube'); - if (args == null) { - // Already warned about an invalid parameter if this happens. - return ''; - } - var positionalArgs = args.rest.sublist(0); - if (positionalArgs.length != 3) { - warn(PackageWarning.invalidParameter, - message: 'Invalid @youtube directive, "${basicMatch[0]}"\n' - 'YouTube directives must be of the form "{@youtube WIDTH ' - 'HEIGHT URL}"'); - return ''; - } - - var width = int.tryParse(positionalArgs[0]); - if (width == null || width <= 0) { - warn(PackageWarning.invalidParameter, - message: 'A @youtube directive has an invalid width, ' - '"${positionalArgs[0]}". The width must be a positive integer.'); - } - - var height = int.tryParse(positionalArgs[1]); - if (height == null || height <= 0) { - warn(PackageWarning.invalidParameter, - message: 'A @youtube directive has an invalid height, ' - '"${positionalArgs[1]}". The height must be a positive integer.'); - } - - var url = validYouTubeUrlRegExp.firstMatch(positionalArgs[2]); - if (url == null) { - warn(PackageWarning.invalidParameter, - message: 'A @youtube directive has an invalid URL: ' - '"${positionalArgs[2]}". Supported YouTube URLs have the ' - 'following format: https://www.youtube.com/watch?v=oHg5SJYRHA0.'); - return ''; - } - var youTubeId = url.group(url.groupCount); - var aspectRatio = (height / width * 100).toStringAsFixed(2); - - return _modelElementRenderer.renderYoutubeUrl(youTubeId, aspectRatio); - }); - } - - /// Replace {@animation ...} in API comments with some HTML to manage an - /// MPEG 4 video as an animation. - /// - /// Syntax: - /// - /// {@animation WIDTH HEIGHT URL [id=ID]} - /// - /// Example: - /// - /// {@animation 300 300 https://example.com/path/to/video.mp4 id="my_video"} - /// - /// Which will render the HTML necessary for embedding a simple click-to-play - /// HTML5 video player with no controls that has an HTML id of "my_video". - /// - /// The optional ID should be a unique id that is a valid JavaScript - /// identifier, and will be used as the id for the video tag. If no ID is - /// supplied, then a unique identifier (starting with "animation_") will be - /// generated. - /// - /// The width and height must be integers specifying the dimensions of the - /// video file in pixels. - String _injectAnimations(String rawDocs) { - // Matches all animation directives (even some invalid ones). This is so - // we can give good error messages if the directive is malformed, instead of - // just silently emitting it as-is. - var basicAnimationRegExp = RegExp(r'''{@animation\s+([^}]+)}'''); - - // Matches valid javascript identifiers. - var validIdRegExp = RegExp(r'^[a-zA-Z_]\w*$'); - - // Make sure we have a set to keep track of used IDs for this href. - package.usedAnimationIdsByHref[href] ??= {}; - - String getUniqueId(String base) { - var animationIdCount = 1; - var id = '$base$animationIdCount'; - // We check for duplicate IDs so that we make sure not to collide with - // user-supplied ids on the same page. - while (package.usedAnimationIdsByHref[href].contains(id)) { - animationIdCount++; - id = '$base$animationIdCount'; - } - return id; - } - - return rawDocs.replaceAllMapped(basicAnimationRegExp, (basicMatch) { - var parser = ArgParser(); - parser.addOption('id'); - var args = _parseArgs(basicMatch[1], parser, 'animation'); - if (args == null) { - // Already warned about an invalid parameter if this happens. - return ''; - } - final positionalArgs = args.rest.sublist(0); - String uniqueId; - var wasDeprecated = false; - if (positionalArgs.length == 4) { - // Supports the original form of the animation tag for backward - // compatibility. - uniqueId = positionalArgs.removeAt(0); - wasDeprecated = true; - } else if (positionalArgs.length == 3) { - uniqueId = args['id'] ?? getUniqueId('animation_'); - } else { - warn(PackageWarning.invalidParameter, - message: 'Invalid @animation directive, "${basicMatch[0]}"\n' - 'Animation directives must be of the form "{@animation WIDTH ' - 'HEIGHT URL [id=ID]}"'); - return ''; - } - - if (!validIdRegExp.hasMatch(uniqueId)) { - warn(PackageWarning.invalidParameter, - message: 'An animation has an invalid identifier, "$uniqueId". The ' - 'identifier can only contain letters, numbers and underscores, ' - 'and must not begin with a number.'); - return ''; - } - if (package.usedAnimationIdsByHref[href].contains(uniqueId)) { - warn(PackageWarning.invalidParameter, - message: 'An animation has a non-unique identifier, "$uniqueId". ' - 'Animation identifiers must be unique.'); - return ''; - } - package.usedAnimationIdsByHref[href].add(uniqueId); - - int width; - try { - width = int.parse(positionalArgs[0]); - } on FormatException { - warn(PackageWarning.invalidParameter, - message: 'An animation has an invalid width ($uniqueId), ' - '"${positionalArgs[0]}". The width must be an integer.'); - return ''; - } - - int height; - try { - height = int.parse(positionalArgs[1]); - } on FormatException { - warn(PackageWarning.invalidParameter, - message: 'An animation has an invalid height ($uniqueId), ' - '"${positionalArgs[1]}". The height must be an integer.'); - return ''; - } - - Uri movieUrl; - try { - movieUrl = Uri.parse(positionalArgs[2]); - } on FormatException catch (e) { - warn(PackageWarning.invalidParameter, - message: 'An animation URL could not be parsed ($uniqueId): ' - '${positionalArgs[2]}\n$e'); - return ''; - } - var overlayId = '${uniqueId}_play_button_'; - - // Only warn about deprecation if some other warning didn't occur. - if (wasDeprecated) { - warn(PackageWarning.deprecated, - message: - 'Deprecated form of @animation directive, "${basicMatch[0]}"\n' - 'Animation directives are now of the form "{@animation ' - 'WIDTH HEIGHT URL [id=ID]}" (id is an optional ' - 'parameter)'); - } - - return _modelElementRenderer.renderAnimation( - uniqueId, width, height, movieUrl, overlayId); - }); - } - /// Replace <[digest]> in API comments with /// the contents of the HTML fragment earlier defined by the /// {@inject-html} directive. The [digest] is a SHA1 of the contents @@ -1639,133 +1232,8 @@ abstract class ModelElement extends Canonicalization if (macro == null) { warn(PackageWarning.unknownMacro, message: match[1]); } + macro = processCommentDirectives(macro ?? ''); return macro; }); } - - /// Parse and remove {@template ...} in API comments and store them - /// in the index on the package. - /// - /// Syntax: - /// - /// {@template NAME} - /// The contents of the macro - /// {@endtemplate} - /// - String _stripMacroTemplatesAndAddToIndex(String rawDocs) { - return rawDocs.replaceAllMapped(templateRegExp, (match) { - packageGraph.addMacro(match[1].trim(), match[2].trim()); - return '{@macro ${match[1].trim()}}'; - }); - } - - /// Parse and remove {@inject-html ...} in API comments and store - /// them in the index on the package, replacing them with a SHA1 hash of the - /// contents, where the HTML will be re-injected after Markdown processing of - /// the rest of the text is complete. - /// - /// Syntax: - /// - /// {@inject-html} - ///

The HTML to inject.

- /// {@end-inject-html} - /// - String _stripHtmlAndAddToIndex(String rawDocs) { - if (!config.injectHtml) return rawDocs; - return rawDocs.replaceAllMapped(htmlRegExp, (match) { - var fragment = match[1]; - var digest = sha1.convert(fragment.codeUnits).toString(); - packageGraph.addHtmlFragment(digest, fragment); - // The newlines are so that Markdown will pass this through without - // touching it. - return '\n$digest\n'; - }); - } - - /// Helper to process arguments given as a (possibly quoted) string. - /// - /// First, this will split the given [argsAsString] into separate arguments, - /// taking any quoting (either ' or " are accepted) into account, including - /// handling backslash-escaped quotes. - /// - /// Then, it will prepend "--" to any args that start with an identifier - /// followed by an equals sign, allowing the argument parser to treat any - /// "foo=bar" argument as "--foo=bar". It does handle quoted args like - /// "foo='bar baz'" too, returning just bar (without quotes) for the foo - /// value. - Iterable _splitUpQuotedArgs(String argsAsString, - {bool convertToArgs = false}) { - final Iterable matches = argMatcher.allMatches(argsAsString); - // Remove quotes around args, and if convertToArgs is true, then for any - // args that look like assignments (start with valid option names followed - // by an equals sign), add a "--" in front so that they parse as options. - return matches.map((Match match) { - var option = ''; - if (convertToArgs && match[1] != null && !match[1].startsWith('-')) { - option = '--'; - } - if (match[2] != null) { - // This arg has quotes, so strip them. - return '$option${match[1] ?? ''}${match[3] ?? ''}${match[4] ?? ''}'; - } - return '$option${match[0]}'; - }); - } - - /// Helper to process arguments given as a (possibly quoted) string. - /// - /// First, this will split the given [argsAsString] into separate arguments - /// with [_splitUpQuotedArgs] it then parses the resulting argument list - /// normally with [argParser] and returns the result. - ArgResults _parseArgs( - String argsAsString, ArgParser argParser, String directiveName) { - var args = _splitUpQuotedArgs(argsAsString, convertToArgs: true); - try { - return argParser.parse(args); - } on ArgParserException catch (e) { - warn(PackageWarning.invalidParameter, - message: 'The {@$directiveName ...} directive was called with ' - 'invalid parameters. $e'); - return null; - } - } - - /// Helper for _injectExamples used to process @example arguments. - /// Returns a map of arguments. The first unnamed argument will have key 'src'. - /// The computed file path, constructed from 'src' and 'region' will have key - /// 'file'. - Map _getExampleArgs(String argsAsString) { - var parser = ArgParser(); - parser.addOption('lang'); - parser.addOption('region'); - var results = _parseArgs(argsAsString, parser, 'example'); - if (results == null) { - return null; - } - - // Extract PATH and fix the path separators. - var src = results.rest.isEmpty - ? '' - : results.rest.first.replaceAll('/', Platform.pathSeparator); - var args = { - 'src': src, - 'lang': results['lang'], - 'region': results['region'] ?? '', - }; - - // Compute 'file' from region and src. - final fragExtension = '.md'; - var file = src + fragExtension; - var region = args['region'] ?? ''; - if (region.isNotEmpty) { - var dir = path.dirname(src); - var basename = path.basenameWithoutExtension(src); - var ext = path.extension(src); - file = path.join(dir, '$basename-$region$ext$fragExtension'); - } - args['file'] = config.examplePathPrefix == null - ? file - : path.join(config.examplePathPrefix, file); - return args; - } } diff --git a/lib/src/model/package_graph.dart b/lib/src/model/package_graph.dart index 80b02d149f..a021b433f9 100644 --- a/lib/src/model/package_graph.dart +++ b/lib/src/model/package_graph.dart @@ -923,7 +923,11 @@ class PackageGraph { } void addHtmlFragment(String name, String content) { - assert(!_localDocumentationBuilt); + // TODO(srawlins): We have to add HTML fragments after documentation is + // built, in order to include fragments which come from macros which + // were generated by a tool. I think the macro/HTML-injection system needs + // to be overhauled to allow for this type of looping. + //assert(!_localDocumentationBuilt); _htmlFragments[name] = content; } } diff --git a/test/model_special_cases_test.dart b/test/model_special_cases_test.dart index 8f1666c06b..6ba938a707 100644 --- a/test/model_special_cases_test.dart +++ b/test/model_special_cases_test.dart @@ -266,6 +266,18 @@ void main() { expect(injectHtmlFromTool.documentationAsHtml, isNot(contains('{@end-inject-html}'))); }); + test('tool outputs a macro which outputs injected HTML', () { + var ToolPrintingMacroWhichInjectsHtml = injectionExLibrary.allClasses + .firstWhere((c) => c.name == 'ToolPrintingMacroWhichInjectsHtml'); + var a = ToolPrintingMacroWhichInjectsHtml.instanceFields + .firstWhere((m) => m.name == 'a'); + expect(a.documentationAsHtml, + contains('

Text.

\n

Title

')); + var b = ToolPrintingMacroWhichInjectsHtml.instanceFields + .firstWhere((m) => m.name == 'b'); + expect(b.documentationAsHtml, + contains('

Text.

\n

Title

')); + }); }); group('Missing and Remote', () { diff --git a/testing/test_package/bin/print_macro.dart b/testing/test_package/bin/print_macro.dart new file mode 100644 index 0000000000..9b4cd5c9d1 --- /dev/null +++ b/testing/test_package/bin/print_macro.dart @@ -0,0 +1,10 @@ +// Copyright (c) 2020, 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. + +// This is a sample "tool" used to test external tool integration into dartdoc. +// It has no other purpose. + +void main() { + print('{@macro html-macro}'); +} diff --git a/testing/test_package/dartdoc_options.yaml b/testing/test_package/dartdoc_options.yaml index 305e927268..2e1d9d5321 100644 --- a/testing/test_package/dartdoc_options.yaml +++ b/testing/test_package/dartdoc_options.yaml @@ -11,6 +11,9 @@ dartdoc: drill: command: ["bin/drill.dart"] description: "Puts holes in things." + print_macro: + command: ["bin/print_macro.dart"] + description: "Prints a macro." echo: macos: ['/bin/sh', '-c', 'echo'] linux: ['/bin/sh', '-c', 'echo'] diff --git a/testing/test_package/lib/example.dart b/testing/test_package/lib/example.dart index bf656c76fe..d441643311 100644 --- a/testing/test_package/lib/example.dart +++ b/testing/test_package/lib/example.dart @@ -200,16 +200,14 @@ class Apple { final ParameterizedTypedef fieldWithTypedef; } - /// Extension on Apple extension AppleExtension on Apple { -/// Can call s on Apple + /// Can call s on Apple void s() { print('Extension on Apple'); } } - class WithGeneric { T prop; WithGeneric(this.prop); @@ -666,7 +664,7 @@ class ExtensionUser { /// Extension on List extension FancyList on List { int get doubleLength => this.length * 2; - List operator-() => this.reversed.toList(); + List operator -() => this.reversed.toList(); List> split(int at) => >[this.sublist(0, at), this.sublist(at)]; static List big() => List(1000000); @@ -674,7 +672,7 @@ extension FancyList on List { extension SymDiff on Set { Set symmetricDifference(Set other) => - this.difference(other).union(other.difference(this)); + this.difference(other).union(other.difference(this)); } /// Extensions can be made specific. @@ -684,16 +682,30 @@ extension IntSet on Set { // Extensions can be private. extension _Shhh on Object { - void secret() { } + void secret() {} } // Extension with no name extension on Object { - void bar() { } + void bar() {} } - /// This class has nothing to do with [_Shhh], [FancyList], or [AnExtension.call], /// but should not crash because we referenced them. /// We should be able to find [DocumentThisExtensionOnce], too. class ExtensionReferencer {} + +class ToolPrintingMacroWhichInjectsHtml { + /// Text. + /// {@template html-macro} + /// {@inject-html}
Title
{@end-inject-html} + /// {@endtemplate} + int a; + + /// Text. + /// + /// {@tool print_macro} + /// Text for tool. + /// {@end-tool} + int b; +} From 88da0750a79cfe3280654d593d04d1489fdb59f1 Mon Sep 17 00:00:00 2001 From: Sam Rawlins Date: Fri, 24 Jul 2020 07:26:11 -0700 Subject: [PATCH 2/3] test --- test/model_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/model_test.dart b/test/model_test.dart index 7352b37d12..90baabe686 100644 --- a/test/model_test.dart +++ b/test/model_test.dart @@ -1617,7 +1617,7 @@ void main() { }); test('correctly finds all the classes', () { - expect(classes, hasLength(33)); + expect(classes, hasLength(34)); }); test('abstract', () { From 95d022591bc6b5769b912e50e97fdb1809a0197e Mon Sep 17 00:00:00 2001 From: Sam Rawlins Date: Fri, 24 Jul 2020 14:17:00 -0700 Subject: [PATCH 3/3] Comment out one more assert. --- lib/src/model/package_graph.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/src/model/package_graph.dart b/lib/src/model/package_graph.dart index a021b433f9..9f235c8ea9 100644 --- a/lib/src/model/package_graph.dart +++ b/lib/src/model/package_graph.dart @@ -913,7 +913,11 @@ class PackageGraph { } void addMacro(String name, String content) { - assert(!_localDocumentationBuilt); + // TODO(srawlins): We have to add HTML fragments after documentation is + // built, in order to include fragments which come from macros which + // were generated by a tool. I think the macro/HTML-injection system needs + // to be overhauled to allow for this type of looping. + //assert(!_localDocumentationBuilt); _macros[name] = content; }