Merge branch 'develop' into bug/JAL-4235_gradle_task_jalviewjsTranspile_does_not_fail...
[jalview.git] / utils / HelpLinksChecker.java
1 /*
2  * Jalview - A Sequence Alignment Editor and Viewer ($$Version-Rel$$)
3  * Copyright (C) $$Year-Rel$$ The Jalview Authors
4  * 
5  * This file is part of Jalview.
6  * 
7  * Jalview is free software: you can redistribute it and/or
8  * modify it under the terms of the GNU General Public License 
9  * as published by the Free Software Foundation, either version 3
10  * of the License, or (at your option) any later version.
11  *  
12  * Jalview is distributed in the hope that it will be useful, but 
13  * WITHOUT ANY WARRANTY; without even the implied warranty 
14  * of MERCHANTABILITY or FITNESS FOR A PARTICULAR 
15  * PURPOSE.  See the GNU General Public License for more details.
16  * 
17  * You should have received a copy of the GNU General Public License
18  * along with Jalview.  If not, see <http://www.gnu.org/licenses/>.
19  * The Jalview Authors are detailed in the 'AUTHORS' file.
20  */
21 import java.io.BufferedReader;
22 import java.io.File;
23 import java.io.FileReader;
24 import java.io.IOException;
25 import java.io.InputStream;
26 import java.net.URL;
27 import java.util.HashMap;
28 import java.util.Map;
29
30 /**
31  * A class to check help file cross-references, and external URLs if internet
32  * access is available
33  * 
34  * @author gmcarstairs
35  *
36  */
37 public class HelpLinksChecker implements BufferedLineReader.LineCleaner
38 {
39   private static final String HELP_HS = "help.hs";
40
41   private static final String HELP_TOC_XML = "helpTOC.xml";
42
43   private static final String HELP_JHM = "help.jhm";
44
45   private static boolean internetAvailable = true;
46
47   private int targetCount = 0;
48
49   private int mapCount = 0;
50
51   private int internalHrefCount = 0;
52
53   private int anchorRefCount = 0;
54
55   private int invalidAnchorRefCount = 0;
56
57   private int externalHrefCount = 0;
58
59   private int invalidMapUrlCount = 0;
60
61   private int invalidTargetCount = 0;
62
63   private int invalidImageCount = 0;
64
65   private int invalidInternalHrefCount = 0;
66
67   private int invalidExternalHrefCount = 0;
68
69   /**
70    * The only parameter should be a path to the root of the help directory in
71    * the workspace
72    * 
73    * @param args
74    *          [0] path to the /html folder in the workspace
75    * @param args
76    *          [1] (optional) -nointernet to suppress external link checking for
77    *          a fast check of internal links only
78    * @throws IOException
79    */
80   public static void main(String[] args) throws IOException
81   {
82     if (args.length == 0 || args.length > 2
83             || (args.length == 2 && !args[1].equals("-nointernet")))
84     {
85       log("Usage: <pathToHelpFolder> [-nointernet]");
86       return;
87     }
88
89     if (args.length == 2)
90     {
91       internetAvailable = false;
92     }
93
94     new HelpLinksChecker().checkLinks(args[0]);
95   }
96
97   /**
98    * Checks help links and reports results
99    * 
100    * @param helpDirectoryPath
101    * @throws IOException
102    */
103   void checkLinks(String helpDirectoryPath) throws IOException
104   {
105     log("Checking help file links");
106     File helpFolder = new File(helpDirectoryPath).getCanonicalFile();
107     if (!helpFolder.exists())
108     {
109       log("Can't find " + helpDirectoryPath);
110       return;
111     }
112
113     internetAvailable &= connectToUrl("http://www.example.org");
114
115     Map<String, String> tocTargets = checkHelpMappings(helpFolder);
116
117     Map<String, String> unusedTargets = new HashMap<String, String>(
118             tocTargets);
119
120     checkTableOfContents(helpFolder, tocTargets, unusedTargets);
121
122     checkHelpSet(helpFolder, tocTargets, unusedTargets);
123
124     checkHtmlFolder(new File(helpFolder, "html"));
125
126     reportResults(unusedTargets);
127   }
128
129   /**
130    * Checks all html files in the given directory or its sub-directories
131    * 
132    * @param folder
133    * @throws IOException
134    */
135   private void checkHtmlFolder(File folder) throws IOException
136   {
137     File[] files = folder.listFiles();
138     for (File f : files)
139     {
140       if (f.isDirectory())
141       {
142         checkHtmlFolder(f);
143       }
144       else
145       {
146         if (f.getAbsolutePath().endsWith(".html"))
147         {
148           checkHtmlFile(f, folder);
149         }
150       }
151     }
152   }
153
154   /**
155    * Checks that any image attribute in help.hs is a valid target
156    * 
157    * @param helpFolder
158    * @param tocTargets
159    * @param unusedTargets
160    *          used targets are removed from here
161    */
162   private void checkHelpSet(File helpFolder,
163           Map<String, String> tocTargets, Map<String, String> unusedTargets)
164           throws IOException
165   {
166     BufferedReader br = new BufferedReader(new FileReader(new File(
167             helpFolder, HELP_HS)));
168     String data = br.readLine();
169     int lineNo = 0;
170
171     while (data != null)
172     {
173       lineNo++;
174       String image = getAttribute(data, "image");
175       if (image != null)
176       {
177         unusedTargets.remove(image);
178         if (!tocTargets.containsKey(image))
179         {
180           log(String.format("Invalid image '%s' at line %d of %s", image,
181                   lineNo, HELP_HS));
182           invalidImageCount++;
183         }
184       }
185       data = br.readLine();
186     }
187     br.close();
188   }
189
190   /**
191    * Print counts to sysout
192    * 
193    * @param unusedTargets
194    */
195   private void reportResults(Map<String, String> unusedTargets)
196   {
197     log("\nResults:");
198     log(targetCount + " distinct help targets");
199     log(mapCount + " help mappings");
200     log(invalidTargetCount + " invalid targets");
201     log(unusedTargets.size() + " unused targets");
202     for (String target : unusedTargets.keySet())
203     {
204       log(String.format("    %s: %s", target, unusedTargets.get(target)));
205     }
206     log(invalidMapUrlCount + " invalid map urls");
207     log(invalidImageCount + " invalid image attributes");
208     log(String.format("%d internal href links (%d with anchors)",
209             internalHrefCount, anchorRefCount));
210     log(invalidInternalHrefCount + " invalid internal href links");
211     log(invalidAnchorRefCount + " invalid internal anchor links");
212     log(externalHrefCount + " external href links");
213     if (internetAvailable)
214     {
215       log(invalidExternalHrefCount + " invalid external href links");
216     }
217     else
218     {
219       System.out
220               .println("External links not verified as internet not available");
221     }
222     if (invalidInternalHrefCount > 0 || invalidExternalHrefCount > 0
223             || invalidImageCount > 0 || invalidAnchorRefCount > 0)
224     {
225       log("*** Failed ***");
226       System.exit(1);
227     }
228     log("*** Success ***");
229   }
230
231   /**
232    * @param s
233    */
234   static void log(String s)
235   {
236     System.out.println(s);
237   }
238
239   /**
240    * Reads the given html file and checks any href attibute values are either
241    * <ul>
242    * <li>a valid relative file path, or</li>
243    * <li>a valid absolute URL (if external link checking is enabled)</li>
244    * </ul>
245    * 
246    * @param htmlFile
247    * @param htmlFolder
248    *          the parent folder (for validation of relative paths)
249    */
250   private void checkHtmlFile(File htmlFile, File htmlFolder)
251           throws IOException
252   {
253     BufferedReader br = new BufferedReader(new FileReader(htmlFile));
254     String data = br.readLine();
255     int lineNo = 0;
256     while (data != null)
257     {
258       lineNo++;
259       String href = getAttribute(data, "href");
260       if (href != null)
261       {
262         String anchor = null;
263         int anchorPos = href.indexOf("#");
264         if (anchorPos != -1)
265         {
266           anchor = href.substring(anchorPos + 1);
267           href = href.substring(0, anchorPos);
268         }
269         boolean badLink = false;
270         if (href.startsWith("http"))
271         {
272           externalHrefCount++;
273           if (internetAvailable)
274           {
275             if (!connectToUrl(href))
276             {
277               badLink = true;
278               invalidExternalHrefCount++;
279             }
280           }
281         }
282         else
283         {
284           internalHrefCount++;
285           String relFile = System.getProperty("os.name").indexOf("Win") > -1 ? href.replace("/", File.separator) : href;
286           File hrefFile = href.equals("") ? htmlFile : new File(htmlFolder,
287                   href);
288           if (hrefFile != htmlFile && !fileExists(hrefFile, relFile))
289           {
290             badLink = true;
291             invalidInternalHrefCount++;
292           }
293           if (anchor != null)
294           {
295             anchorRefCount++;
296             if (!badLink)
297             {
298               if (!checkAnchorExists(hrefFile, anchor))
299               {
300                 log(String.format("Invalid anchor: %s at line %d of %s",
301                         anchor, lineNo, getPath(htmlFile)));
302                 invalidAnchorRefCount++;
303               }
304             }
305           }
306         }
307         if (badLink)
308         {
309           log(String.format("Invalid href %s at line %d of %s", href,
310                   lineNo, getPath(htmlFile)));
311         }
312       }
313       data = br.readLine();
314     }
315     br.close();
316   }
317
318   /**
319    * Performs a case-sensitive check that the href'd file exists
320    * 
321    * @param hrefFile
322    * @return
323    * @throws IOException
324    */
325   boolean fileExists(File hrefFile, String href) throws IOException
326   {
327     if (!hrefFile.exists())
328     {
329       return false;
330     }
331
332     /*
333      * On Mac or Windows, file.exists() is not case sensitive, so do an
334      * additional check with case sensitivity 
335      */
336     int slashPos = href.lastIndexOf(File.separator);
337     String expectedFileName = slashPos == -1 ? href : href
338             .substring(slashPos + 1);
339     String cp = hrefFile.getCanonicalPath();
340     slashPos = cp.lastIndexOf(File.separator);
341     String actualFileName = slashPos == -1 ? cp : cp
342             .substring(slashPos + 1);
343
344     return expectedFileName.equals(actualFileName);
345   }
346
347   /**
348    * Reads the file and checks for the presence of the given html anchor
349    * 
350    * @param hrefFile
351    * @param anchor
352    * @return true if anchor is found else false
353    */
354   private boolean checkAnchorExists(File hrefFile, String anchor)
355   {
356     String nameAnchor = "<a name=\"" + anchor + "\"";
357     String idAnchor = "<a id=\"" + anchor + "\"";
358     boolean found = false;
359     try
360     {
361       BufferedReader br = new BufferedReader(new FileReader(hrefFile));
362       BufferedLineReader blr = new BufferedLineReader(br, 3, this);
363       String data = blr.read();
364       while (data != null)
365       {
366         if (data.contains(nameAnchor) || data.contains(idAnchor))
367         {
368           found = true;
369           break;
370         }
371         data = blr.read();
372       }
373       br.close();
374     } catch (IOException e)
375     {
376       // ignore
377     }
378     return found;
379   }
380
381   /**
382    * Returns the part of the file path starting from /help/
383    * 
384    * @param helpFile
385    * @return
386    */
387   private String getPath(File helpFile)
388   {
389     String path = helpFile.getPath();
390     int helpPos = path.indexOf("/help/");
391     return helpPos == -1 ? path : path.substring(helpPos);
392   }
393
394   /**
395    * Returns true if the URL returns an input stream, or false if the URL
396    * returns an error code or we cannot connect to it (e.g. no internet
397    * available)
398    * 
399    * @param url
400    * @return
401    */
402   private boolean connectToUrl(String url)
403   {
404     try
405     {
406       URL u = new URL(url);
407       InputStream connection = u.openStream();
408       connection.close();
409       return true;
410     } catch (Throwable t)
411     {
412       return false;
413     }
414   }
415
416   /**
417    * Reads file help.jhm and checks that
418    * <ul>
419    * <li>each target attribute is in tocTargets</li>
420    * <li>each url attribute is a valid relative file link</li>
421    * </ul>
422    * 
423    * @param helpFolder
424    */
425   private Map<String, String> checkHelpMappings(File helpFolder)
426           throws IOException
427   {
428     Map<String, String> targets = new HashMap<String, String>();
429     BufferedReader br = new BufferedReader(new FileReader(new File(
430             helpFolder, HELP_JHM)));
431     String data = br.readLine();
432     int lineNo = 0;
433     while (data != null)
434     {
435       lineNo++;
436
437       /*
438        * record target, check for duplicates
439        */
440       String target = getAttribute(data, "target");
441       if (target != null)
442       {
443         mapCount++;
444         if (targets.containsKey(target))
445         {
446           log(String.format(
447                   "Duplicate target mapping to %s at line %d of %s",
448                   target, lineNo, HELP_JHM));
449         }
450         else
451         {
452           targetCount++;
453         }
454       }
455
456       /*
457        * validate url
458        */
459       String url = getAttribute(data, "url");
460       if (url != null)
461       {
462         targets.put(target, url);
463         int anchorPos = url.indexOf("#");
464         if (anchorPos != -1)
465         {
466           url = url.substring(0, anchorPos);
467         }
468         if (!new File(helpFolder, url).exists())
469         {
470           log(String.format("Invalid url path '%s' at line %d of %s", url,
471                   lineNo, HELP_JHM));
472           invalidMapUrlCount++;
473         }
474       }
475       data = br.readLine();
476     }
477     br.close();
478     return targets;
479   }
480
481   /**
482    * Reads file helpTOC.xml and reports any invalid targets
483    * 
484    * @param helpFolder
485    * @param tocTargets
486    * @param unusedTargets
487    *          used targets are removed from this map
488    * 
489    * @return
490    * @throws IOException
491    */
492   private void checkTableOfContents(File helpFolder,
493           Map<String, String> tocTargets, Map<String, String> unusedTargets)
494           throws IOException
495   {
496     BufferedReader br = new BufferedReader(new FileReader(new File(
497             helpFolder, HELP_TOC_XML)));
498     String data = br.readLine();
499     int lineNo = 0;
500     while (data != null)
501     {
502       lineNo++;
503       /*
504        * assuming no more than one "target" per line of file here
505        */
506       String target = getAttribute(data, "target");
507       if (target != null)
508       {
509         unusedTargets.remove(target);
510         if (!tocTargets.containsKey(target))
511         {
512           log(String.format("Invalid target '%s' at line %d of %s", target,
513                   lineNo, HELP_TOC_XML));
514           invalidTargetCount++;
515         }
516       }
517       data = br.readLine();
518     }
519     br.close();
520   }
521
522   /**
523    * Returns the value of an attribute if found in the data, else null
524    * 
525    * @param data
526    * @param attName
527    * @return
528    */
529   private static String getAttribute(String data, String attName)
530   {
531     /*
532      * make a partial attempt at ignoring within <!-- html comments -->
533      * (doesn't work if multi-line)
534      */
535     int commentStartPos = data.indexOf("<!--");
536     int commentEndPos = commentStartPos == -1 ? -1 : data.substring(
537             commentStartPos + 4).indexOf("-->");
538     String value = null;
539     String match = attName + "=\"";
540     int attPos = data.indexOf(match);
541     if (attPos > 0
542             && (commentStartPos == -1 || attPos < commentStartPos || attPos > commentEndPos))
543     {
544       data = data.substring(attPos + match.length());
545       value = data.substring(0, data.indexOf("\""));
546     }
547     return value;
548   }
549
550   /**
551    * Trim whitespace from concatenated lines but preserve one space for valid
552    * parsing
553    */
554   @Override
555   public String cleanLine(String l)
556   {
557     return l.trim() + " ";
558   }
559 }