blob: 19bf3d54f650482214600d03b19783967e777a1c [file] [log] [blame]
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/error/error.dart';
import 'package:analyzer/error/listener.dart';
import 'package:analyzer/src/generated/parser.dart';
import 'package:analyzer/dart/ast/token.dart';
import 'package:analyzer/src/dart/ast/token.dart' hide SimpleToken;
import 'package:analyzer/src/dart/scanner/reader.dart';
import 'package:analyzer/src/dart/scanner/scanner.dart';
import 'package:analyzer/src/generated/source.dart';
import 'package:angular_ast/angular_ast.dart';
import 'package:angular_analyzer_plugin/ast.dart';
import 'package:angular_analyzer_plugin/src/ng_expr_parser.dart';
import 'package:angular_analyzer_plugin/src/ignoring_error_listener.dart';
import 'package:angular_analyzer_plugin/src/strings.dart';
import 'package:angular_analyzer_plugin/tasks.dart';
import 'package:meta/meta.dart';
class HtmlTreeConverter {
final EmbeddedDartParser dartParser;
final Source templateSource;
final AnalysisErrorListener errorListener;
// Following Angular2 Logic:
// https://github.com/dart-lang/angular2/blob/8220ba3a693aff51eed33cd1ec9542bde9017423/lib/src/compiler/schema/dom_element_schema_registry.dart#L199
static const attrToPropMap = const {
'class': 'className',
'innerHtml': 'innerHTML',
'readonly': 'readOnly',
'tabindex': 'tabIndex',
};
HtmlTreeConverter(this.dartParser, this.templateSource, this.errorListener);
DocumentInfo convertFromAstList(List<StandaloneTemplateAst> asts) {
DocumentInfo root = new DocumentInfo();
if (asts.isEmpty) {
root.childNodes.add(new TextInfo(0, '', root, []));
}
for (StandaloneTemplateAst node in asts) {
var convertedNode = convert(node, parent: root);
if (convertedNode != null) {
root.childNodes.add(convertedNode);
}
}
return root;
}
NodeInfo convert(
StandaloneTemplateAst node, {
@required ElementInfo parent,
}) {
if (node is ElementAst) {
String localName = node.name;
List<AttributeInfo> attributes = _convertAttributes(
attributes: node.attributes,
bananas: node.bananas,
events: node.events,
properties: node.properties,
references: node.references,
stars: node.stars,
);
attributes.sort((a, b) => a.offset.compareTo(b.offset));
final closeComponent = node.closeComplement;
SourceRange openingSpan;
SourceRange openingNameSpan;
SourceRange closingSpan;
SourceRange closingNameSpan;
if (node.isSynthetic) {
openingSpan = _toSourceRange(closeComponent.beginToken.offset, 0);
openingNameSpan = openingSpan;
} else {
openingSpan = _toSourceRange(
node.beginToken.offset, node.endToken.end - node.beginToken.offset);
openingNameSpan = new SourceRange(
(node as ParsedElementAst).identifierToken.offset,
(node as ParsedElementAst).identifierToken.lexeme.length);
}
// Check for void element cases (has closing complement)
// If closeComponent is synthetic, handle it after child nodes are found.
if (closeComponent != null && !closeComponent.isSynthetic) {
closingSpan = _toSourceRange(closeComponent.beginToken.offset,
closeComponent.endToken.end - closeComponent.beginToken.offset);
closingNameSpan =
new SourceRange(closingSpan.offset + '</'.length, localName.length);
}
ElementInfo element = new ElementInfo(
localName,
openingSpan,
closingSpan,
openingNameSpan,
closingNameSpan,
false,
attributes,
findTemplateAttribute(attributes),
parent);
for (AttributeInfo attribute in attributes) {
attribute.parent = element;
}
List<NodeInfo> children = _convertChildren(node, element);
element.childNodes.addAll(children);
if (!element.isSynthetic &&
element.openingSpanIsClosed &&
closingSpan != null &&
(openingSpan.offset + openingSpan.length) == closingSpan.offset) {
element.childNodes.add(new TextInfo(
openingSpan.offset + openingSpan.length, '', element, [],
synthetic: true));
}
return element;
}
if (node is EmbeddedContentAst) {
final localName = 'ng-content';
final attributes = <AttributeInfo>[];
final closeComplement = node.closeComplement;
SourceRange openingSpan;
SourceRange openingNameSpan;
SourceRange closingSpan;
SourceRange closingNameSpan;
if (node.isSynthetic) {
openingSpan = _toSourceRange(closeComplement.beginToken.offset, 0);
openingNameSpan = openingSpan;
} else {
openingSpan = _toSourceRange(
node.beginToken.offset, node.endToken.end - node.beginToken.offset);
openingNameSpan =
new SourceRange(openingSpan.offset + '<'.length, localName.length);
var pnode = node as ParsedEmbeddedContentAst;
var valueToken = pnode.selectorValueToken;
if (pnode.selectToken != null) {
attributes.add(new TextAttribute(
'select',
pnode.selectToken.offset,
valueToken?.innerValue?.lexeme,
valueToken?.innerValue?.offset,
[],
));
}
}
if (closeComplement.isSynthetic) {
closingSpan = _toSourceRange(node.endToken.end, 0);
closingNameSpan = closingSpan;
} else {
closingSpan = _toSourceRange(closeComplement.beginToken.offset,
closeComplement.endToken.end - closeComplement.beginToken.offset);
closingNameSpan =
new SourceRange(closingSpan.offset + '</'.length, localName.length);
}
var ngContent = new ElementInfo(localName, openingSpan, closingSpan,
openingNameSpan, closingNameSpan, false, attributes, null, parent);
for (AttributeInfo attribute in attributes) {
attribute.parent = ngContent;
}
return ngContent;
}
if (node is EmbeddedTemplateAst) {
String localName = 'template';
List<AttributeInfo> attributes = _convertAttributes(
attributes: node.attributes,
events: node.events,
properties: node.properties,
references: node.references,
);
final closeComponent = node.closeComplement;
SourceRange openingSpan;
SourceRange openingNameSpan;
SourceRange closingSpan;
SourceRange closingNameSpan;
if (node.isSynthetic) {
openingSpan = _toSourceRange(closeComponent.beginToken.offset, 0);
openingNameSpan = openingSpan;
} else {
openingSpan = _toSourceRange(
node.beginToken.offset, node.endToken.end - node.beginToken.offset);
openingNameSpan =
new SourceRange(openingSpan.offset + '<'.length, localName.length);
}
// Check for void element cases (has closing complement)
if (closeComponent != null) {
if (closeComponent.isSynthetic) {
closingSpan = _toSourceRange(node.endToken.end, 0);
closingNameSpan = closingSpan;
} else {
closingSpan = _toSourceRange(closeComponent.beginToken.offset,
closeComponent.endToken.end - closeComponent.beginToken.offset);
closingNameSpan = new SourceRange(
closingSpan.offset + '</'.length, localName.length);
}
}
ElementInfo element = new ElementInfo(
localName,
openingSpan,
closingSpan,
openingNameSpan,
closingNameSpan,
true,
attributes,
findTemplateAttribute(attributes),
parent);
for (AttributeInfo attribute in attributes) {
attribute.parent = element;
}
List<NodeInfo> children = _convertChildren(node, element);
element.childNodes.addAll(children);
if (!element.isSynthetic &&
element.openingSpanIsClosed &&
closingSpan != null &&
(openingSpan.offset + openingSpan.length) == closingSpan.offset) {
element.childNodes.add(new TextInfo(
openingSpan.offset + openingSpan.length, '', element, [],
synthetic: true));
}
return element;
}
if (node is TextAst) {
int offset = node.sourceSpan.start.offset;
String text = node.value;
return new TextInfo(
offset, text, parent, dartParser.findMustaches(text, offset));
}
if (node is InterpolationAst) {
int offset = node.sourceSpan.start.offset;
String text = '{{' + node.value + '}}';
return new TextInfo(
offset, text, parent, dartParser.findMustaches(text, offset));
}
return null;
}
List<AttributeInfo> _convertAttributes({
List<ParsedAttributeAst> attributes: const [],
List<ParsedBananaAst> bananas: const [],
List<ParsedEventAst> events: const [],
List<ParsedPropertyAst> properties: const [],
List<ParsedReferenceAst> references: const [],
List<ParsedStarAst> stars: const [],
}) {
List<AttributeInfo> returnAttributes = <AttributeInfo>[];
for (ParsedAttributeAst attribute in attributes) {
if (attribute.name == 'template') {
returnAttributes.add(_convertTemplateAttribute(attribute));
} else {
String value;
int valueOffset;
if (attribute.valueToken != null) {
value = attribute.valueToken.innerValue.lexeme;
valueOffset = attribute.valueToken.innerValue.offset;
}
returnAttributes.add(new TextAttribute(
attribute.name,
attribute.nameOffset,
value,
valueOffset,
dartParser.findMustaches(value, valueOffset),
));
}
}
bananas.map(_convertExpressionBoundAttribute).forEach(returnAttributes.add);
events.map(_convertStatementsBoundAttribute).forEach(returnAttributes.add);
properties
.map(_convertExpressionBoundAttribute)
.forEach(returnAttributes.add);
for (ParsedReferenceAst reference in references) {
String value;
int valueOffset;
if (reference.valueToken != null) {
value = reference.valueToken.innerValue.lexeme;
valueOffset = reference.valueToken.innerValue.offset;
}
returnAttributes.add(new TextAttribute(
reference.prefixToken.lexeme + reference.nameToken.lexeme,
reference.prefixToken.offset,
value,
valueOffset,
dartParser.findMustaches(value, valueOffset)));
}
stars.map(_convertTemplateAttribute).forEach(returnAttributes.add);
return returnAttributes;
}
TemplateAttribute _convertTemplateAttribute(TemplateAst ast) {
String name;
int nameOffset;
String value;
int valueOffset;
String origName;
int origNameOffset;
var virtualAttributes = [];
if (ast is ParsedStarAst) {
value = ast.value;
valueOffset = ast.valueOffset;
origName = ast.prefixToken.lexeme + ast.nameToken.lexeme;
origNameOffset = ast.prefixToken.offset;
name = ast.nameToken.lexeme;
nameOffset = ast.nameToken.offset;
String fullAstName;
if (value != null) {
fullAstName = ast.name +
(' ' * (ast.valueToken.innerValue.offset - ast.nameToken.end)) +
(value ?? '');
} else {
fullAstName = ast.name + ' ';
}
virtualAttributes =
dartParser.parseTemplateVirtualAttributes(nameOffset, fullAstName);
}
if (ast is ParsedAttributeAst) {
value = ast.value;
valueOffset = ast.valueOffset;
origName = ast.name;
origNameOffset = ast.nameOffset;
name = origName;
nameOffset = origNameOffset;
if (value == null || value.isEmpty) {
errorListener.onError(new AnalysisError(templateSource, origNameOffset,
origName.length, AngularWarningCode.EMPTY_BINDING, [origName]));
} else {
virtualAttributes =
dartParser.parseTemplateVirtualAttributes(valueOffset, value);
}
}
TemplateAttribute templateAttribute = new TemplateAttribute(
name,
nameOffset,
value,
valueOffset,
origName,
origNameOffset,
virtualAttributes);
for (AttributeInfo virtualAttribute in virtualAttributes) {
virtualAttribute.parent = templateAttribute;
}
return templateAttribute;
}
StatementsBoundAttribute _convertStatementsBoundAttribute(
ParsedEventAst ast) {
var prefixComponent =
(ast.prefixToken.errorSynthetic ? '' : ast.prefixToken.lexeme);
var suffixComponent =
((ast.suffixToken == null) || ast.suffixToken.errorSynthetic)
? ''
: ast.suffixToken.lexeme;
var origName = prefixComponent + ast.name + suffixComponent;
var origNameOffset = ast.prefixToken.offset;
var value = ast.value;
if ((value == null || value.isEmpty) &&
!ast.prefixToken.errorSynthetic &&
!ast.suffixToken.errorSynthetic) {
errorListener.onError(new AnalysisError(templateSource, origNameOffset,
origName.length, AngularWarningCode.EMPTY_BINDING, [ast.name]));
}
var valueOffset = ast.valueOffset;
var propName = ast.nameToken.lexeme;
var propNameOffset = ast.nameToken.offset;
return new StatementsBoundAttribute(
propName,
propNameOffset,
value,
valueOffset,
origName,
origNameOffset,
dartParser.parseDartStatements(valueOffset, value));
}
ExpressionBoundAttribute _convertExpressionBoundAttribute(TemplateAst ast) {
// Default starting.
ExpressionBoundType bound = ExpressionBoundType.input;
var parsed = ast as ParsedDecoratorAst;
var origName =
(parsed.prefixToken.errorSynthetic ? '' : parsed.prefixToken.lexeme) +
parsed.nameToken.lexeme +
((parsed.suffixToken == null || parsed.suffixToken.errorSynthetic)
? ''
: parsed.suffixToken.lexeme);
var origNameOffset = parsed.prefixToken.offset;
var propName = parsed.nameToken.lexeme;
var propNameOffset = parsed.nameToken.offset;
if (ast is ParsedPropertyAst) {
var name = ast.name;
if (ast.postfix != null) {
bool replacePropName = false;
if (name == 'class') {
bound = ExpressionBoundType.clazz;
replacePropName = true;
} else if (name == 'attr') {
bound = ExpressionBoundType.attr;
replacePropName = true;
} else if (name == 'style') {
bound = ExpressionBoundType.style;
replacePropName = true;
}
if (replacePropName) {
propName = ast.postfix + (ast.unit == null ? '' : '.${ast.unit}');
propNameOffset = parsed.nameToken.offset + name.length + '.'.length;
}
}
} else {
bound = ExpressionBoundType.twoWay;
}
var value = parsed.valueToken?.innerValue?.lexeme;
if ((value == null || value.isEmpty) &&
!parsed.prefixToken.errorSynthetic &&
!parsed.suffixToken.errorSynthetic) {
errorListener.onError(new AnalysisError(
templateSource,
origNameOffset,
origName.length,
AngularWarningCode.EMPTY_BINDING,
[origName],
));
}
var valueOffset = parsed.valueToken?.innerValue?.offset;
propName = attrToPropMap[propName] ?? propName;
return new ExpressionBoundAttribute(
propName,
propNameOffset,
value,
valueOffset,
origName,
origNameOffset,
dartParser.parseDartExpression(valueOffset, value, true),
bound);
}
List<NodeInfo> _convertChildren(
StandaloneTemplateAst node, ElementInfo parent) {
List<NodeInfo> children = <NodeInfo>[];
for (StandaloneTemplateAst child in node.childNodes) {
NodeInfo childNode = convert(child, parent: parent);
if (childNode != null) {
children.add(childNode);
if (childNode is ElementInfo) {
parent.childNodesMaxEnd = childNode.childNodesMaxEnd;
} else {
parent.childNodesMaxEnd = childNode.offset + childNode.length;
}
}
}
return children;
}
TemplateAttribute findTemplateAttribute(List<AttributeInfo> attributes) {
for (AttributeInfo attribute in attributes) {
if (attribute is TemplateAttribute) {
return attribute;
}
}
return null;
}
SourceRange _toSourceRange(int offset, int length) {
return new SourceRange(offset, length);
}
}
class EmbeddedDartParser {
final Source templateSource;
final AnalysisErrorListener errorListener;
final ErrorReporter errorReporter;
EmbeddedDartParser(
this.templateSource, this.errorListener, this.errorReporter);
/**
* Parse the given Dart [code] that starts at [offset].
*/
Expression parseDartExpression(int offset, String code, bool detectTrailing) {
if (code == null) {
return null;
}
final Token token = _scanDartCode(offset, code);
Expression expression;
// suppress errors for this. But still parse it so we can analyze it and stuff
if (code.trim().isEmpty) {
expression = _parseDartExpressionAtToken(token,
errorListener: new IgnoringAnalysisErrorListener());
} else {
expression = _parseDartExpressionAtToken(token);
}
if (detectTrailing && expression.endToken.next.type != TokenType.EOF) {
int trailingExpressionBegin = expression.endToken.next.offset;
errorListener.onError(new AnalysisError(
templateSource,
trailingExpressionBegin,
offset + code.length - trailingExpressionBegin,
AngularWarningCode.TRAILING_EXPRESSION));
}
return expression;
}
/**
* Parse the given Dart [code] that starts ot [offset].
* Also removes and reports dangling closing brackets.
*/
List<Statement> parseDartStatements(int offset, String code) {
List<Statement> allStatements = new List<Statement>();
if (code == null) {
return allStatements;
}
code = code + ';';
Token token = _scanDartCode(offset, code);
while (token.type != TokenType.EOF) {
List<Statement> currentStatements = _parseDartStatementsAtToken(token);
if (currentStatements.isNotEmpty) {
allStatements.addAll(currentStatements);
token = currentStatements.last.endToken.next;
}
if (token.type == TokenType.EOF) {
break;
}
if (token.type == TokenType.CLOSE_CURLY_BRACKET) {
int startCloseBracket = token.offset;
while (token.type == TokenType.CLOSE_CURLY_BRACKET) {
token = token.next;
}
int length = token.offset - startCloseBracket;
errorListener.onError(new AnalysisError(
templateSource,
startCloseBracket,
length,
ParserErrorCode.UNEXPECTED_TOKEN,
["}"]));
continue;
} else {
//Nothing should trigger here, but just in case to prevent infinite loop
token = token.next;
}
}
return allStatements;
}
/**
* Parse the Dart expression starting at the given [token].
*/
Expression _parseDartExpressionAtToken(Token token,
{AnalysisErrorListener errorListener}) {
errorListener ??= this.errorListener;
Parser parser = new NgExprParser(templateSource, errorListener);
return parser.parseExpression(token);
}
/**
* Parse the Dart statement starting at the given [token].
*/
List<Statement> _parseDartStatementsAtToken(Token token) {
Parser parser = new Parser(templateSource, errorListener);
return parser.parseStatements(token);
}
/**
* Scan the given Dart [code] that starts at [offset].
*/
Token _scanDartCode(int offset, String code) {
String text = ' ' * offset + code;
CharSequenceReader reader = new CharSequenceReader(text);
Scanner scanner = new Scanner(templateSource, reader, errorListener);
return scanner.tokenize();
}
/**
* Scan the given [text] staring at the given [offset] and resolve all of
* its embedded expressions.
*/
List<Mustache> findMustaches(String text, int fileOffset) {
List<Mustache> mustaches = <Mustache>[];
if (text == null || text.length < 2) {
return mustaches;
}
int textOffset = 0;
while (true) {
// begin
final int begin = text.indexOf('{{', textOffset);
final int nextBegin = text.indexOf('{{', begin + 2);
final int end = text.indexOf('}}', textOffset);
int exprBegin, exprEnd;
bool detectTrailing = false;
// Absolutely no mustaches - simple text.
if (begin == -1 && end == -1) {
break;
}
if (end == -1) {
// Begin mustache exists, but no end mustache.
errorListener.onError(new AnalysisError(templateSource,
fileOffset + begin, 2, AngularWarningCode.UNTERMINATED_MUSTACHE));
// Move the cursor ahead and keep looking for more unmatched mustaches.
textOffset = begin + 2;
exprBegin = textOffset;
exprEnd = _startsWithWhitespace(text.substring(exprBegin))
? exprBegin
: text.length;
} else if (begin == -1 || end < begin) {
// Both exists, but there is an end before a begin.
// Example: blah }} {{ mustache ...
errorListener.onError(new AnalysisError(templateSource,
fileOffset + end, 2, AngularWarningCode.UNOPENED_MUSTACHE));
// Move the cursor ahead and keep looking for more unmatched mustaches.
textOffset = end + 2;
continue;
} else if (nextBegin != -1 && nextBegin < end) {
// Two open mustaches, but both opens are in sequence before an end.
// Example: {{ blah {{ mustache }}
errorListener.onError(new AnalysisError(templateSource,
fileOffset + begin, 2, AngularWarningCode.UNTERMINATED_MUSTACHE));
// Skip this open mustache, check the next open we found
textOffset = begin + 2;
exprBegin = textOffset;
exprEnd = nextBegin;
} else {
// Proper open and close mustache exists and in correct order.
exprBegin = begin + 2;
exprEnd = end;
textOffset = end + 2;
detectTrailing = true;
}
// resolve
String code = text.substring(exprBegin, exprEnd);
Expression expression =
parseDartExpression(fileOffset + exprBegin, code, detectTrailing);
var offset = fileOffset + begin;
var length;
if (end == -1) {
length = expression.offset + expression.length - offset;
} else {
length = end + 2 - begin;
}
mustaches.add(new Mustache(
offset,
length,
expression,
fileOffset + exprBegin,
fileOffset + exprEnd,
));
}
return mustaches;
}
bool _startsWithWhitespace(String string) {
// trim returns the original string when no changes were made
return !identical(string.trimLeft(), string);
}
/**
* Desugar a template="" or *blah="" attribute into its list of virtual [AttributeInfo]s
*/
List<AttributeInfo> parseTemplateVirtualAttributes(int offset, String code) {
List<AttributeInfo> attributes = <AttributeInfo>[];
Token token = _scanDartCode(offset, code);
String prefix = null;
while (token.type != TokenType.EOF) {
// skip optional comma or semicolons
if (_isDelimiter(token)) {
token = token.next;
continue;
}
// maybe a local variable
if (_isTemplateVarBeginToken(token)) {
if (token.type == TokenType.HASH) {
errorReporter.reportErrorForToken(
AngularWarningCode.UNEXPECTED_HASH_IN_TEMPLATE, token);
}
int originalVarOffset = token.offset;
String originalName = token.lexeme;
token = token.next;
// get the local variable name
String localVarName = "";
int localVarOffset = token.offset;
if (!_tokenMatchesIdentifier(token)) {
errorReporter.reportErrorForToken(
AngularWarningCode.EXPECTED_IDENTIFIER, token);
} else {
localVarOffset = token.offset;
localVarName = token.lexeme;
originalName +=
' ' * (token.offset - originalVarOffset) + localVarName;
token = token.next;
}
// get an optional internal variable
int internalVarOffset = null;
String internalVarName = null;
if (token.type == TokenType.EQ) {
token = token.next;
// get the internal variable
if (!_tokenMatchesIdentifier(token)) {
errorReporter.reportErrorForToken(
AngularWarningCode.EXPECTED_IDENTIFIER, token);
break;
}
internalVarOffset = token.offset;
internalVarName = token.lexeme;
token = token.next;
}
// declare the local variable
// Note the care that the varname's offset is preserved in place.
attributes.add(new TextAttribute.synthetic(
'let-$localVarName',
localVarOffset - 'let-'.length,
internalVarName,
internalVarOffset,
originalName,
originalVarOffset, []));
continue;
}
// key
int keyOffset = token.offset;
String originalName = null;
int originalNameOffset = keyOffset;
String key = null;
if (_tokenMatchesIdentifier(token)) {
// scan for a full attribute name
key = '';
int lastEnd = token.offset;
while (token.offset == lastEnd &&
(_tokenMatchesIdentifier(token) || token.type == TokenType.MINUS)) {
key += token.lexeme;
lastEnd = token.end;
token = token.next;
}
originalName = key;
// add the prefix
if (prefix == null) {
prefix = key;
} else {
key = prefix + capitalize(key);
}
} else {
errorReporter.reportErrorForToken(
AngularWarningCode.EXPECTED_IDENTIFIER, token);
break;
}
// skip optional ':' or '='
if (token.type == TokenType.COLON || token.type == TokenType.EQ) {
token = token.next;
}
// expression
if (!_isTemplateVarBeginToken(token) &&
!_isDelimiter(token) &&
token.type != TokenType.EOF) {
Expression expression = _parseDartExpressionAtToken(token);
var start = token.offset - offset;
token = expression.endToken.next;
var end = token.offset - offset;
var exprCode = code.substring(start, end);
attributes.add(new ExpressionBoundAttribute(
key,
keyOffset,
exprCode,
token.offset,
originalName,
originalNameOffset,
expression,
ExpressionBoundType.input));
} else {
attributes.add(new TextAttribute.synthetic(
key, keyOffset, null, null, originalName, originalNameOffset, []));
}
}
return attributes;
}
static bool _isDelimiter(Token token) =>
token.type == TokenType.COMMA || token.type == TokenType.SEMICOLON;
static bool _isTemplateVarBeginToken(Token token) {
return token is KeywordToken && token.keyword == Keyword.VAR ||
(token.type == TokenType.IDENTIFIER && token.lexeme == 'let') ||
token.type == TokenType.HASH;
}
static bool _tokenMatchesBuiltInIdentifier(Token token) =>
token is KeywordToken && token.keyword.isBuiltInOrPseudo;
static bool _tokenMatchesIdentifier(Token token) =>
token.type == TokenType.IDENTIFIER ||
_tokenMatchesBuiltInIdentifier(token);
}
class IgnorableHtmlInternalError extends StateError {
IgnorableHtmlInternalError(String msg) : super(msg);
}