|
| 1 | +import ts from 'typescript'; |
| 2 | +import { augmentDiagnosticWithNode, buildWarn } from '@utils'; |
| 3 | +import { tsResolveModuleName } from '../../sys/typescript/typescript-resolve-module'; |
| 4 | +import { isStaticGetter } from '../transform-utils'; |
| 5 | +import { parseStaticEvents } from './events'; |
| 6 | +import { parseStaticListeners } from './listeners'; |
| 7 | +import { parseStaticMethods } from './methods'; |
| 8 | +import { parseStaticProps } from './props'; |
| 9 | +import { parseStaticStates } from './states'; |
| 10 | +import { parseStaticWatchers } from './watchers'; |
| 11 | + |
| 12 | +import type * as d from '../../../declarations'; |
| 13 | +import { detectModernPropDeclarations } from '../detect-modern-prop-decls'; |
| 14 | + |
| 15 | +type DeDupeMember = |
| 16 | + | d.ComponentCompilerProperty |
| 17 | + | d.ComponentCompilerState |
| 18 | + | d.ComponentCompilerMethod |
| 19 | + | d.ComponentCompilerListener |
| 20 | + | d.ComponentCompilerEvent |
| 21 | + | d.ComponentCompilerWatch; |
| 22 | + |
| 23 | +/** |
| 24 | + * Given two arrays of static members, return a new array containing only the |
| 25 | + * members from the first array that are not present in the second array. |
| 26 | + * This is used to de-dupe static members that are inherited from a parent class. |
| 27 | + * |
| 28 | + * @param dedupeMembers the array of static members to de-dupe |
| 29 | + * @param staticMembers the array of static members to compare against |
| 30 | + * @returns an array of static members that are not present in the second array |
| 31 | + */ |
| 32 | +const deDupeMembers = <T extends DeDupeMember>(dedupeMembers: T[], staticMembers: T[]) => { |
| 33 | + return dedupeMembers.filter( |
| 34 | + (s) => |
| 35 | + !staticMembers.some((d) => { |
| 36 | + if ((d as d.ComponentCompilerWatch).methodName) { |
| 37 | + return (d as any).methodName === (s as any).methodName; |
| 38 | + } |
| 39 | + return (d as any).name === (s as any).name; |
| 40 | + }), |
| 41 | + ); |
| 42 | +}; |
| 43 | + |
| 44 | +/** |
| 45 | + * A recursive function that walks the AST to find a class declaration. |
| 46 | + * @param node the current AST node |
| 47 | + * @param depth the current depth in the AST |
| 48 | + * @param name optional name of the class to find |
| 49 | + * @returns the found class declaration or undefined |
| 50 | + */ |
| 51 | +function findClassWalk(node?: ts.Node, name?: string): ts.ClassDeclaration | undefined { |
| 52 | + if (!node) return undefined; |
| 53 | + if (node && ts.isClassDeclaration(node) && (!name || node.name?.text === name)) { |
| 54 | + return node; |
| 55 | + } |
| 56 | + let found: ts.ClassDeclaration | undefined; |
| 57 | + |
| 58 | + ts.forEachChild(node, (child) => { |
| 59 | + if (found) return; |
| 60 | + const result = findClassWalk(child, name); |
| 61 | + if (result) found = result; |
| 62 | + }); |
| 63 | + |
| 64 | + return found; |
| 65 | +} |
| 66 | + |
| 67 | +/** |
| 68 | + * A function that checks if a statement matches a named declaration. |
| 69 | + * @param name the name to match |
| 70 | + * @returns a function that checks if a statement is a named declaration |
| 71 | + */ |
| 72 | +function matchesNamedDeclaration(name: string) { |
| 73 | + return function (stmt: ts.Statement): stmt is ts.ClassDeclaration | ts.FunctionDeclaration | ts.VariableStatement { |
| 74 | + // ClassDeclaration: class Foo {} |
| 75 | + if (ts.isClassDeclaration(stmt) && stmt.name?.text === name) { |
| 76 | + return true; |
| 77 | + } |
| 78 | + |
| 79 | + // FunctionDeclaration: function Foo() {} |
| 80 | + if (ts.isFunctionDeclaration(stmt) && stmt.name?.text === name) { |
| 81 | + return true; |
| 82 | + } |
| 83 | + |
| 84 | + // VariableStatement: const Foo = ... |
| 85 | + if (ts.isVariableStatement(stmt)) { |
| 86 | + for (const decl of stmt.declarationList.declarations) { |
| 87 | + if (ts.isIdentifier(decl.name) && decl.name.text === name) { |
| 88 | + return true; |
| 89 | + } |
| 90 | + } |
| 91 | + } |
| 92 | + |
| 93 | + return false; |
| 94 | + }; |
| 95 | +} |
| 96 | + |
| 97 | +/** |
| 98 | + * A recursive function that builds a tree of classes that extend from each other. |
| 99 | + * |
| 100 | + * @param compilerCtx the current compiler context |
| 101 | + * @param classDeclaration a class declaration to analyze |
| 102 | + * @param dependentClasses a flat array tree of classes that extend from each other |
| 103 | + * @param typeChecker the TypeScript type checker |
| 104 | + * @returns a flat array of classes that extend from each other, including the current class |
| 105 | + */ |
| 106 | +function buildExtendsTree( |
| 107 | + compilerCtx: d.CompilerCtx, |
| 108 | + classDeclaration: ts.ClassDeclaration, |
| 109 | + dependentClasses: { classNode: ts.ClassDeclaration; fileName: string }[], |
| 110 | + typeChecker: ts.TypeChecker, |
| 111 | + buildCtx: d.BuildCtx, |
| 112 | +) { |
| 113 | + const hasHeritageClauses = classDeclaration.heritageClauses; |
| 114 | + if (!hasHeritageClauses?.length) return dependentClasses; |
| 115 | + |
| 116 | + const extendsClause = hasHeritageClauses.find((clause) => clause.token === ts.SyntaxKind.ExtendsKeyword); |
| 117 | + if (!extendsClause) return dependentClasses; |
| 118 | + |
| 119 | + let classIdentifiers: ts.Identifier[] = []; |
| 120 | + let foundClassDeclaration: ts.ClassDeclaration | undefined; |
| 121 | + // used when the class we found is wrapped in a mixin factory function - |
| 122 | + // the extender ctor will be from a dynamic function argument - so we stop recursing |
| 123 | + let keepLooking = true; |
| 124 | + |
| 125 | + extendsClause.types.forEach((type) => { |
| 126 | + if ( |
| 127 | + ts.isExpressionWithTypeArguments(type) && |
| 128 | + ts.isCallExpression(type.expression) && |
| 129 | + type.expression.expression.getText() === 'Mixin' |
| 130 | + ) { |
| 131 | + // handle mixin case: extends Mixin(SomeClassFactoryFunction1, SomeClassFactoryFunction2) |
| 132 | + classIdentifiers = type.expression.arguments.filter(ts.isIdentifier); |
| 133 | + } else if (ts.isIdentifier(type.expression)) { |
| 134 | + // handle simple case: extends SomeClass |
| 135 | + classIdentifiers = [type.expression]; |
| 136 | + } |
| 137 | + }); |
| 138 | + |
| 139 | + classIdentifiers.forEach((extendee) => { |
| 140 | + try { |
| 141 | + // happy path (normally 1 file level removed): the extends type resolves to a class declaration in another file |
| 142 | + |
| 143 | + const symbol = typeChecker.getSymbolAtLocation(extendee); |
| 144 | + const aliasedSymbol = symbol ? typeChecker.getAliasedSymbol(symbol) : undefined; |
| 145 | + foundClassDeclaration = aliasedSymbol?.declarations?.find(ts.isClassDeclaration); |
| 146 | + |
| 147 | + if (!foundClassDeclaration) { |
| 148 | + // the found `extends` type does not resolve to a class declaration; |
| 149 | + // if it's wrapped in a function - let's try and find it inside |
| 150 | + const node = aliasedSymbol?.declarations?.[0]; |
| 151 | + foundClassDeclaration = findClassWalk(node); |
| 152 | + keepLooking = false; |
| 153 | + } |
| 154 | + |
| 155 | + if (foundClassDeclaration && !dependentClasses.some((dc) => dc.classNode === foundClassDeclaration)) { |
| 156 | + const foundModule = compilerCtx.moduleMap.get(foundClassDeclaration.getSourceFile().fileName); |
| 157 | + |
| 158 | + if (foundModule) { |
| 159 | + const source = foundModule.staticSourceFile as ts.SourceFile; |
| 160 | + const sourceClass = findClassWalk(source, foundClassDeclaration.name?.getText()); |
| 161 | + |
| 162 | + if (sourceClass) { |
| 163 | + dependentClasses.push({ classNode: sourceClass, fileName: source.fileName }); |
| 164 | + if (keepLooking) { |
| 165 | + buildExtendsTree(compilerCtx, foundClassDeclaration, dependentClasses, typeChecker, buildCtx); |
| 166 | + } |
| 167 | + } |
| 168 | + } |
| 169 | + } |
| 170 | + } catch (_e) { |
| 171 | + // sad path (normally >1 levels removed): the extends type does not resolve so let's find it manually: |
| 172 | + |
| 173 | + const currentSource = classDeclaration.getSourceFile(); |
| 174 | + if (!currentSource) return; |
| 175 | + |
| 176 | + // let's see if we can find the class in the current source file first |
| 177 | + const matchedStatement = currentSource.statements.find(matchesNamedDeclaration(extendee.getText())); |
| 178 | + |
| 179 | + if (matchedStatement && ts.isClassDeclaration(matchedStatement)) { |
| 180 | + foundClassDeclaration = matchedStatement; |
| 181 | + } else if (matchedStatement) { |
| 182 | + // the found `extends` type does not resolve to a class declaration; |
| 183 | + // if it's wrapped in a function - let's try and find it inside |
| 184 | + foundClassDeclaration = findClassWalk(matchedStatement); |
| 185 | + keepLooking = false; |
| 186 | + } |
| 187 | + |
| 188 | + if (foundClassDeclaration && !dependentClasses.some((dc) => dc.classNode === foundClassDeclaration)) { |
| 189 | + // we found the class declaration in the current module |
| 190 | + dependentClasses.push({ classNode: foundClassDeclaration, fileName: currentSource.fileName }); |
| 191 | + if (keepLooking) { |
| 192 | + buildExtendsTree(compilerCtx, foundClassDeclaration, dependentClasses, typeChecker, buildCtx); |
| 193 | + } |
| 194 | + return; |
| 195 | + } |
| 196 | + |
| 197 | + // if not found, let's check the import statements |
| 198 | + const importStatements = currentSource.statements.filter(ts.isImportDeclaration); |
| 199 | + importStatements.forEach((statement) => { |
| 200 | + // 1) loop through import declarations in the current source file |
| 201 | + if (statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause?.namedBindings)) { |
| 202 | + statement.importClause?.namedBindings.elements.forEach((element) => { |
| 203 | + // 2) loop through the named bindings of the import declaration |
| 204 | + |
| 205 | + if (element.name.getText() === extendee.getText()) { |
| 206 | + // 3) check the name matches the `extends` type expression |
| 207 | + const className = element.propertyName?.getText() || element.name.getText(); |
| 208 | + const foundFile = tsResolveModuleName( |
| 209 | + buildCtx.config, |
| 210 | + compilerCtx, |
| 211 | + statement.moduleSpecifier.getText().replaceAll(/['"]/g, ''), |
| 212 | + currentSource.fileName, |
| 213 | + ); |
| 214 | + |
| 215 | + if (foundFile?.resolvedModule && className) { |
| 216 | + // 4) resolve the module name to a file |
| 217 | + const foundModule = compilerCtx.moduleMap.get(foundFile.resolvedModule.resolvedFileName); |
| 218 | + |
| 219 | + // 5) look for the corresponding resolved statement |
| 220 | + const matchedStatement = (foundModule?.staticSourceFile as ts.SourceFile).statements.find( |
| 221 | + matchesNamedDeclaration(className), |
| 222 | + ); |
| 223 | + foundClassDeclaration = matchedStatement |
| 224 | + ? ts.isClassDeclaration(matchedStatement) |
| 225 | + ? matchedStatement |
| 226 | + : undefined |
| 227 | + : undefined; |
| 228 | + |
| 229 | + if (!foundClassDeclaration && matchedStatement) { |
| 230 | + // 5.b) the found `extends` type does not resolve to a class declaration; |
| 231 | + // if it's wrapped in a function - let's try and find it inside |
| 232 | + foundClassDeclaration = findClassWalk(matchedStatement); |
| 233 | + keepLooking = false; |
| 234 | + } |
| 235 | + |
| 236 | + if (foundClassDeclaration && !dependentClasses.some((dc) => dc.classNode === foundClassDeclaration)) { |
| 237 | + // 6) if we found the class declaration, push it and check if it itself extends from another class |
| 238 | + dependentClasses.push({ classNode: foundClassDeclaration, fileName: currentSource.fileName }); |
| 239 | + if (keepLooking) { |
| 240 | + buildExtendsTree(compilerCtx, foundClassDeclaration, dependentClasses, typeChecker, buildCtx); |
| 241 | + } |
| 242 | + return; |
| 243 | + } |
| 244 | + } |
| 245 | + } |
| 246 | + }); |
| 247 | + } |
| 248 | + }); |
| 249 | + } |
| 250 | + }); |
| 251 | + |
| 252 | + return dependentClasses; |
| 253 | +} |
| 254 | + |
| 255 | +/** |
| 256 | + * Given a class declaration, this function will analyze its heritage clauses |
| 257 | + * to find any extended classes, and then parse the static members of those |
| 258 | + * extended classes to merge them into the current class's metadata. |
| 259 | + * |
| 260 | + * @param compilerCtx |
| 261 | + * @param typeChecker |
| 262 | + * @param buildCtx |
| 263 | + * @param cmpNode |
| 264 | + * @param staticMembers |
| 265 | + * @returns an object containing merged metadata from extended classes |
| 266 | + */ |
| 267 | +export function mergeExtendedClassMeta( |
| 268 | + compilerCtx: d.CompilerCtx, |
| 269 | + typeChecker: ts.TypeChecker, |
| 270 | + buildCtx: d.BuildCtx, |
| 271 | + cmpNode: ts.ClassDeclaration, |
| 272 | + staticMembers: ts.ClassElement[], |
| 273 | +) { |
| 274 | + const tree = buildExtendsTree(compilerCtx, cmpNode, [], typeChecker, buildCtx); |
| 275 | + let hasMixin = false; |
| 276 | + let doesExtend = false; |
| 277 | + let properties = parseStaticProps(staticMembers); |
| 278 | + let states = parseStaticStates(staticMembers); |
| 279 | + let methods = parseStaticMethods(staticMembers); |
| 280 | + let listeners = parseStaticListeners(staticMembers); |
| 281 | + let events = parseStaticEvents(staticMembers); |
| 282 | + let watchers = parseStaticWatchers(staticMembers); |
| 283 | + let classMethods = cmpNode.members.filter(ts.isMethodDeclaration); |
| 284 | + |
| 285 | + tree.forEach((extendedClass) => { |
| 286 | + const extendedStaticMembers = extendedClass.classNode.members.filter(isStaticGetter); |
| 287 | + const mixinProps = parseStaticProps(extendedStaticMembers) ?? []; |
| 288 | + const mixinStates = parseStaticStates(extendedStaticMembers) ?? []; |
| 289 | + const mixinMethods = parseStaticMethods(extendedStaticMembers) ?? []; |
| 290 | + const isMixin = mixinProps.length > 0 || mixinStates.length > 0; |
| 291 | + const module = compilerCtx.moduleMap.get(extendedClass.fileName); |
| 292 | + if (!module) return; |
| 293 | + |
| 294 | + module.isMixin = isMixin; |
| 295 | + module.isExtended = true; |
| 296 | + doesExtend = true; |
| 297 | + |
| 298 | + if (isMixin && !detectModernPropDeclarations(extendedClass.classNode)) { |
| 299 | + const err = buildWarn(buildCtx.diagnostics); |
| 300 | + const target = buildCtx.config.tsCompilerOptions?.target; |
| 301 | + err.messageText = `Component classes can only extend from other Stencil decorated base classes when targetting more modern JavaScript (ES2022 and above). |
| 302 | + ${target ? `Your current TypeScript configuration is set to target \`${ts.ScriptTarget[target]}\`.` : ''} Please amend your tsconfig.json.`; |
| 303 | + if (!buildCtx.config._isTesting) augmentDiagnosticWithNode(err, extendedClass.classNode); |
| 304 | + } |
| 305 | + |
| 306 | + properties = [...deDupeMembers(mixinProps, properties), ...properties]; |
| 307 | + states = [...deDupeMembers(mixinStates, states), ...states]; |
| 308 | + methods = [...deDupeMembers(mixinMethods, methods), ...methods]; |
| 309 | + listeners = [...deDupeMembers(parseStaticListeners(extendedStaticMembers) ?? [], listeners), ...listeners]; |
| 310 | + events = [...deDupeMembers(parseStaticEvents(extendedStaticMembers) ?? [], events), ...events]; |
| 311 | + watchers = [...deDupeMembers(parseStaticWatchers(extendedStaticMembers) ?? [], watchers), ...watchers]; |
| 312 | + classMethods = [...classMethods, ...(extendedClass.classNode.members.filter(ts.isMethodDeclaration) ?? [])]; |
| 313 | + |
| 314 | + if (isMixin) hasMixin = true; |
| 315 | + }); |
| 316 | + |
| 317 | + return { hasMixin, doesExtend, properties, states, methods, listeners, events, watchers, classMethods }; |
| 318 | +} |
0 commit comments