View Javadoc
1   /*
2    * Copyright (c) 2002-2025 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;
16  
17  import static org.junit.Assert.fail;
18  
19  import java.io.File;
20  import java.io.IOException;
21  import java.lang.reflect.Constructor;
22  import java.lang.reflect.Method;
23  import java.lang.reflect.Modifier;
24  import java.nio.charset.Charset;
25  import java.nio.charset.StandardCharsets;
26  import java.nio.file.Files;
27  import java.nio.file.Paths;
28  import java.time.LocalDate;
29  import java.util.ArrayList;
30  import java.util.Calendar;
31  import java.util.Iterator;
32  import java.util.List;
33  import java.util.Locale;
34  import java.util.regex.Matcher;
35  import java.util.regex.Pattern;
36  
37  import org.apache.commons.io.FileUtils;
38  import org.apache.commons.lang3.StringUtils;
39  import org.apache.commons.logging.Log;
40  import org.junit.After;
41  import org.junit.AfterClass;
42  import org.junit.Before;
43  import org.junit.BeforeClass;
44  import org.junit.Test;
45  
46  /**
47   * Test of coding style for issues which cannot be detected by Checkstyle.
48   *
49   * @author Ahmed Ashour
50   * @author Ronald Brill
51   * @author Sven Strickroth
52   * @author Kristof Neirynck
53   */
54  public class CodeStyleTest {
55  
56      private static final Charset SOURCE_ENCODING = StandardCharsets.UTF_8;
57      private static final Pattern LEADING_WHITESPACE = Pattern.compile("^\\s+");
58      private static final Pattern LOG_STATIC_STRING =
59                                      Pattern.compile("^\\s*LOG\\.[a-z]+\\(\"[^\"]*\"(, [a-zA-Z_]+)?\\);");
60      private List<String> failures_ = new ArrayList<>();
61      private String title_ = "unknown";
62  
63      /**
64       * After.
65       * @throws IOException in case of error
66       */
67      @After
68      public void after() throws IOException {
69          final StringBuilder sb = new StringBuilder();
70          for (final String error : failures_) {
71              sb.append('\n').append(error);
72          }
73  
74          if (System.getenv("EXPORT_FAILURES") != null) {
75              Files.write(Paths.get("target", title_ + ".txt"), failures_);
76          }
77  
78          final int errorsNumber = failures_.size();
79          if (errorsNumber == 1) {
80              fail(title_ + " error: " + sb);
81          }
82          else if (errorsNumber > 1) {
83              fail(title_ + " " + errorsNumber + " errors: " + sb);
84          }
85      }
86  
87      private void addFailure(final String file, final int line, final String error) {
88          failures_.add(file + ", line " + (line <= 0 ? 1 : line) + ": " + error);
89      }
90  
91      /**
92       * @throws IOException if the test fails
93       */
94      @Test
95      public void codeStyle() throws IOException {
96          title_ = "CodeStyle";
97          final List<File> files = new ArrayList<>();
98          addAll(new File("src/main"), files);
99          addAll(new File("src/test"), files);
100         final List<String> classNames = getClassNames(files);
101         process(files, classNames);
102         // for (final String className : classNames) {
103         //     addFailure("Not used " + className);
104         // }
105 
106         licenseYear();
107         versionYear();
108         parentInPom();
109     }
110 
111     private static List<String> getClassNames(final List<File> files) {
112         final List<String> list = new ArrayList<>();
113         for (final File file : files) {
114             String fileName = file.getName();
115             if (fileName.endsWith(".java")) {
116                 fileName = fileName.substring(0, fileName.length() - 5);
117                 fileName = fileName.substring(fileName.lastIndexOf('.') + 1);
118                 list.add(fileName);
119             }
120         }
121         return list;
122     }
123 
124     private void addAll(final File dir, final List<File> files) throws IOException {
125         final File[] children = dir.listFiles();
126         if (children != null) {
127             for (final File child : children) {
128                 if (child.isDirectory()
129                         && !".git".equals(child.getName())
130                         && !("test".equals(dir.getName()) && "resources".equals(child.getName()))) {
131                     addAll(child, files);
132                 }
133                 else {
134                     files.add(child);
135                 }
136             }
137         }
138     }
139 
140     private void process(final List<File> files, final List<String> classNames) throws IOException {
141         for (final File file : files) {
142             final String relativePath = file.getAbsolutePath().substring(new File(".").getAbsolutePath().length() - 1);
143             if (file.getName().endsWith(".java")) {
144                 final List<String> lines = FileUtils.readLines(file, SOURCE_ENCODING);
145                 openingCurlyBracket(lines, relativePath);
146                 year(lines, relativePath);
147                 javaDocFirstLine(lines, relativePath);
148                 classJavaDoc(lines, relativePath);
149                 methodFirstLine(lines, relativePath);
150                 methodLastLine(lines, relativePath);
151                 lineBetweenMethods(lines, relativePath);
152                 runWith(lines, relativePath);
153                 vs85aspx(lines, relativePath);
154                 deprecated(lines, relativePath);
155                 staticJSMethod(lines, relativePath);
156                 singleAlert(lines, relativePath);
157                 staticLoggers(lines, relativePath);
158                 loggingEnabled(lines, relativePath);
159                 alerts(lines, relativePath);
160                 className(lines, relativePath);
161                 classNameUsed(lines, classNames, relativePath);
162                 spaces(lines, relativePath);
163                 indentation(lines, relativePath);
164             }
165         }
166     }
167 
168     /**
169      * Ensures that no opening curly bracket exists by itself in a single line.
170      */
171     private void openingCurlyBracket(final List<String> lines, final String path) {
172         int index = 1;
173         for (final String line : lines) {
174             if ("{".equals(line.trim())) {
175                 addFailure(path, index, "Opening curly bracket is alone");
176             }
177             index++;
178         }
179     }
180 
181     /**
182      * Checks the year in the source.
183      */
184     private void year(final List<String> lines, final String path) {
185         final int year = Calendar.getInstance(Locale.ROOT).get(Calendar.YEAR);
186         if (lines.size() < 2 || !lines.get(1).contains("Copyright (c) 2002-" + year)) {
187             addFailure(path, lines.size() < 2 ? 0 : 1, "Incorrect year");
188         }
189     }
190 
191     /**
192      * Checks the JavaDoc first line, it should not be empty, and should not start with lower-case.
193      */
194     private void javaDocFirstLine(final List<String> lines, final String relativePath) {
195         for (int index = 1; index < lines.size(); index++) {
196             final String previousLine = lines.get(index - 1);
197             final String currentLine = lines.get(index);
198             if ("/**".equals(previousLine.trim())) {
199                 if ("*".equals(currentLine.trim()) || currentLine.contains("*/")) {
200                     addFailure(relativePath, index + 1, "Empty first line in JavaDoc");
201                 }
202                 if (currentLine.trim().startsWith("*")) {
203                     final String text = currentLine.trim().substring(1).trim();
204                     if (!text.isEmpty() && Character.isLowerCase(text.charAt(0))) {
205                         addFailure(relativePath, index + 1, "Lower case start of text in JavaDoc");
206                     }
207                 }
208             }
209         }
210     }
211 
212     /**
213      * Checks the JavaDoc for class should be superseded with an empty line.
214      */
215     private void classJavaDoc(final List<String> lines, final String relativePath) {
216         for (int index = 1; index < lines.size(); index++) {
217             final String previousLine = lines.get(index - 1);
218             final String currentLine = lines.get(index);
219             if (currentLine.startsWith("/**") && !previousLine.isEmpty()) {
220                 addFailure(relativePath, index, "No empty line the beginning of JavaDoc");
221             }
222         }
223     }
224 
225     /**
226      * Checks the method first line, it should not be empty.
227      */
228     private void methodFirstLine(final List<String> lines, final String relativePath) {
229         for (int index = 0; index < lines.size() - 1; index++) {
230             final String line = lines.get(index);
231             if (StringUtils.isBlank(lines.get(index + 1))
232                 && line.length() > 4 && index > 0 && lines.get(index - 1).startsWith("    ")
233                 && Character.isWhitespace(line.charAt(0)) && line.endsWith("{")
234                 && !line.contains(" class ") && !line.contains(" interface ") && !line.contains(" @interface ")
235                 && (!Character.isWhitespace(line.charAt(4))
236                     || line.trim().startsWith("public") || line.trim().startsWith("protected")
237                     || line.trim().startsWith("private"))) {
238                 addFailure(relativePath, index + 2, "Empty line");
239             }
240         }
241     }
242 
243     /**
244      * Checks the method last line, it should not be empty.
245      */
246     private void methodLastLine(final List<String> lines, final String relativePath) {
247         for (int index = 0; index < lines.size() - 1; index++) {
248             final String line = lines.get(index);
249             final String nextLine = lines.get(index + 1);
250             if (StringUtils.isBlank(line) && "    }".equals(nextLine)) {
251                 addFailure(relativePath, index + 1, "Empty line");
252             }
253         }
254     }
255 
256     /**
257      * Checks that empty line must exist between consecutive methods.
258      */
259     private void lineBetweenMethods(final List<String> lines, final String relativePath) {
260         for (int index = 0; index < lines.size() - 1; index++) {
261             final String line = lines.get(index);
262             final String nextLine = lines.get(index + 1);
263             if ("    }".equals(line) && !nextLine.isEmpty() && !"}".equals(nextLine)) {
264                 addFailure(relativePath, index + 1, "Non-empty line");
265             }
266             if (nextLine.trim().equals("/**") && line.trim().equals("}")) {
267                 addFailure(relativePath, index + 2, "Non-empty line");
268             }
269         }
270     }
271 
272     /**
273      * @throws Exception if an error occurs
274      */
275     @Test
276     public void xmlStyle() throws Exception {
277         title_ = "XMLStyle";
278         processXML(new File("."), false);
279         processXML(new File("src/main/resources"), true);
280         processXML(new File("src/assembly"), true);
281         processXML(new File("src/changes"), true);
282     }
283 
284     private void processXML(final File dir, final boolean recursive) throws Exception {
285         final File[] files = dir.listFiles();
286         if (files != null) {
287             for (final File file : files) {
288                 if (file.isDirectory() && !".git".equals(file.getName())) {
289                     if (recursive) {
290                         processXML(file, true);
291                     }
292                 }
293                 else {
294                     if (file.getName().endsWith(".xml")) {
295                         final List<String> lines = FileUtils.readLines(file, SOURCE_ENCODING);
296                         final String relativePath = file.getAbsolutePath().substring(
297                                 new File(".").getAbsolutePath().length() - 1);
298                         mixedIndentation(lines, relativePath);
299                         trailingWhitespace(lines, relativePath);
300                         badIndentationLevels(lines, relativePath);
301                     }
302                 }
303             }
304         }
305     }
306 
307     /**
308      * Verifies that no XML files have mixed indentation (tabs and spaces, mixed).
309      */
310     private void mixedIndentation(final List<String> lines, final String relativePath) {
311         for (int i = 0; i < lines.size(); i++) {
312             final String line = lines.get(i);
313             if (line.indexOf('\t') != -1) {
314                 addFailure(relativePath, i + 1, "Mixed indentation");
315             }
316         }
317     }
318 
319     /**
320      * Verifies that no XML files have trailing whitespace.
321      */
322     private void trailingWhitespace(final List<String> lines, final String relativePath) {
323         for (int i = 0; i < lines.size(); i++) {
324             final String line = lines.get(i);
325             if (!line.isEmpty()) {
326                 final char last = line.charAt(line.length() - 1);
327                 if (Character.isWhitespace(last)) {
328                     addFailure(relativePath, i + 1, "Trailing whitespace");
329                 }
330             }
331         }
332     }
333 
334     /**
335      * Verifies that no XML files have bad indentation levels (each indentation level is 4 spaces).
336      */
337     private void badIndentationLevels(final List<String> lines, final String relativePath) {
338         for (int i = 0; i < lines.size(); i++) {
339             final int indentation = getIndentation(lines.get(i));
340             if (indentation % 4 != 0) {
341                 addFailure(relativePath, i + 1, "Bad indentation level (" + indentation + ")");
342             }
343         }
344     }
345 
346     /**
347      * Checks the year in {@code LICENSE.txt}.
348      */
349     private void licenseYear() throws IOException {
350         final List<String> lines = FileUtils.readLines(new File("checkstyle.xml"), SOURCE_ENCODING);
351         boolean check = false;
352         final String copyright = "Copyright (c) 2002-" + LocalDate.now().getYear();
353         for (final String line : lines) {
354             if (line.contains("<property name=\"header\"")) {
355                 if (!line.contains(copyright)) {
356                     addFailure("checkstyle.xml", 0, "Incorrect year in checkstyle.xml");
357                 }
358                 check = true;
359             }
360         }
361         if (!check) {
362             addFailure("checkstyle.xml", 0, "No \"header\" found");
363         }
364     }
365 
366     /**
367      * Checks the year in the {@link Version}.
368      */
369     private void versionYear() throws IOException {
370         final List<String> lines =
371                 FileUtils.readLines(new File("src/main/java/org/htmlunit/Version.java"),
372                         SOURCE_ENCODING);
373         for (final String line : lines) {
374             if (line.contains("return \"Copyright (c) 2002-" + Calendar.getInstance(Locale.ROOT).get(Calendar.YEAR))) {
375                 return;
376             }
377         }
378         addFailure("src/main/java/org/htmlunit/Version.java", 0, "Incorrect year in Version.getCopyright()");
379     }
380 
381     /**
382      * Verifies no &lt;parent&gt; tag in {@code pom.xml}.
383      */
384     private void parentInPom() throws IOException {
385         final List<String> lines = FileUtils.readLines(new File("pom.xml"), SOURCE_ENCODING);
386         for (int i = 0; i < lines.size(); i++) {
387             if (lines.get(i).contains("<parent>")) {
388                 addFailure("pom.xml", i + 1, "'pom.xml' should not have <parent> tag");
389                 break;
390             }
391         }
392     }
393 
394     /**
395      * Verifies that no direct instantiation of WebClient from a test that runs with BrowserRunner.
396      */
397     private void runWith(final List<String> lines, final String relativePath) {
398         if (relativePath.replace('\\', '/').contains("src/test/java")
399                 && !relativePath.contains("CodeStyleTest")
400                 && !relativePath.contains("WebClient9Test")
401                 && !relativePath.contains("FaqTest")) {
402             boolean runWith = false;
403             boolean browserNone = true;
404             int index = 1;
405             for (final String line : lines) {
406                 if (line.contains("@RunWith(BrowserRunner.class)")) {
407                     runWith = true;
408                 }
409                 if (line.contains("@Test")) {
410                     browserNone = false;
411                 }
412                 if (relativePath.contains("JavaScriptEngineTest") && line.contains("nonStandardBrowserVersion")) {
413                     browserNone = true;
414                 }
415                 if (runWith) {
416                     if (!browserNone && line.contains("new WebClient(") && !line.contains("getBrowserVersion()")) {
417                         addFailure(relativePath, index,
418                                 "Never directly instantiate WebClient, please use getWebClient() instead.");
419                     }
420                     if (line.contains("notYetImplemented()")) {
421                         addFailure(relativePath, index, "Use @NotYetImplemented instead of notYetImplemented()");
422                     }
423                 }
424                 index++;
425             }
426         }
427     }
428 
429     /**
430      * Verifies that no "(VS.85).aspx" token exists (which is sometimes used in MSDN documentation).
431      */
432     private void vs85aspx(final List<String> lines, final String relativePath) {
433         if (!relativePath.contains("CodeStyleTest")) {
434             int i = 0;
435             for (final String line : lines) {
436                 if (line.contains("(VS.85).aspx")) {
437                     addFailure(relativePath, i + 1, "Please remove \"(VS.85)\" from the URL");
438                 }
439                 i++;
440             }
441         }
442     }
443 
444     /**
445      * Verifies that deprecated tag is followed by "As of " or "since ", and '@Deprecated' annotation follows.
446      */
447     private void deprecated(final List<String> lines, final String relativePath) {
448         int i = 0;
449         for (String line : lines) {
450             line = line.trim().toLowerCase(Locale.ROOT);
451             if (line.startsWith("* @deprecated")) {
452                 if (!line.startsWith("* @deprecated as of ") && !line.startsWith("* @deprecated since ")) {
453                     addFailure(relativePath, i + 1,
454                             "@deprecated must be immediately followed by \"As of \" or \"since \"");
455                 }
456                 if (!getAnnotations(lines, i).contains("@Deprecated")) {
457                     addFailure(relativePath, i + 1, "No \"@Deprecated\" annotation");
458                 }
459             }
460             i++;
461         }
462     }
463 
464     /**
465      * Returns all annotation lines that comes after the given 'javadoc' line.
466      * @param lines source code lines
467      * @param index the index to start searching from, must be a 'javadoc' line.
468      */
469     private static List<String> getAnnotations(final List<String> lines, int index) {
470         final List<String> annotations = new ArrayList<>();
471         while (!lines.get(index++).trim().endsWith("*/")) {
472             //empty;
473         }
474         while (lines.get(index).trim().startsWith("@")) {
475             annotations.add(lines.get(index++).trim());
476         }
477         return annotations;
478     }
479 
480     /**
481      * Verifies that no static JavaScript method exists.
482      */
483     private void staticJSMethod(final List<String> lines, final String relativePath) {
484         if (relativePath.endsWith("Console.java")) {
485             return;
486         }
487         int i = 0;
488         for (final String line : lines) {
489             if (line.contains(" static ")
490                     && (line.contains(" jsxFunction_") || line.contains(" jsxGet_") || line.contains(" jsxSet_"))
491                     && !line.contains(" jsxFunction_write") && !line.contains(" jsxFunction_insertBefore")
492                     && !line.contains(" jsxFunction_drawImage")) {
493                 addFailure(relativePath, i + 1, "Use of static JavaScript function");
494             }
495             i++;
496         }
497     }
498 
499     /**
500      * Single @Alert does not need curly brackets.
501      */
502     private void singleAlert(final List<String> lines, final String relativePath) {
503         int i = 0;
504         for (final String line : lines) {
505             if (line.trim().startsWith("@Alerts") && line.contains("@Alerts({") && line.contains("})")) {
506                 final String alert = line.substring(line.indexOf('{'), line.indexOf('}'));
507                 if (!alert.contains(",") && alert.contains("\"")
508                         && alert.indexOf('"', alert.indexOf('"') + 1) != -1) {
509                     addFailure(relativePath, i + 1, "No need for curly brackets");
510                 }
511             }
512             i++;
513         }
514     }
515 
516     /**
517      * Verifies that only static loggers exist.
518      */
519     private void staticLoggers(final List<String> lines, final String relativePath) {
520         int i = 0;
521         final String logClassName = Log.class.getSimpleName();
522         for (String line : lines) {
523             line = line.trim();
524             if (line.contains(" " + logClassName + " ")
525                     && !line.contains(" LOG ")
526                     && !line.contains(" static ")
527                     && !line.startsWith("//")
528                     && !line.contains("webConsoleLogger_") // this one is there by design
529                     && !line.contains("(final Log logger)") // logger as parameter
530                     && !line.contains("httpclient.wire")) {
531                 addFailure(relativePath, i + 1, "Non-static logger detected");
532             }
533             i++;
534         }
535     }
536 
537     /**
538      * Verifies that there is code to check log enablement.
539      * <p> For example,
540      * <code><pre>
541      *    if (log.isDebugEnabled()) {
542      *        ... do something expensive ...
543      *        log.debug(theResult);
544      *    }
545      * </pre></code>
546      * </p>
547      */
548     private void loggingEnabled(final List<String> lines, final String relativePath) {
549         if (relativePath.contains("CodeStyleTest")) {
550             return;
551         }
552         int i = 0;
553         for (final String line : lines) {
554             if (line.contains("LOG.trace(") && !LOG_STATIC_STRING.matcher(line).matches()) {
555                 loggingEnabled(lines, i, "Trace", relativePath);
556             }
557             else if (line.contains("LOG.debug(") && !LOG_STATIC_STRING.matcher(line).matches()) {
558                 loggingEnabled(lines, i, "Debug", relativePath);
559             }
560             i++;
561         }
562     }
563 
564     private void loggingEnabled(final List<String> lines, final int index, final String method,
565             final String relativePath) {
566         final int indentation = getIndentation(lines.get(index));
567         for (int i = index - 1; i >= 0; i--) {
568             final String line = lines.get(i);
569             if (getIndentation(line) < indentation && line.contains("LOG.is" + method + "Enabled()")) {
570                 return;
571             }
572             if (getIndentation(line) == 4) { // a method
573                 addFailure(relativePath, index + 1, "Must be inside a \"if (LOG.is" + method + "Enabled())\" check");
574                 return;
575             }
576         }
577     }
578 
579     private static int getIndentation(final String line) {
580         final Matcher matcher = LEADING_WHITESPACE.matcher(line);
581         if (matcher.find()) {
582             return matcher.end() - matcher.start();
583         }
584         return 0;
585     }
586 
587     /**
588      * Verifies that \@Alerts is correctly defined.
589      */
590     private void alerts(final List<String> lines, final String relativePath) {
591         for (int i = 0; i < lines.size(); i++) {
592             if (lines.get(i).startsWith("    @Alerts(")) {
593                 final List<String> alerts = alertsToList(lines, i, true);
594                 alertVerify(alerts, relativePath, i);
595             }
596         }
597     }
598 
599     /**
600      * Verifies that the class name is used.
601      */
602     private static void classNameUsed(final List<String> lines, final List<String> classNames,
603             final String relativePath) {
604         String simpleName = relativePath.substring(0, relativePath.length() - 5);
605         simpleName = simpleName.substring(simpleName.lastIndexOf(File.separator) + 1);
606         for (final String line : lines) {
607             for (final Iterator<String> it = classNames.iterator(); it.hasNext();) {
608                 final String className = it.next();
609                 if (line.contains(className) && !className.equals(simpleName)) {
610                     it.remove();
611                 }
612             }
613         }
614     }
615 
616     /**
617      * Verifies that the class name is used.
618      */
619     private void className(final List<String> lines, final String relativePath) {
620         if (relativePath.contains("main") && relativePath.contains("host")) {
621             String fileName = relativePath.substring(0, relativePath.length() - 5);
622             fileName = fileName.substring(fileName.lastIndexOf(File.separator) + 1);
623             String wrongName = null;
624             int i = 0;
625             for (final String line : lines) {
626                 if (line.startsWith(" * ")) {
627                     int p0 = line.indexOf("{@code ");
628                     if (p0 != -1) {
629                         p0 = p0 + "{@code ".length();
630                         final int p1 = line.indexOf('}', p0 + 1);
631                         final String name = line.substring(p0, p1);
632                         if (!name.equals(fileName)) {
633                             wrongName = name;
634                         }
635                     }
636                 }
637                 else if (line.startsWith("@JsxClass")) {
638                     int p0 = line.indexOf("className = \"");
639                     if (p0 != -1) {
640                         p0 = p0 + "className = \"".length();
641                         final int p1 = line.indexOf("\"", p0 + 1);
642                         String name = line.substring(p0, p1);
643                         // JsxClass starts with lower case
644                         if (Character.isLowerCase(name.charAt(0))) {
645                             name = StringUtils.capitalize(name);
646                             if (name.equals(fileName)) {
647                                 wrongName = null;
648                             }
649                         }
650                     }
651                 }
652                 else if (line.startsWith("public class")) {
653                     if (wrongName != null) {
654                         addFailure(relativePath, i + 1, "Incorrect host class '" + wrongName + "'");
655                     }
656                     return;
657                 }
658                 ++i;
659             }
660         }
661     }
662 
663     /**
664      * Returns array of String of the alerts which are in the specified index.
665      *
666      * @param lines the list of strings
667      * @param alertsIndex the index in which the \@Alerts is defined
668      * @return array of alert strings
669      */
670     public static List<String> alertsToList(final List<String> lines, final int alertsIndex) {
671         return alertsToList(lines, alertsIndex, false);
672     }
673 
674     private static List<String> alertsToList(final List<String> lines, final int alertsIndex,
675             final boolean preserveCommas) {
676         if ("    @Alerts".equals(lines.get(alertsIndex))) {
677             lines.set(alertsIndex, "    @Alerts()");
678         }
679         if (!lines.get(alertsIndex).startsWith("    @Alerts(")) {
680             throw new IllegalArgumentException("No @Alerts found in " + (alertsIndex + 1));
681         }
682         final StringBuilder alerts = new StringBuilder();
683         for (int i = alertsIndex;; i++) {
684             final String line = lines.get(i);
685             if (alerts.length() != 0) {
686                 alerts.append('\n');
687             }
688             if (line.startsWith("    @Alerts(")) {
689                 alerts.append(line.substring("    @Alerts(".length()));
690             }
691             else {
692                 alerts.append(line);
693             }
694             if (line.endsWith(")")) {
695                 alerts.deleteCharAt(alerts.length() - 1);
696                 break;
697             }
698         }
699         final List<String> list = alertsToList(alerts.toString());
700         if (!preserveCommas) {
701             for (int i = 0; i < list.size(); i++) {
702                 String value = list.get(i);
703                 if (value.startsWith(",")) {
704                     value = value.substring(1).trim();
705                 }
706                 list.set(i, value);
707             }
708         }
709         return list;
710     }
711 
712     /**
713      * Verifies a specific \@Alerts definition.
714      */
715     private void alertVerify(final List<String> alerts, final String relativePath, final int lineIndex) {
716         if (alerts.size() == 1) {
717             if (alerts.get(0).contains("DEFAULT")) {
718                 addFailure(relativePath, lineIndex + 1, "No need for \"DEFAULT\"");
719             }
720         }
721         else {
722             final List<String> names = new ArrayList<>();
723             for (final String alert : alerts) {
724                 String cleanedAlert = alert;
725                 if (alert.charAt(0) == ',') {
726                     if (alert.charAt(1) != '\n') {
727                         addFailure(relativePath, lineIndex + 1, "Expectation must be in a separate line");
728                     }
729                     cleanedAlert = alert.substring(1).trim();
730                 }
731 
732                 final int quoteIndex = cleanedAlert.indexOf('"');
733                 final int equalsIndex = cleanedAlert.indexOf('=');
734                 if (equalsIndex != -1 && equalsIndex < quoteIndex) {
735                     final String name = cleanedAlert.substring(0, equalsIndex - 1);
736                     alertVerifyOrder(name, names, relativePath, lineIndex);
737                     names.add(name);
738                 }
739             }
740         }
741     }
742 
743     /**
744      * Converts the given alerts definition to an array of expressions.
745      */
746     private static List<String> alertsToList(final String string) {
747         final List<String> list = new ArrayList<>();
748         if ("\"\"".equals(string)) {
749             list.add(string);
750         }
751         else {
752             final StringBuilder currentToken = new StringBuilder();
753 
754             boolean insideString = true;
755             boolean startsWithBraces = false;
756             for (final String token : string.split("(?<!\\\\)\"")) {
757                 insideString = !insideString;
758                 if (currentToken.length() != 0) {
759                     currentToken.append('"');
760                 }
761                 else {
762                     startsWithBraces = token.toString().contains("{");
763                 }
764 
765                 if (!insideString && token.startsWith(",") && !startsWithBraces) {
766                     list.add(currentToken.toString());
767                     currentToken.setLength(0);
768                     startsWithBraces = token.toString().contains("{");
769                 }
770 
771                 if (!insideString && token.contains("}")) {
772                     final int curlyIndex = token.indexOf('}') + 1;
773                     currentToken.append(token, 0, curlyIndex);
774                     list.add(currentToken.toString());
775                     currentToken.setLength(0);
776                     currentToken.append(token, curlyIndex, token.length());
777                 }
778                 else {
779                     if (!insideString && token.contains(",") && !startsWithBraces) {
780                         final String[] expressions = token.split(",");
781                         currentToken.append(expressions[0]);
782                         if (currentToken.length() != 0) {
783                             list.add(currentToken.toString());
784                         }
785                         for (int i = 1; i < expressions.length - 1; i++) {
786                             list.add(',' + expressions[i]);
787                         }
788                         currentToken.setLength(0);
789                         currentToken.append(',' + expressions[expressions.length - 1]);
790                     }
791                     else {
792                         currentToken.append(token);
793                     }
794                 }
795             }
796             if (currentToken.length() != 0) {
797                 if (!currentToken.toString().contains("\"")) {
798                     currentToken.insert(0, '"');
799                 }
800                 int totalQuotes = 0;
801                 for (int i = 0; i < currentToken.length(); i++) {
802                     if (currentToken.charAt(i) == '"' && (i == 0 || currentToken.charAt(i - 1) != '\\')) {
803                         totalQuotes++;
804                     }
805                 }
806                 if (totalQuotes % 2 != 0) {
807                     currentToken.append('"');
808                 }
809 
810                 list.add(currentToken.toString());
811             }
812         }
813         return list;
814     }
815 
816     /**
817      * Verifies \@Alerts specific order.
818      *
819      * @param browserName the browser name
820      * @param previousList the previously defined browser names
821      */
822     private void alertVerifyOrder(final String browserName, final List<String> previousList,
823             final String relativePath, final int lineIndex) {
824         switch (browserName) {
825             case "DEFAULT":
826                 if (!previousList.isEmpty()) {
827                     addFailure(relativePath, lineIndex + 1, "DEFAULT must come first");
828                 }
829                 break;
830 
831             default:
832         }
833     }
834 
835     /**
836      * Verifies that no extra leading spaces (in test code).
837      */
838     private void spaces(final List<String> lines, final String relativePath) {
839         for (int i = 0; i + 1 < lines.size(); i++) {
840             String line = lines.get(i).trim();
841             String next = lines.get(i + 1).trim();
842             if (line.startsWith("+ \"") && next.startsWith("+ \"")) {
843                 line = line.substring(3);
844                 final String lineTrimmed = line.trim();
845                 next = next.substring(3);
846                 if (lineTrimmed.startsWith("<") && next.trim().startsWith("<") || lineTrimmed.startsWith("try")
847                         || lineTrimmed.startsWith("for") || lineTrimmed.startsWith("function")
848                         || lineTrimmed.startsWith("if")) {
849                     final int difference = getInitialSpaces(next) - getInitialSpaces(line);
850                     if (difference > 2) {
851                         addFailure(relativePath, i + 2, "Too many initial spaces");
852                     }
853                     else if (difference == 1) {
854                         addFailure(relativePath, i + 2, "Add one more space");
855                     }
856                 }
857             }
858         }
859     }
860 
861     /**
862      * Verifies the indentation of the expectations.
863      */
864     private void indentation(final List<String> lines, final String relativePath) {
865         for (int i = 0; i + 1 < lines.size(); i++) {
866             final String line = lines.get(i);
867             if (line.startsWith("        CHROME = ")
868                     || line.startsWith("        EDGE = ")
869                     || line.startsWith("        FF = ")
870                     || line.startsWith("        FF_ESR = ")) {
871                 addFailure(relativePath, i + 2, "Incorrect indentation");
872             }
873         }
874     }
875 
876     private static int getInitialSpaces(final String s) {
877         int spaces = 0;
878         while (spaces < s.length() && s.charAt(spaces) == ' ') {
879             spaces++;
880         }
881         return spaces;
882     }
883 
884     /**
885      * Tests if all JUnit 4 candidate test methods declare <tt>@Test</tt> annotation.
886      * @throws Exception if the test fails
887      */
888     @Test
889     public void tests() throws Exception {
890         title_ = "Tests";
891         testTests(new File("src/test/java"));
892     }
893 
894     private void testTests(final File dir) throws Exception {
895         final File[] files = dir.listFiles();
896         if (files == null) {
897             return;
898         }
899 
900         for (final File file : files) {
901             if (file.isDirectory()) {
902                 if (!".git".equals(file.getName())) {
903                     testTests(file);
904                 }
905             }
906             else {
907                 if (file.getName().endsWith(".java")) {
908                     final int index = new File("src/test/java").getAbsolutePath().length();
909                     String name = file.getAbsolutePath();
910                     name = name.substring(index + 1, name.length() - 5);
911                     name = name.replace(File.separatorChar, '.');
912                     final Class<?> clazz;
913                     try {
914                         clazz = Class.forName(name);
915                     }
916                     catch (final Exception e) {
917                         continue;
918                     }
919                     name = file.getName();
920                     if (name.endsWith("Test.java") || name.endsWith("TestCase.java")) {
921                         for (final Constructor<?> ctor : clazz.getConstructors()) {
922                             if (ctor.getParameterTypes().length == 0) {
923                                 for (final Method method : clazz.getDeclaredMethods()) {
924                                     if (Modifier.isPublic(method.getModifiers())
925                                             && method.getAnnotation(Before.class) == null
926                                             && method.getAnnotation(BeforeClass.class) == null
927                                             && method.getAnnotation(After.class) == null
928                                             && method.getAnnotation(AfterClass.class) == null
929                                             && method.getAnnotation(Test.class) == null
930                                             && method.getReturnType() == Void.TYPE
931                                             && method.getParameterTypes().length == 0) {
932                                         final List<String> lines = FileUtils.readLines(file, SOURCE_ENCODING);
933                                         int line = -1;
934                                         for (int i = 0; i < lines.size(); ++i) {
935                                             if (lines.get(i).contains("public void " + method.getName() + "()")) {
936                                                 line = i + 1;
937                                                 break;
938                                             }
939                                         }
940                                         addFailure(file.toString().replace('\\', '/'), line,
941                                                 "Method '" + method.getName() + "' does not declare @Test annotation");
942                                     }
943                                 }
944                             }
945                         }
946                     }
947                 }
948             }
949         }
950     }
951 
952 }