View Javadoc
1   /*
2    * Copyright (c) 2002-2026 Gargoyle Software Inc.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    * https://www.apache.org/licenses/LICENSE-2.0
8    *
9    * Unless required by applicable law or agreed to in writing, software
10   * distributed under the License is distributed on an "AS IS" BASIS,
11   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12   * See the License for the specific language governing permissions and
13   * limitations under the License.
14   */
15  package org.htmlunit.javascript.configuration;
16  
17  import static org.htmlunit.javascript.configuration.SupportedBrowser.CHROME;
18  import static org.htmlunit.javascript.configuration.SupportedBrowser.EDGE;
19  import static org.htmlunit.javascript.configuration.SupportedBrowser.FF;
20  import static org.htmlunit.javascript.configuration.SupportedBrowser.FF_ESR;
21  
22  import java.lang.annotation.Annotation;
23  import java.lang.reflect.Field;
24  import java.lang.reflect.Method;
25  import java.util.ArrayList;
26  import java.util.HashSet;
27  import java.util.Map;
28  import java.util.Map.Entry;
29  import java.util.Set;
30  import java.util.concurrent.ConcurrentHashMap;
31  
32  import org.apache.commons.logging.Log;
33  import org.apache.commons.logging.LogFactory;
34  import org.htmlunit.BrowserVersion;
35  import org.htmlunit.corejs.javascript.SymbolKey;
36  import org.htmlunit.javascript.HtmlUnitScriptable;
37  import org.htmlunit.javascript.JavaScriptEngine;
38  import org.htmlunit.util.StringUtils;
39  
40  /**
41   * An abstract container for all the JavaScript configuration information.
42   *
43   * @author Mike Bowler
44   * @author Chris Erskine
45   * @author Ahmed Ashour
46   * @author Ronald Brill
47   * @author Frank Danek
48   */
49  public abstract class AbstractJavaScriptConfiguration {
50  
51      private static final Log LOG = LogFactory.getLog(AbstractJavaScriptConfiguration.class);
52  
53      private Map<Class<?>, Class<? extends HtmlUnitScriptable>> domJavaScriptMap_;
54  
55      private final ArrayList<ClassConfiguration> configuration_;
56      private ClassConfiguration globalThisConfiguration_;
57  
58      /**
59       * Constructor.
60       * @param browser the browser version to use
61       * @param globalThisClass the globalThis class for faster access
62       */
63      protected AbstractJavaScriptConfiguration(final BrowserVersion browser, final Class<?> globalThisClass) {
64          configuration_ = new ArrayList<>(getClasses().length);
65  
66          for (final Class<? extends HtmlUnitScriptable> klass : getClasses()) {
67              final ClassConfiguration config = getClassConfiguration(klass, browser);
68              if (config != null) {
69                  configuration_.add(config);
70                  if (klass == globalThisClass) {
71                      globalThisConfiguration_ = config;
72                  }
73              }
74          }
75      }
76  
77      /**
78       * Returns the classes configured by this configuration.
79       *
80       * @return the classes configured by this configuration
81       */
82      protected abstract Class<? extends HtmlUnitScriptable>[] getClasses();
83  
84      /**
85       * Gets all the configurations.
86       * @return the class configurations
87       */
88      public Iterable<ClassConfiguration> getAll() {
89          return configuration_;
90      }
91  
92      /**
93       * Returns the class configuration of the given {@code klass}.
94       *
95       * @param klass the class
96       * @param browserVersion the browser version
97       * @return the class configuration
98       */
99      public static ClassConfiguration getClassConfiguration(final Class<? extends HtmlUnitScriptable> klass,
100         final BrowserVersion browserVersion) {
101         if (browserVersion != null) {
102             final SupportedBrowser expectedBrowser;
103             if (browserVersion.isChrome()) {
104                 expectedBrowser = CHROME;
105             }
106             else if (browserVersion.isEdge()) {
107                 expectedBrowser = EDGE;
108             }
109             else if (browserVersion.isFirefoxESR()) {
110                 expectedBrowser = FF_ESR;
111             }
112             else if (browserVersion.isFirefox()) {
113                 expectedBrowser = FF;
114             }
115             else {
116                 expectedBrowser = CHROME;  // our current fallback
117             }
118 
119             final String hostClassName = klass.getName();
120             final JsxClasses jsxClasses = klass.getAnnotation(JsxClasses.class);
121             if (jsxClasses != null) {
122                 if (klass.getAnnotation(JsxClass.class) != null) {
123                     throw new RuntimeException("Invalid JsxClasses/JsxClass annotation; class '"
124                         + hostClassName + "' has both.");
125                 }
126                 final JsxClass[] jsxClassValues = jsxClasses.value();
127                 if (jsxClassValues.length == 1) {
128                     throw new RuntimeException("No need to specify JsxClasses with a single JsxClass for "
129                             + hostClassName);
130                 }
131                 final Set<Class<?>> domClasses = new HashSet<>();
132 
133                 boolean isJsObject = false;
134                 String className = null;
135 
136                 final String extendedClassName;
137                 final Class<?> superClass = klass.getSuperclass();
138                 if (superClass.getAnnotation(JsxClass.class) == null
139                         && superClass.getAnnotation(JsxClasses.class) == null) {
140                     extendedClassName = "";
141                 }
142                 else {
143                     extendedClassName = superClass.getSimpleName();
144                 }
145 
146                 for (final JsxClass jsxClass : jsxClassValues) {
147                     if (jsxClass != null && isSupported(jsxClass.value(), expectedBrowser)) {
148                         domClasses.add(jsxClass.domClass());
149                         if (jsxClass.isJSObject()) {
150                             isJsObject = true;
151                         }
152                         if (!jsxClass.className().isEmpty()) {
153                             className = jsxClass.className();
154                         }
155                     }
156                 }
157 
158                 if (domClasses.size() > 0) {
159                     final ClassConfiguration classConfiguration =
160                             new ClassConfiguration(klass, domClasses.toArray(new Class<?>[0]), isJsObject,
161                                     className, extendedClassName);
162 
163                     process(classConfiguration, expectedBrowser);
164                     return classConfiguration;
165                 }
166             }
167 
168             final JsxClass jsxClass = klass.getAnnotation(JsxClass.class);
169             if (jsxClass != null && isSupported(jsxClass.value(), expectedBrowser)) {
170 
171                 final Set<Class<?>> domClasses = new HashSet<>();
172                 final Class<?> domClass = jsxClass.domClass();
173                 if (domClass != null && domClass != Object.class) {
174                     domClasses.add(domClass);
175                 }
176 
177                 String className = jsxClass.className();
178                 if (className.isEmpty()) {
179                     className = null;
180                 }
181 
182                 final String extendedClassName;
183                 final Class<?> superClass = klass.getSuperclass();
184                 if (superClass.getAnnotation(JsxClass.class) == null
185                         && superClass.getAnnotation(JsxClasses.class) == null) {
186                     extendedClassName = "";
187                 }
188                 else {
189                     extendedClassName = superClass.getSimpleName();
190                 }
191 
192                 final ClassConfiguration classConfiguration
193                     = new ClassConfiguration(klass,
194                             domClasses.toArray(new Class<?>[0]),
195                             jsxClass.isJSObject(),
196                             className,
197                             extendedClassName);
198 
199                 process(classConfiguration, expectedBrowser);
200                 return classConfiguration;
201             }
202         }
203         return null;
204     }
205 
206     private static void process(final ClassConfiguration classConfiguration, final SupportedBrowser expectedBrowser) {
207         final Map<String, Method> allGetters = new ConcurrentHashMap<>();
208         final Map<String, Method> allSetters = new ConcurrentHashMap<>();
209 
210         try {
211             // do this as first step to be able to overwrite the symbol later if needed
212             classConfiguration.addSymbolConstant(SymbolKey.TO_STRING_TAG, classConfiguration.getHostClassSimpleName());
213 
214             for (final Method method : classConfiguration.getHostClass().getDeclaredMethods()) {
215                 for (final Annotation annotation : method.getAnnotations()) {
216                     if (annotation instanceof JsxGetter jsxGetter) {
217                         if (isSupported(jsxGetter.value(), expectedBrowser)) {
218                             String property;
219                             if (jsxGetter.propertyName().isEmpty()) {
220                                 final int prefix = method.getName().startsWith("is") ? 2 : 3;
221                                 property = method.getName().substring(prefix);
222                                 property = Character.toLowerCase(property.charAt(0)) + property.substring(1);
223                             }
224                             else {
225                                 property = jsxGetter.propertyName();
226                             }
227                             allGetters.put(property, method);
228                         }
229                     }
230                     else if (annotation instanceof JsxSetter jsxSetter) {
231                         if (isSupported(jsxSetter.value(), expectedBrowser)) {
232                             String property;
233                             if (jsxSetter.propertyName().isEmpty()) {
234                                 property = method.getName().substring(3);
235                                 property = Character.toLowerCase(property.charAt(0)) + property.substring(1);
236                             }
237                             else {
238                                 property = jsxSetter.propertyName();
239                             }
240                             allSetters.put(property, method);
241                         }
242                     }
243                     if (annotation instanceof JsxSymbol jsxSymbol) {
244                         if (isSupported(jsxSymbol.value(), expectedBrowser)) {
245                             final String symbolKeyName;
246                             if (jsxSymbol.symbolName().isEmpty()) {
247                                 symbolKeyName = method.getName();
248                             }
249                             else {
250                                 symbolKeyName = jsxSymbol.symbolName();
251                             }
252 
253                             final SymbolKey symbolKey;
254                             if ("iterator".equalsIgnoreCase(symbolKeyName)) {
255                                 symbolKey = SymbolKey.ITERATOR;
256                             }
257                             else {
258                                 throw new RuntimeException("Invalid JsxSymbol annotation; unsupported '"
259                                         + symbolKeyName + "' symbol name.");
260                             }
261                             classConfiguration.addSymbol(symbolKey, method);
262                         }
263                     }
264                     else if (annotation instanceof JsxFunction jsxFunction) {
265                         if (isSupported(jsxFunction.value(), expectedBrowser)) {
266                             final String name;
267                             if (jsxFunction.functionName().isEmpty()) {
268                                 name = method.getName();
269                             }
270                             else {
271                                 name = jsxFunction.functionName();
272                             }
273                             classConfiguration.addFunction(name, method);
274                         }
275                     }
276                     else if (annotation instanceof JsxStaticGetter jsxStaticGetter) {
277                         if (isSupported(jsxStaticGetter.value(), expectedBrowser)) {
278                             final int prefix = method.getName().startsWith("is") ? 2 : 3;
279                             String property = method.getName().substring(prefix);
280                             property = Character.toLowerCase(property.charAt(0)) + property.substring(1);
281                             classConfiguration.addStaticProperty(property, method, null);
282                         }
283                     }
284                     else if (annotation instanceof JsxStaticFunction jsxStaticFunction) {
285                         if (isSupported(jsxStaticFunction.value(), expectedBrowser)) {
286                             final String name;
287                             if (jsxStaticFunction.functionName().isEmpty()) {
288                                 name = method.getName();
289                             }
290                             else {
291                                 name = jsxStaticFunction.functionName();
292                             }
293                             classConfiguration.addStaticFunction(name, method);
294                         }
295                     }
296                     else if (annotation instanceof JsxConstructor jsxConstructor) {
297                         if (isSupported(jsxConstructor.value(), expectedBrowser)) {
298                             final String name;
299                             if (jsxConstructor.functionName().isEmpty()) {
300                                 name = classConfiguration.getClassName();
301                             }
302                             else {
303                                 name = jsxConstructor.functionName();
304                             }
305                             classConfiguration.setJSConstructor(name, method);
306                         }
307                     }
308                     else if (annotation instanceof JsxConstructorAlias jsxConstructorAlias) {
309                         if (isSupported(jsxConstructorAlias.value(), expectedBrowser)) {
310                             classConfiguration.setJSConstructorAlias(jsxConstructorAlias.alias());
311                         }
312                     }
313                 }
314             }
315 
316             for (final Entry<String, Method> getterEntry : allGetters.entrySet()) {
317                 final String property = getterEntry.getKey();
318                 classConfiguration.addProperty(property, getterEntry.getValue(), allSetters.get(property));
319             }
320 
321             // JsxConstant/JsxSymbolConstant
322             for (final Field field : classConfiguration.getHostClass().getDeclaredFields()) {
323                 for (final Annotation annotation : field.getAnnotations()) {
324                     if (annotation instanceof JsxConstant jsxConstant) {
325                         if (isSupported(jsxConstant.value(), expectedBrowser)) {
326                             try {
327                                 classConfiguration.addConstant(field.getName(), field.get(null));
328                             }
329                             catch (final IllegalAccessException e) {
330                                 throw JavaScriptEngine.reportRuntimeError(
331                                         "Cannot get field '" + field.getName()
332                                         + "' for type: " + classConfiguration.getHostClass().getName()
333                                         + "reason: " + e.getMessage());
334                             }
335                         }
336                     }
337                     if (annotation instanceof JsxSymbolConstant jsxSymbolConstant) {
338                         if (isSupported(jsxSymbolConstant.value(), expectedBrowser)) {
339                             final SymbolKey symbolKey;
340                             if (StringUtils.startsWithIgnoreCase(field.getName(), "TO_STRING_TAG")) {
341                                 symbolKey = SymbolKey.TO_STRING_TAG;
342                             }
343                             else {
344                                 throw new RuntimeException("Invalid JsxSymbol annotation; unsupported '"
345                                         + field.getName() + "' symbol name.");
346                             }
347                             classConfiguration.addSymbolConstant(symbolKey, field.get(null).toString());
348                         }
349                     }
350                 }
351             }
352         }
353         catch (final Throwable e) {
354             throw new RuntimeException(
355                     "Processing classConfiguration for class "
356                             + classConfiguration.getHostClassSimpleName() + "failed."
357                             + " Reason: " + e, e);
358         }
359     }
360 
361     private static boolean isSupported(final SupportedBrowser[] browsers, final SupportedBrowser expectedBrowser) {
362         for (final SupportedBrowser browser : browsers) {
363             if (browser == expectedBrowser) {
364                 return true;
365             }
366         }
367         return false;
368     }
369 
370     /**
371      * Returns an immutable map containing the DOM to JavaScript mappings. Keys are
372      * java classes for the various DOM classes (e.g. HtmlInput.class) and the values
373      * are the JavaScript class names (e.g. "HTMLAnchorElement").
374      * @param clazz the class to get the scriptable for
375      * @return the mappings
376      */
377     public Class<? extends HtmlUnitScriptable> getDomJavaScriptMappingFor(final Class<?> clazz) {
378         if (domJavaScriptMap_ == null) {
379             final Map<Class<?>, Class<? extends HtmlUnitScriptable>> map =
380                     new ConcurrentHashMap<>(configuration_.size());
381 
382             final boolean debug = LOG.isDebugEnabled();
383             for (final ClassConfiguration classConfig : configuration_) {
384                 for (final Class<?> domClass : classConfig.getDomClasses()) {
385                     // preload and validate that the class exists
386                     if (debug) {
387                         LOG.debug("Mapping " + domClass.getName() + " to " + classConfig.getClassName());
388                     }
389                     map.put(domClass, classConfig.getHostClass());
390                 }
391             }
392 
393             domJavaScriptMap_ = map;
394         }
395 
396         return domJavaScriptMap_.get(clazz);
397     }
398 
399     protected ClassConfiguration getGlobalThisConfiguration() {
400         return globalThisConfiguration_;
401     }
402 }