import {readFileSync, writeFileSync} from 'fs'; import ts, {Node, SourceFile, SyntaxKind, Type} from 'typescript'; import {join} from 'path'; import {DATA_DIR, prettyJson, RUST_OUT_DIR} from './_common'; const reactDeclarations = readFileSync(join(__dirname, 'data', 'react.d.ts'), 'utf8'); // TODO Consider and check behaviour when value matches case insensitively, after trimming whitespace, numerically (for number values), etc. // TODO This file is currently manually sourced and written. Try to get machine-readable spec and automate. const defaultAttributeValues: { [attr: string]: { tags: string[]; defaultValue: string; isPositiveInteger?: boolean; }[]; } = JSON.parse(readFileSync(join(DATA_DIR, 'attrs.json'), 'utf8')); const tagNameNormalised = { 'anchor': 'a', }; const attrNameNormalised = { 'classname': 'class', }; const reactSpecificAttributes = [ 'defaultChecked', 'defaultValue', 'suppressContentEditableWarning', 'suppressHydrationWarning', ]; const collapsibleAndTrimmable = { 'class': ['html:*'], 'd': ['svg:*'], }; // TODO Is escapedText the API for getting name? const getNameOfNode = (n: any) => n.name.escapedText; const normaliseName = (name: string, norms: { [name: string]: string }) => [name.toLowerCase()].map(n => norms[n] || n)[0]; type AttrConfig = { boolean: boolean; redundantIfEmpty: boolean; collapseAndTrim: boolean; defaultValue?: string; }; const rsTagAttr = ({ redundantIfEmpty, defaultValue, collapseAndTrim, boolean, }: AttrConfig) => `AttributeMinification { boolean: ${boolean}, redundant_if_empty: ${redundantIfEmpty}, collapse_and_trim: ${collapseAndTrim}, default_value: ${defaultValue == undefined ? 'None' : `Some(b"${defaultValue}")`}, }`; const processReactTypeDeclarations = (source: SourceFile) => { const nodes: Node[] = [source]; // Use index-based loop to keep iterating as nodes array grows. for (let i = 0; i < nodes.length; i++) { // forEachChild doesn't work if return value is number (e.g. return value of Array.prototype.push). nodes[i].forEachChild(c => void nodes.push(c)); } const attributeNodes = nodes .filter(n => n.kind === ts.SyntaxKind.InterfaceDeclaration) .map(n => [/^([A-Za-z]*)(HTML|SVG)Attributes/.exec(getNameOfNode(n)), n]) .filter(([matches]) => !!matches) .map(([matches, node]) => [matches![2].toLowerCase(), normaliseName(matches![1], tagNameNormalised), node]) .filter(([namespace, tagName]) => namespace !== 'html' || !['all', 'webview'].includes(tagName)) .map(([namespace, tag, node]) => ({namespace, tag, node})) .sort((a, b) => a.namespace.localeCompare(b.namespace) || a.tag.localeCompare(b.tag)); // Process global HTML attributes first as they also appear on some specific HTML tags but we don't want to keep the specific ones if they're global. if (attributeNodes[0].namespace !== 'html' || attributeNodes[0].tag !== '') { throw new Error(`Global HTML attributes is not first to be processed`); } // Map structure: attr => namespace => tag => config. const attributes = new Map>>(); for (const {namespace, tag, node} of attributeNodes) { const fullyQualifiedTagName = [namespace, tag || '*'].join(':'); for (const n of node.members.filter((n: Node) => n.kind === ts.SyntaxKind.PropertySignature)) { const attrName = normaliseName(getNameOfNode(n), attrNameNormalised); if (reactSpecificAttributes.includes(attrName)) continue; const types: SyntaxKind[] = n.type.kind === ts.SyntaxKind.UnionType ? n.type.types.map((t: Node) => t.kind) : [n.type.kind]; const boolean = types.includes(ts.SyntaxKind.BooleanKeyword); // If types includes boolean and string, make it a boolean attr to prevent it from being removed if empty value. const redundantIfEmpty = !boolean && types.some(t => t === ts.SyntaxKind.StringKeyword || t === ts.SyntaxKind.NumberKeyword); const defaultValues = (defaultAttributeValues[attrName] || []) .filter(a => a.tags.includes(fullyQualifiedTagName)) .map(a => a.defaultValue); const collapseAndTrim = (collapsibleAndTrimmable[attrName] || []).includes(fullyQualifiedTagName); if (defaultValues.length > 1) { throw new Error(`Tag-attribute combination <${fullyQualifiedTagName} ${attrName}> has multiple default values: ${defaultValues}`); } const attr: AttrConfig = { boolean, redundantIfEmpty, collapseAndTrim, defaultValue: defaultValues[0], }; if (!attributes.has(attrName)) attributes.set(attrName, new Map()); const namespacesForAttribute = attributes.get(attrName)!; if (!namespacesForAttribute.has(namespace)) namespacesForAttribute.set(namespace, new Map()); const tagsForNsAttribute = namespacesForAttribute.get(namespace)!; if (tagsForNsAttribute.has(tag)) throw new Error(`Duplicate tag-attribute combination: <${fullyQualifiedTagName} ${attrName}>`); const globalAttr = tagsForNsAttribute.get('*'); if (globalAttr) { if (globalAttr.boolean !== attr.boolean || globalAttr.redundantIfEmpty !== attr.redundantIfEmpty || globalAttr.collapseAndTrim !== attr.collapseAndTrim || globalAttr.defaultValue !== attr.defaultValue) { throw new Error(`Global and tag-specific attributes conflict: ${prettyJson(globalAttr)} ${prettyJson(attr)}`); } } else { tagsForNsAttribute.set(tag || '*', attr); } } } let code = ` use crate::spec::tag::ns::Namespace; pub struct AttributeMinification { pub boolean: bool, pub redundant_if_empty: bool, pub collapse_and_trim: bool, pub default_value: Option<&'static [u8]>, } pub enum AttrMapEntry { AllNamespaceElements(AttributeMinification), SpecificNamespaceElements(phf::Map<&'static [u8], AttributeMinification>), } #[derive(Clone, Copy)] pub struct ByNamespace { // Make pub so this struct can be statically created in gen/attrs.rs. pub html: Option<&'static AttrMapEntry>, pub svg: Option<&'static AttrMapEntry>, } impl ByNamespace { fn get(&self, ns: Namespace) -> Option<&'static AttrMapEntry> { match ns { Namespace::Html => self.html, Namespace::Svg => self.svg, } } } pub struct AttrMap(phf::Map<&'static [u8], ByNamespace>); impl AttrMap { pub const fn new(map: phf::Map<&'static [u8], ByNamespace>) -> AttrMap { AttrMap(map) } pub fn get(&self, ns: Namespace, tag: &[u8], attr: &[u8]) -> Option<&AttributeMinification> { self.0.get(attr).and_then(|namespaces| namespaces.get(ns)).and_then(|entry| match entry { AttrMapEntry::AllNamespaceElements(min) => Some(min), AttrMapEntry::SpecificNamespaceElements(map) => map.get(tag), }) } } `; for (const [attrName, namespaces] of attributes) { let byNsCode = ''; byNsCode += `static ${attrName.toUpperCase()}_ATTR: ByNamespace = ByNamespace {\n`; for (const ns of ['html', 'svg'] as const) { byNsCode += `\t${ns}: `; const tagsMap = namespaces.get(ns); if (!tagsMap) { byNsCode += 'None'; } else { const globalAttr = tagsMap.get('*'); if (globalAttr) { code += `static ${ns.toUpperCase()}_${attrName.toUpperCase()}_ATTR: &AttrMapEntry = &AttrMapEntry::AllNamespaceElements(${rsTagAttr(globalAttr)});\n\n`; } else { code += `static ${ns.toUpperCase()}_${attrName.toUpperCase()}_ATTR: &AttrMapEntry = &AttrMapEntry::SpecificNamespaceElements(phf::phf_map! {\n${ [...tagsMap].map(([tagName, tagAttr]) => `b\"${tagName}\" => ${rsTagAttr(tagAttr)}`).join(',\n') }\n});\n\n`; } byNsCode += `Some(${ns.toUpperCase()}_${attrName.toUpperCase()}_ATTR)`; } byNsCode += ',\n'; } byNsCode += '};\n\n'; code += byNsCode; } code += 'pub static ATTRS: AttrMap = AttrMap::new(phf::phf_map! {\n'; for (const attr_name of attributes.keys()) { code += `\tb\"${attr_name}\" => ${attr_name.toUpperCase()}_ATTR,\n`; } code += '});\n\n'; return code; }; const source = ts.createSourceFile(`react.d.ts`, reactDeclarations, ts.ScriptTarget.ES2020); writeFileSync(join(RUST_OUT_DIR, 'attrs.rs'), processReactTypeDeclarations(source));