blob: 6eef2076cea2b33e951358e8805d387395873803 [file] [log] [blame]
// Copyright (c) 2015, 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.
/// Utility code to convert markdown comments to html.
library dartdoc.markdown_processor;
import 'dart:convert';
import 'dart:math';
import 'package:dartdoc/src/comment_references/model_comment_reference.dart';
import 'package:dartdoc/src/matching_link_result.dart';
import 'package:dartdoc/src/model/comment_referable.dart';
import 'package:dartdoc/src/model/model.dart';
import 'package:dartdoc/src/warnings.dart';
import 'package:markdown/markdown.dart' as md;
import 'package:meta/meta.dart';
const _validHtmlTags = [
'a',
'abbr',
'address',
'area',
'article',
'aside',
'audio',
'b',
'bdi',
'bdo',
'blockquote',
'br',
'button',
'canvas',
'caption',
'cite',
'code',
'col',
'colgroup',
'data',
'datalist',
'dd',
'del',
'dfn',
'div',
'dl',
'dt',
'em',
'fieldset',
'figcaption',
'figure',
'footer',
'form',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'header',
'hr',
'i',
'iframe',
'img',
'input',
'ins',
'kbd',
'keygen',
'label',
'legend',
'li',
'link',
'main',
'map',
'mark',
'meta',
'meter',
'nav',
'noscript',
'object',
'ol',
'optgroup',
'option',
'output',
'p',
'param',
'pre',
'progress',
'q',
's',
'samp',
'script',
'section',
'select',
'small',
'source',
'span',
'strong',
'style',
'sub',
'sup',
'table',
'tbody',
'td',
'template',
'textarea',
'tfoot',
'th',
'thead',
'time',
'title',
'tr',
'track',
'u',
'ul',
'var',
'video',
'wbr'
];
final RegExp _nonHTML =
RegExp("</?(?!(${_validHtmlTags.join("|")})[> ])\\w+[> ]");
final HtmlEscape _htmlEscape = const HtmlEscape(HtmlEscapeMode.element);
final List<md.InlineSyntax> _markdownSyntaxes = [
_InlineCodeSyntax(),
_AutolinkWithoutScheme(),
md.InlineHtmlSyntax(),
md.StrikethroughSyntax(),
md.AutolinkExtensionSyntax(),
];
final List<md.BlockSyntax> _markdownBlockSyntaxes = [
const md.FencedCodeBlockSyntax(),
const md.HeaderWithIdSyntax(),
const md.SetextHeaderWithIdSyntax(),
const md.TableSyntax(),
];
// Remove these schemas from the display text for hyperlinks.
final RegExp _hideSchemes = RegExp('^(http|https)://');
class _IterableBlockParser extends md.BlockParser {
_IterableBlockParser(List<String> lines, md.Document document)
: super(lines, document);
Iterable<md.Node> parseLinesGenerator() sync* {
while (!isDone) {
for (var syntax in blockSyntaxes) {
if (syntax.canParse(this)) {
var block = syntax.parse(this);
if (block != null) yield (block);
break;
}
}
}
}
}
/// Return false if the passed [referable] is an unnamed [Constructor],
/// or if it is shadowing another type of element, or is a parameter of
/// one of the above.
bool _rejectUnnamedAndShadowingConstructors(CommentReferable referable) {
if (referable is Constructor) {
if (referable.isUnnamedConstructor) return false;
if (referable.enclosingElement
.referenceChildren[referable.name.split('.').last] is! Constructor) {
return false;
}
}
return true;
}
/// Return false unless the passed [referable] represents a callable object.
/// Allows constructors but does not require them.
bool _requireCallable(CommentReferable referable) =>
referable is ModelElement && referable.isCallable;
/// Return false unless the passed [referable] represents a constructor.
bool _requireConstructor(CommentReferable referable) =>
referable is Constructor;
/// Implements _getMatchingLinkElement via [CommentReferable.referenceBy].
MatchingLinkResult _getMatchingLinkElementCommentReferable(
String codeRef, Warnable warnable) {
var commentReference =
warnable.commentRefs[codeRef] ?? ModelCommentReference.synthetic(codeRef);
bool Function(CommentReferable) filter;
bool Function(CommentReferable) allowTree;
// Constructor references are pretty ambiguous by nature since they can be
// declared with the same name as the class they are constructing, and even
// if they don't use field-formal parameters, sometimes have parameters
// named the same as members.
// Maybe clean this up with inspiration from constructor tear-off syntax?
if (commentReference.allowUnnamedConstructor) {
// Neither reject, nor require, a default constructor in the event
// the comment reference structure implies one. (We can not require it
// in case a library name is the same as a member class name and the class
// is the intended lookup). For example, [FooClass.FooClass] structurally
// "looks like" a default constructor, so we should allow it here.
filter = commentReference.hasCallableHint ? _requireCallable : null;
} else if (commentReference.hasConstructorHint &&
commentReference.hasCallableHint) {
// This takes precedence over the callable hint if both are present --
// pick a constructor if and only constructor if we see `new`.
filter = _requireConstructor;
} else if (commentReference.hasCallableHint) {
// Trailing parens indicate we are looking for a callable.
filter = _requireCallable;
} else {
// Without hints, reject unnamed constructors and their parameters to force
// resolution to the class.
filter = _rejectUnnamedAndShadowingConstructors;
if (!commentReference.allowUnnamedConstructorParameter) {
allowTree = _rejectUnnamedAndShadowingConstructors;
}
}
var lookupResult = warnable.referenceBy(commentReference.referenceBy,
allowTree: allowTree, filter: filter);
// TODO(jcollins-g): Consider prioritizing analyzer resolution before custom.
return MatchingLinkResult(lookupResult);
}
md.Node _makeLinkNode(String codeRef, Warnable warnable) {
var result = getMatchingLinkElement(warnable, codeRef);
var textContent = _htmlEscape.convert(codeRef);
var linkedElement = result.commentReferable;
if (linkedElement != null) {
if (linkedElement.href != null) {
var anchor = md.Element.text('a', textContent);
if (linkedElement is ModelElement && linkedElement.isDeprecated) {
anchor.attributes['class'] = 'deprecated';
}
anchor.attributes['href'] = linkedElement.href;
return anchor;
}
// else this would be linkedElement.linkedName, but link bodies are slightly
// different for doc references, so fall out.
} else {
if (result.warn) {
// Avoid claiming documentation is inherited when it comes from the
// current element.
warnable.warn(PackageWarning.unresolvedDocReference,
message: codeRef,
referredFrom: warnable.documentationIsLocal
? null
: warnable.documentationFrom);
}
}
return md.Element.text('code', textContent);
}
@visibleForTesting
MatchingLinkResult getMatchingLinkElement(Warnable warnable, String codeRef) {
var result = _getMatchingLinkElementCommentReferable(codeRef, warnable);
markdownStats.totalReferences++;
if (result.commentReferable != null) markdownStats.resolvedReferences++;
return result;
}
// Maximum number of characters to display before a suspected generic.
const maxPriorContext = 20;
// Maximum number of characters to display after the beginning of a suspected generic.
const maxPostContext = 30;
final RegExp allBeforeFirstNewline = RegExp(r'^.*\n', multiLine: true);
final RegExp allAfterLastNewline = RegExp(r'\n.*$', multiLine: true);
// Generics should be wrapped into `[]` blocks, to avoid handling them as HTML tags
// (like, [Apple<int>]). @Hixie asked for a warning when there's something, that looks
// like a non HTML tag (a generic?) outside of a `[]` block.
// https://github.com/dart-lang/dartdoc/issues/1250#issuecomment-269257942
void showWarningsForGenericsOutsideSquareBracketsBlocks(
String text, Warnable element) {
// Skip this if not warned for performance and for dart-lang/sdk#46419.
if (element.config.packageWarningOptions
.warningModes[PackageWarning.typeAsHtml] !=
PackageWarningMode.ignore) {
for (var position in findFreeHangingGenericsPositions(text)) {
var priorContext =
text.substring(max(position - maxPriorContext, 0), position);
var postContext =
text.substring(position, min(position + maxPostContext, text.length));
priorContext = priorContext.replaceAll(allBeforeFirstNewline, '');
postContext = postContext.replaceAll(allAfterLastNewline, '');
var errorMessage = '$priorContext$postContext';
// TODO(jcollins-g): allow for more specific error location inside comments
element.warn(PackageWarning.typeAsHtml, message: errorMessage);
}
}
}
Iterable<int> findFreeHangingGenericsPositions(String string) sync* {
var currentPosition = 0;
var squareBracketsDepth = 0;
while (true) {
final nextOpenBracket = string.indexOf('[', currentPosition);
final nextCloseBracket = string.indexOf(']', currentPosition);
final nextNonHTMLTag = string.indexOf(_nonHTML, currentPosition);
final nextPositions = [nextOpenBracket, nextCloseBracket, nextNonHTMLTag]
.where((p) => p != -1);
if (nextPositions.isEmpty) {
break;
}
currentPosition = nextPositions.reduce(min);
if (nextOpenBracket == currentPosition) {
squareBracketsDepth += 1;
} else if (nextCloseBracket == currentPosition) {
squareBracketsDepth = max(squareBracketsDepth - 1, 0);
} else if (nextNonHTMLTag == currentPosition) {
if (squareBracketsDepth == 0) {
yield currentPosition;
}
}
currentPosition++;
}
}
class MarkdownDocument extends md.Document {
factory MarkdownDocument.withElementLinkResolver(Canonicalization element) {
md.Node /*?*/ linkResolver(String name, [String /*?*/ _]) {
if (name.isEmpty) {
return null;
}
return _makeLinkNode(name, element);
}
return MarkdownDocument(
inlineSyntaxes: _markdownSyntaxes,
blockSyntaxes: _markdownBlockSyntaxes,
linkResolver: linkResolver);
}
MarkdownDocument(
{Iterable<md.BlockSyntax> blockSyntaxes,
Iterable<md.InlineSyntax> inlineSyntaxes,
md.ExtensionSet extensionSet,
md.Resolver linkResolver,
md.Resolver imageLinkResolver})
: super(
blockSyntaxes: blockSyntaxes,
inlineSyntaxes: inlineSyntaxes,
extensionSet: extensionSet,
linkResolver: linkResolver,
imageLinkResolver: imageLinkResolver);
/// Parses markdown text, collecting the first [md.Node] or all of them
/// if [processFullText] is `true`. If more than one node is present,
/// then [DocumentationParseResult.hasExtendedDocs] will be set to `true`.
DocumentationParseResult parseMarkdownText(
String text, bool processFullText) {
var hasExtendedContent = false;
var lines = LineSplitter.split(text).toList();
md.Node firstNode;
var nodes = <md.Node>[];
for (var node in _IterableBlockParser(lines, this).parseLinesGenerator()) {
if (firstNode != null) {
hasExtendedContent = true;
if (!processFullText) break;
}
firstNode ??= node;
nodes.add(node);
}
_parseInlineContent(nodes);
return DocumentationParseResult(nodes, hasExtendedContent);
}
// From package:markdown/src/document.dart
// TODO(jcollins-g): consider making this a public method in markdown package
void _parseInlineContent(List<md.Node> nodes) {
for (var i = 0; i < nodes.length; i++) {
var node = nodes[i];
if (node is md.UnparsedContent) {
var inlineNodes = md.InlineParser(node.textContent, this).parse();
nodes.removeAt(i);
nodes.insertAll(i, inlineNodes);
i += inlineNodes.length - 1;
} else if (node is md.Element && node.children != null) {
_parseInlineContent(node.children);
}
}
}
}
class DocumentationParseResult {
static const empty = DocumentationParseResult([], false);
final List<md.Node> nodes;
final bool hasExtendedDocs;
const DocumentationParseResult(this.nodes, this.hasExtendedDocs);
}
class _InlineCodeSyntax extends md.InlineSyntax {
_InlineCodeSyntax() : super(r'\[:\s?((?:.|\n)*?)\s?:\]');
@override
bool onMatch(md.InlineParser parser, Match match) {
var element = md.Element.text('code', _htmlEscape.convert(match[1] /*!*/));
parser.addNode(element);
return true;
}
}
class _AutolinkWithoutScheme extends md.AutolinkSyntax {
@override
bool onMatch(md.InlineParser parser, Match match) {
var url = match[1] /*!*/;
var text = _htmlEscape.convert(url).replaceFirst(_hideSchemes, '');
var anchor = md.Element.text('a', text);
anchor.attributes['href'] = url;
parser.addNode(anchor);
return true;
}
}