Files
sdk/pkg/analyzer_plugin/tool/spec/from_html.dart
T
Sam Rawlins 49a3fb4f96 [analyzer] Separate the HTML parser utility from the DOM
With the parser code in the same library as the DOM classes, the
`pkg/analyzer/tool/messages/generate.dart` program, which generates all
of the diagnostic classes and diagnostics, _depends_ on a library
with a `parse` method which depends on these diagnostic classes (in
order to report errors while parsing HTML). This means that if
there is any existing error (like an unknown identifier) in the
existing error codes, it is impossible to generate the error codes.

Since we don't need the `parse` method to generate diagnostics, we
split up the library and remove the indirect dependency.

Separating the parser code out removes the loop.

Change-Id: Ifb9e9fd979e341ae64a1ed49aaf3758d3dcf1dee
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/287220
Reviewed-by: Brian Wilkerson <brianwilkerson@google.com>
Commit-Queue: Samuel Rawlins <srawlins@google.com>
2023-03-08 03:06:19 +00:00

575 lines
19 KiB
Dart

// Copyright (c) 2014, 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.
/// Code for reading an HTML API description.
import 'dart:io';
import 'package:analyzer_utilities/html_dom.dart' as dom;
import 'package:analyzer_utilities/html_generator.dart';
import 'package:analyzer_utilities/html_parser.dart' as parser;
import 'package:path/path.dart';
import 'api.dart';
/// Read the API description from the file 'plugin_spec.html'. [pkgPath] is the
/// path to the current package.
Api readApi(String pkgPath) {
var reader = ApiReader(join(pkgPath, 'tool', 'spec', 'plugin_spec.html'));
return reader.readApi();
}
typedef ElementProcessor = void Function(dom.Element element);
typedef TextProcessor = void Function(dom.Text text);
class ApiReader {
static const List<String> specialElements = [
'domain',
'feedback',
'object',
'refactorings',
'refactoring',
'type',
'types',
'request',
'notification',
'params',
'result',
'field',
'list',
'map',
'enum',
'key',
'value',
'options',
'ref',
'code',
'version',
'union',
'index',
'include'
];
/// The absolute and normalized path to the file being read.
final String filePath;
/// Initialize a newly created API reader to read from the file with the given
/// [filePath].
ApiReader(this.filePath);
/// Create an [Api] object from an HTML representation such as:
///
/// <html>
/// ...
/// <body>
/// ... <version>1.0</version> ...
/// <domain name="...">...</domain> <!-- zero or more -->
/// <types>...</types>
/// <refactorings>...</refactorings>
/// </body>
/// </html>
///
/// Child elements of <api> can occur in any order.
Api apiFromHtml(dom.Element html) {
Api api;
var versions = <String>[];
var domains = <Domain>[];
Types? types;
Refactorings? refactorings;
recurse(html, 'api', {
'domain': (dom.Element element) {
domains.add(domainFromHtml(element));
},
'refactorings': (dom.Element element) {
refactorings = refactoringsFromHtml(element);
},
'types': (dom.Element element) {
types = typesFromHtml(element);
},
'version': (dom.Element element) {
versions.add(innerText(element));
},
'index': (dom.Element element) {
/* Ignore; generated dynamically. */
}
});
if (versions.length != 1) {
throw Exception('The API must contain exactly one <version> element');
}
api = Api(versions[0], domains, types!, refactorings, html);
return api;
}
/// Check that the given [element] has all of the attributes in
/// [requiredAttributes], possibly some of the attributes in
/// [optionalAttributes], and no others.
void checkAttributes(
dom.Element element, List<String> requiredAttributes, String context,
{List<String> optionalAttributes = const []}) {
var attributesFound = <String>{};
element.attributes.forEach((name, value) {
if (!requiredAttributes.contains(name) &&
!optionalAttributes.contains(name)) {
throw Exception(
'$context: Unexpected attribute in ${element.name}: $name');
}
attributesFound.add(name);
});
for (var expectedAttribute in requiredAttributes) {
if (!attributesFound.contains(expectedAttribute)) {
throw Exception(
'$context: ${element.name} must contain attribute $expectedAttribute');
}
}
}
/// Check that the given [element] has the given [expectedName].
void checkName(dom.Element element, String expectedName, [String? context]) {
if (element.name != expectedName) {
context ??= element.name;
throw Exception(
'$context: Expected $expectedName, found ${element.name}');
}
}
/// Create a [Domain] object from an HTML representation such as:
///
/// <domain name="domainName">
/// <request method="...">...</request> <!-- zero or more -->
/// <notification event="...">...</notification> <!-- zero or more -->
/// </domain>
///
/// Child elements can occur in any order.
Domain domainFromHtml(dom.Element html) {
checkName(html, 'domain');
var name = html.attributes['name'];
var context = name ?? 'domain';
var experimental = html.attributes['experimental'] == 'true';
checkAttributes(html, ['name'], context,
optionalAttributes: ['experimental']);
var requests = <Request>[];
var notifications = <Notification>[];
recurse(html, context, {
'request': (dom.Element child) {
requests.add(requestFromHtml(child, context));
},
'notification': (dom.Element child) {
notifications.add(notificationFromHtml(child, context));
}
});
return Domain(name!, requests, notifications, html,
experimental: experimental);
}
dom.Element getAncestor(dom.Element html, String name, String context) {
var ancestor = html.parent;
while (ancestor != null) {
if (ancestor.name == name) {
return ancestor;
}
ancestor = ancestor.parent;
}
throw Exception('$context: <${html.name}> must be nested within <$name>');
}
/// Create a [Notification] object from an HTML representation such as:
///
/// <notification event="methodName">
/// <params>...</params> <!-- optional -->
/// </notification>
///
/// Note that the event name should not include the domain name.
///
/// <params> has the same form as <object>, as described in [typeDeclFromHtml].
///
/// Child elements can occur in any order.
Notification notificationFromHtml(dom.Element html, String context) {
var domainName = getAncestor(html, 'domain', context).attributes['name']!;
checkName(html, 'notification', context);
var event = html.attributes['event'];
context = '$context.${event ?? 'event'}';
checkAttributes(html, ['event'], context);
late TypeObject params;
recurse(html, context, {
'params': (dom.Element child) {
params = typeObjectFromHtml(child, '$context.params');
}
});
return Notification(domainName, event!, params, html);
}
/// Create a single of [TypeDecl] corresponding to the type defined inside the
/// given HTML element.
TypeDecl processContentsAsType(dom.Element html, String context) {
var types = processContentsAsTypes(html, context);
if (types.length != 1) {
throw Exception('$context: Exactly one type must be specified');
}
return types[0];
}
/// Create a list of [TypeDecl]s corresponding to the types defined inside the
/// given HTML element. The following forms are supported.
///
/// To refer to a type declared elsewhere (or a built-in type):
///
/// <ref>typeName</ref>
///
/// For a list: <list>ItemType</list>
///
/// For a map: <map><key>KeyType</key><value>ValueType</value></map>
///
/// For a JSON object:
///
/// <object>
/// <field name="...">...</field> <!-- zero or more -->
/// </object>
///
/// For an enum:
///
/// <enum>
/// <value>...</value> <!-- zero or more -->
/// </enum>
///
/// For a union type:
/// <union>
/// TYPE <!-- zero or more -->
/// </union>
List<TypeDecl> processContentsAsTypes(dom.Element html, String context) {
var types = <TypeDecl>[];
recurse(html, context, {
'object': (dom.Element child) {
types.add(typeObjectFromHtml(child, context));
},
'list': (dom.Element child) {
checkAttributes(child, [], context);
types.add(TypeList(processContentsAsType(child, context), child));
},
'map': (dom.Element child) {
checkAttributes(child, [], context);
TypeDecl? keyType;
TypeDecl? valueType;
recurse(child, context, {
'key': (dom.Element child) {
if (keyType != null) {
throw Exception('$context: Key type already specified');
}
keyType = processContentsAsType(child, '$context.key');
},
'value': (dom.Element child) {
if (valueType != null) {
throw Exception('$context: Value type already specified');
}
valueType = processContentsAsType(child, '$context.value');
}
});
if (keyType == null) {
throw Exception('$context: Key type not specified');
}
if (valueType == null) {
throw Exception('$context: Value type not specified');
}
types.add(TypeMap(keyType as TypeReference, valueType!, child));
},
'enum': (dom.Element child) {
types.add(typeEnumFromHtml(child, context));
},
'ref': (dom.Element child) {
checkAttributes(child, [], context);
types.add(TypeReference(innerText(child), child));
},
'union': (dom.Element child) {
checkAttributes(child, ['field'], context);
var field = child.attributes['field'];
types.add(
TypeUnion(processContentsAsTypes(child, context), field!, child));
}
});
return types;
}
/// Read the API description from file with the given [filePath].
Api readApi() {
var file = File(filePath);
var htmlContents = file.readAsStringSync();
var document = parser.parse(htmlContents, file.uri);
var htmlElement = document.children
.singleWhere((element) => element.name.toLowerCase() == 'html');
return apiFromHtml(htmlElement);
}
void recurse(dom.Element parent, String context,
Map<String, ElementProcessor> elementProcessors) {
for (var key in elementProcessors.keys) {
if (!specialElements.contains(key)) {
throw Exception('$context: $key is not a special element');
}
}
for (var node in parent.nodes) {
if (node is dom.Element) {
if (elementProcessors.containsKey(node.name)) {
elementProcessors[node.name]!(node);
} else if (specialElements.contains(node.name)) {
throw Exception('$context: Unexpected use of <${node.name}>');
} else {
recurse(node, context, elementProcessors);
}
}
}
}
/// Create a [Refactoring] object from an HTML representation such as:
///
/// <refactoring kind="refactoringKind">
/// <feedback>...</feedback> <!-- optional -->
/// <options>...</options> <!-- optional -->
/// </refactoring>
///
/// <feedback> and <options> have the same form as <object>, as described in
/// [typeDeclFromHtml].
///
/// Child elements can occur in any order.
Refactoring refactoringFromHtml(dom.Element html) {
checkName(html, 'refactoring');
var kind = html.attributes['kind'];
var context = kind ?? 'refactoring';
checkAttributes(html, ['kind'], context);
TypeObject? feedback;
TypeObject? options;
recurse(html, context, {
'feedback': (dom.Element child) {
feedback = typeObjectFromHtml(child, '$context.feedback');
},
'options': (dom.Element child) {
options = typeObjectFromHtml(child, '$context.options');
}
});
return Refactoring(kind!, feedback, options, html);
}
/// Create a [Refactorings] object from an HTML representation such as:
///
/// <refactorings>
/// <refactoring kind="...">...</refactoring> <!-- zero or more -->
/// </refactorings>
Refactorings refactoringsFromHtml(dom.Element html) {
checkName(html, 'refactorings');
var context = 'refactorings';
checkAttributes(html, [], context);
var refactorings = <Refactoring>[];
recurse(html, context, {
'refactoring': (dom.Element child) {
refactorings.add(refactoringFromHtml(child));
}
});
return Refactorings(refactorings, html);
}
/// Create a [Request] object from an HTML representation such as:
///
/// <request method="methodName">
/// <params>...</params> <!-- optional -->
/// <result>...</result> <!-- optional -->
/// </request>
///
/// Note that the method name should not include the domain name.
///
/// <params> and <result> have the same form as <object>, as described in
/// [typeDeclFromHtml].
///
/// Child elements can occur in any order.
Request requestFromHtml(dom.Element html, String context) {
var domainName = getAncestor(html, 'domain', context).attributes['name']!;
checkName(html, 'request', context);
var method = html.attributes['method'];
context = '$context.${method ?? 'method'}';
checkAttributes(html, ['method'], context,
optionalAttributes: ['experimental', 'deprecated']);
var experimental = html.attributes['experimental'] == 'true';
var deprecated = html.attributes['deprecated'] == 'true';
TypeObject? params;
TypeObject? result;
recurse(html, context, {
'params': (dom.Element child) {
params = typeObjectFromHtml(child, '$context.params');
},
'result': (dom.Element child) {
result = typeObjectFromHtml(child, '$context.result');
}
});
return Request(domainName, method!, params, result, html,
experimental: experimental, deprecated: deprecated);
}
/// Create a [TypeDefinition] object from an HTML representation such as:
///
/// <type name="typeName">
/// TYPE
/// </type>
///
/// Where TYPE is any HTML that can be parsed by [typeDeclFromHtml].
///
/// Child elements can occur in any order.
TypeDefinition typeDefinitionFromHtml(dom.Element html) {
checkName(html, 'type');
var name = html.attributes['name'];
var context = name ?? 'type';
checkAttributes(html, ['name'], context,
optionalAttributes: ['experimental', 'deprecated']);
var type = processContentsAsType(html, context);
var experimental = html.attributes['experimental'] == 'true';
var deprecated = html.attributes['deprecated'] == 'true';
return TypeDefinition(name!, type, html,
experimental: experimental, deprecated: deprecated);
}
/// Create a [TypeEnum] from an HTML description.
TypeEnum typeEnumFromHtml(dom.Element html, String context) {
checkName(html, 'enum', context);
checkAttributes(html, [], context);
var values = <TypeEnumValue>[];
recurse(html, context, {
'value': (dom.Element child) {
values.add(typeEnumValueFromHtml(child, context));
}
});
return TypeEnum(values, html);
}
/// Create a [TypeEnumValue] from an HTML description such as:
///
/// <enum>
/// <code>VALUE</code>
/// </enum>
///
/// Where VALUE is the text of the enumerated value.
///
/// Child elements can occur in any order.
TypeEnumValue typeEnumValueFromHtml(dom.Element html, String context) {
checkName(html, 'value', context);
checkAttributes(html, [], context, optionalAttributes: ['deprecated']);
var deprecated = html.attributes['deprecated'] == 'true';
var values = <String>[];
recurse(html, context, {
'code': (dom.Element child) {
var text = innerText(child).trim();
values.add(text);
}
});
if (values.length != 1) {
throw Exception('$context: Exactly one value must be specified');
}
return TypeEnumValue(values[0], html, deprecated: deprecated);
}
/// Create a [TypeObjectField] from an HTML description such as:
///
/// <field name="fieldName">
/// TYPE
/// </field>
///
/// Where TYPE is any HTML that can be parsed by [typeDeclFromHtml].
///
/// In addition, the attribute optional="true" may be used to specify that the
/// field is optional, and the attribute value="..." may be used to specify
/// that the field is required to have a certain value.
///
/// Child elements can occur in any order.
TypeObjectField typeObjectFieldFromHtml(dom.Element html, String context) {
checkName(html, 'field', context);
var name = html.attributes['name'];
if (name == null) {
throw Exception('$context: name not specified');
}
context = '$context.$name';
checkAttributes(html, ['name'], context,
optionalAttributes: [
'optional',
'value',
'deprecated',
'experimental'
]);
var deprecated = html.attributes['deprecated'] == 'true';
var experimental = html.attributes['experimental'] == 'true';
var optional = false;
var optionalString = html.attributes['optional'];
if (optionalString != null) {
switch (optionalString) {
case 'true':
optional = true;
break;
case 'false':
optional = false;
break;
default:
throw Exception(
'$context: field contains invalid "optional" attribute: "$optionalString"');
}
}
var value = html.attributes['value'];
var type = processContentsAsType(html, context);
return TypeObjectField(name, type, html,
optional: optional,
value: value,
deprecated: deprecated,
experimental: experimental);
}
/// Create a [TypeObject] from an HTML description.
TypeObject typeObjectFromHtml(dom.Element html, String context) {
checkAttributes(html, [], context, optionalAttributes: ['experimental']);
var fields = <TypeObjectField>[];
recurse(html, context, {
'field': (dom.Element child) {
fields.add(typeObjectFieldFromHtml(child, context));
}
});
var experimental = html.attributes['experimental'] == 'true';
return TypeObject(fields, html, experimental: experimental);
}
/// Create a [Types] object from an HTML representation such as:
///
/// <types>
/// <type name="...">...</type> <!-- zero or more -->
/// </types>
Types typesFromHtml(dom.Element html) {
checkName(html, 'types');
var context = 'types';
checkAttributes(html, [], context);
var importUris = <String>[];
var typeMap = <String, TypeDefinition>{};
var childElements = <dom.Element>[];
recurse(html, context, {
'include': (dom.Element child) {
var importUri = child.attributes['import'];
if (importUri != null) {
importUris.add(importUri);
}
var relativePath = child.attributes['path'];
var path = normalize(join(dirname(filePath), relativePath));
var reader = ApiReader(path);
var api = reader.readApi();
for (var typeDefinition in api.types) {
typeDefinition.isExternal = true;
childElements.add(typeDefinition.html!);
typeMap[typeDefinition.name] = typeDefinition;
}
},
'type': (dom.Element child) {
var typeDefinition = typeDefinitionFromHtml(child);
typeMap[typeDefinition.name] = typeDefinition;
}
});
for (var element in childElements) {
html.append(element);
}
var types = Types(typeMap, html);
types.importUris.addAll(importUris);
return types;
}
}