| // 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. | |
| library; | |
| 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'. [pkgRoot] is the | |
| /// path to the SDK's `pkg` directory. | |
| Api readApi(String pkgRoot) { | |
| var reader = ApiReader( | |
| join(pkgRoot, 'analysis_server', 'tool', 'spec', 'spec_input.html'), | |
| ); | |
| return reader.readApi(); | |
| } | |
| typedef ElementProcessor = void Function(dom.Element element); | |
| 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]. | |
| new(this.filePath); | |
| /// Create an [Api] object from an HTML representation such as: | |
| /// | |
| /// ```html | |
| /// <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>[]; | |
| var types = Types({}, null); | |
| var refactorings = Refactorings([], null); | |
| 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: | |
| /// | |
| /// ```html | |
| /// <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']; | |
| if (name == null) { | |
| throw Exception('domains: name not specified'); | |
| } | |
| var experimental = html.attributes['experimental'] == 'true'; | |
| checkAttributes(html, ['name'], name, optionalAttributes: ['experimental']); | |
| var requests = <Request>[]; | |
| var notifications = <Notification>[]; | |
| recurse(html, name, { | |
| 'request': (dom.Element child) { | |
| requests.add(requestFromHtml(child, name)); | |
| }, | |
| 'notification': (dom.Element child) { | |
| notifications.add(notificationFromHtml(child, name)); | |
| }, | |
| }); | |
| 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: | |
| /// | |
| /// ```html | |
| /// <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 | |
| /// [typeObjectFromHtml]. | |
| /// | |
| /// Child elements can occur in any order. | |
| Notification notificationFromHtml(dom.Element html, String context) { | |
| var domainName = getAncestor(html, 'domain', context).attributes['name']; | |
| if (domainName == null) { | |
| throw Exception('$context: domain not specified'); | |
| } | |
| checkName(html, 'notification', context); | |
| var event = html.attributes['event']; | |
| if (event == null) { | |
| throw Exception('$context: event not specified'); | |
| } | |
| context = '$context.$event'; | |
| TypeObject? params; | |
| recurse(html, context, { | |
| 'params': (dom.Element child) { | |
| params = typeObjectFromHtml(child, '$context.params'); | |
| }, | |
| }); | |
| checkAttributes( | |
| html, | |
| ['event'], | |
| context, | |
| optionalAttributes: ['experimental'], | |
| ); | |
| var experimental = html.attributes['experimental'] == 'true'; | |
| return Notification( | |
| domainName, | |
| event, | |
| params, | |
| html, | |
| experimental: experimental, | |
| ); | |
| } | |
| /// 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: | |
| /// ```html | |
| /// <object> | |
| /// <field name="...">...</field> <!-- zero or more --> | |
| /// </object> | |
| /// ``` | |
| /// | |
| /// For an enum: | |
| /// ```html | |
| /// <enum> | |
| /// <value>...</value> <!-- zero or more --> | |
| /// </enum> | |
| /// ``` | |
| /// | |
| /// For a union type: | |
| /// ```html | |
| /// <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? keyTypeNullable; | |
| TypeDecl? valueTypeNullable; | |
| recurse(child, context, { | |
| 'key': (dom.Element child) { | |
| if (keyTypeNullable != null) { | |
| throw Exception('$context: Key type already specified'); | |
| } | |
| keyTypeNullable = processContentsAsType(child, '$context.key'); | |
| }, | |
| 'value': (dom.Element child) { | |
| if (valueTypeNullable != null) { | |
| throw Exception('$context: Value type already specified'); | |
| } | |
| valueTypeNullable = processContentsAsType(child, '$context.value'); | |
| }, | |
| }); | |
| var keyType = keyTypeNullable; | |
| if (keyType is! TypeReference) { | |
| throw Exception( | |
| '$context: Key type not specified, or not a reference', | |
| ); | |
| } | |
| var valueType = valueTypeNullable; | |
| if (valueType == null) { | |
| throw Exception('$context: Value type not specified'); | |
| } | |
| types.add(TypeMap(keyType, 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) { | |
| var processor = elementProcessors[node.name]; | |
| if (processor != null) { | |
| processor(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: | |
| /// | |
| /// ```html | |
| /// <refactoring kind="refactoringKind"> | |
| /// <feedback>...</feedback> <!-- optional --> | |
| /// <options>...</options> <!-- optional --> | |
| /// </refactoring> | |
| /// ``` | |
| /// | |
| /// `<feedback>` and `<options>` have the same form as `<object>`, as | |
| /// described in [typeObjectFromHtml]. | |
| /// | |
| /// Child elements can occur in any order. | |
| Refactoring refactoringFromHtml(dom.Element html) { | |
| checkName(html, 'refactoring'); | |
| var kind = html.attributes['kind']; | |
| if (kind == null) { | |
| throw Exception('refactorings: kind not specified'); | |
| } | |
| checkAttributes(html, ['kind'], kind); | |
| TypeObject? feedback; | |
| TypeObject? options; | |
| recurse(html, kind, { | |
| 'feedback': (dom.Element child) { | |
| feedback = typeObjectFromHtml(child, '$kind.feedback'); | |
| }, | |
| 'options': (dom.Element child) { | |
| options = typeObjectFromHtml(child, '$kind.options'); | |
| }, | |
| }); | |
| return Refactoring(kind, feedback, options, html); | |
| } | |
| /// Create a [Refactorings] object from an HTML representation such as: | |
| /// | |
| /// ```html | |
| /// <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: | |
| /// | |
| /// ```html | |
| /// <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 [typeObjectFromHtml]. | |
| /// | |
| /// Child elements can occur in any order. | |
| Request requestFromHtml(dom.Element html, String context) { | |
| var domainName = getAncestor(html, 'domain', context).attributes['name']; | |
| if (domainName == null) { | |
| throw Exception('$context: domain not specified'); | |
| } | |
| checkName(html, 'request', context); | |
| var method = html.attributes['method']; | |
| if (method == null) { | |
| throw Exception('$context: method not specified'); | |
| } | |
| context = '$context.$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: | |
| /// | |
| /// ```html | |
| /// <type name="typeName"> | |
| /// TYPE | |
| /// </type> | |
| /// ``` | |
| /// | |
| /// Where TYPE is any HTML that can be parsed by [typeObjectFromHtml]. | |
| /// | |
| /// Child elements can occur in any order. | |
| TypeDefinition typeDefinitionFromHtml(dom.Element html) { | |
| checkName(html, 'type'); | |
| var name = html.attributes['name']; | |
| if (name == null) { | |
| throw Exception('types: name not specified'); | |
| } | |
| checkAttributes( | |
| html, | |
| ['name'], | |
| name, | |
| optionalAttributes: ['experimental', 'deprecated'], | |
| ); | |
| var type = processContentsAsType(html, name); | |
| 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: | |
| /// | |
| /// ```html | |
| /// <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: | |
| /// | |
| /// ```html | |
| /// <field name="fieldName"> | |
| /// TYPE | |
| /// </field> | |
| /// ``` | |
| /// | |
| /// Where TYPE is any HTML that can be parsed by [typeObjectFromHtml]. | |
| /// | |
| /// 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; | |
| case 'false': | |
| optional = false; | |
| 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: | |
| /// | |
| /// ```html | |
| /// <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; | |
| } | |
| } |