001    /*
002     * Copyright 2007 Paul Hammant
003     * Copyright 2007 ThinkTank Maths Limited
004     * 
005     * ThinkTank Maths Limited grants a non-revocable, perpetual licence
006     * to Paul Hammant for unlimited use, relicensing and redistribution. No
007     * explicit permission is required from ThinkTank Maths Limited for
008     * any future decisions made with regard to this file.
009     * 
010     * Redistribution and use in source and binary forms, with or without
011     * modification, are permitted provided that the following conditions
012     * are met:
013     * 
014     * 1. Redistributions of source code must retain the above copyright
015     *    notice, this list of conditions and the following disclaimer.
016     * 2. Redistributions in binary form must reproduce the above copyright
017     *    notice, this list of conditions and the following disclaimer in the
018     *    documentation and/or other materials provided with the distribution.
019     * 3. Neither the name of the copyright holders nor the names of its
020     *    contributors may be used to endorse or promote products derived from
021     *    this software without specific prior written permission.
022     *
023     * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
024     * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
025     * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
026     * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
027     * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
028     * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
029     * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
030     * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
031     * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
032     * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
033     * THE POSSIBILITY OF SUCH DAMAGE.
034     */
035    package com.thoughtworks.paranamer;
036    
037    import java.io.BufferedReader;
038    import java.io.File;
039    import java.io.FileInputStream;
040    import java.io.FileNotFoundException;
041    import java.io.IOException;
042    import java.io.InputStream;
043    import java.io.InputStreamReader;
044    import java.io.StringReader;
045    import java.io.UnsupportedEncodingException;
046    import java.lang.reflect.AccessibleObject;
047    import java.lang.reflect.Constructor;
048    import java.lang.reflect.Method;
049    import java.net.URI;
050    import java.net.URISyntaxException;
051    import java.net.URL;
052    import java.net.URLConnection;
053    import java.util.Enumeration;
054    import java.util.HashSet;
055    import java.util.Set;
056    import java.util.SortedMap;
057    import java.util.TreeMap;
058    import java.util.regex.Matcher;
059    import java.util.regex.Pattern;
060    import java.util.zip.GZIPInputStream;
061    import java.util.zip.Inflater;
062    import java.util.zip.InflaterInputStream;
063    import java.util.zip.ZipEntry;
064    import java.util.zip.ZipException;
065    import java.util.zip.ZipFile;
066    
067    /**
068     * Implementation of {@link Paranamer} which can access Javadocs at runtime to extract
069     * parameter names of methods. Works with:-
070     * <ul>
071     * <li>Javadoc in zip file</li>
072     * <li>Javadoc in directory</li>
073     * <li>Javadoc at remote URL</li>
074     * </ul>
075     * Future implementations may be able to take multiple sources, but this version must be
076     * instantiated with the correct location of the Javadocs for the package you wish to
077     * extract the parameter names. Note that if a zip archive contains multiple
078     * "package-list" files, the first one will be used to index the packages which may be
079     * queried.
080     * <p>
081     * Note that this does not perform any caching of entries (except what it finds in the
082     * package-list file, which is very lightweight)... every lookup will involve a disc hit.
083     * If you want to speed up performance, use a {@link CachingParanamer}.
084     * <p>
085     * Implementation note: the constructors of this implementation let the client know if I/O
086     * problems will stop the recovery of parameter names. It might be preferable to suppress
087     * exceptions and simply return NO_PARAMETER_NAMES_LIST.
088     * <p>
089     * TODO: example use code
090     * <p>
091     * Known issues:-
092     * <ul>
093     * <li>Only tested with Javadoc 1.3 - 1.6</li>
094     * <li>Doesn't handle methods that declare the generic type as a parameter (rare use case)</li>
095     * <li>Some "erased" generic methods fail, e.g. File.compareTo(File), which is erased to
096     * File.compareTo(Object).</li>
097     * <li>URL implementation is really slow</li>
098     * <li>Doesn't support nested classes (due to limitations in the Java 1.4 reflection API)</li>
099     * </ul>
100     * 
101     * @author Samuel Halliday, ThinkTank Maths Limited
102     */
103    public class JavadocParanamer implements Paranamer {
104    
105            private static final String IE =
106                            "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 2.0.50727)";
107    
108            private static final ParameterNamesNotFoundException CLASS_NOT_SUPPORTED =
109                            new ParameterNamesNotFoundException("class not supported");
110    
111            /** In the case of an archive, this stores the path up to the base of the Javadocs */
112            private String base = null;
113            private final boolean isArchive;
114            private final boolean isDirectory;
115    
116            private final boolean isURI;
117    
118            /**
119             * Regardless of the implementation, this stores the base location of the remote or
120             * local file or directory.
121             */
122            private final URI location;
123    
124            /** The packages which are supported by this instance. Contains Strings */
125            private final Set packages = new HashSet();
126    
127            /**
128             * Construct a Javadoc reading implementation of {@link Paranamer} using a local
129             * directory or zip archive as a source.
130             * 
131             * @param archiveOrDirectory
132             *            either a zip archive of Javadocs or the base directory of Javadocs.
133             * @throws IOException
134             *             if there was an error when reading from either the archive or the
135             *             package-list file.
136             * @throws FileNotFoundException
137             *             if the archive, directory or <code>package-list</code> file does not
138             *             exist.
139             * @throws NullPointerException
140             *             if any parameter is null
141             * @throws IllegalArgumentException
142             *             If the given parameter is not a file or directory or if it is a file
143             *             but not a javadoc zip archive.
144             */
145            public JavadocParanamer(File archiveOrDirectory) throws IOException {
146                    if (archiveOrDirectory == null)
147                            throw new NullPointerException();
148    
149                    if (!archiveOrDirectory.exists())
150                            throw new FileNotFoundException(
151                                    archiveOrDirectory.getAbsolutePath());
152    
153                    isURI = false;
154                    location = archiveOrDirectory.toURI();
155    
156                    if (archiveOrDirectory.isDirectory()) {
157                            // is a directory
158                            isArchive = false;
159                            isDirectory = true;
160                            // check that "package-list" exists
161                            File dir = archiveOrDirectory;
162                            File packageList =
163                                            new File(dir.getAbsolutePath() + "/package-list");
164                            if (!packageList.isFile())
165                                    throw new FileNotFoundException("No package-list found at "
166                                                    + dir.getAbsolutePath()
167                                                    + ". Not a valid Javadoc directory.");
168                            // it appear to be a valid Javadoc directory
169                            FileInputStream input = new FileInputStream(packageList);
170                            try {
171                                    String packageListString = streamToString(input);
172                                    parsePackageList(packageListString);
173                            } finally {
174                                    input.close();
175                            }
176                    } else if (archiveOrDirectory.isFile()) {
177                            // is a file
178                            isArchive = true;
179                            isDirectory = false;
180                            File archive = archiveOrDirectory;
181                            if (!archive.getAbsolutePath().toLowerCase().endsWith(".zip"))
182                                    throw new IllegalArgumentException(archive.getAbsolutePath()
183                                                    + " is not a zip file.");
184                            // check that a "package-list" exists somewhere in the archive
185                            ZipFile zip = new ZipFile(archive);
186                            try {
187                                    // we need to check for a file named "package-list".
188                                    // There may be multiple files in the archive
189                                    // but we cannot use ZipFile.getEntry for suffix names
190                                    // so we have to look through all the entries.
191                                    // We then pick the largest file.
192                                    Enumeration entries = zip.entries();
193                                    // grr... http://javablog.co.uk/2007/11/25/enumeration-and-iterable
194                                    // Set<ZipEntry>
195                                    SortedMap packageLists = new TreeMap();
196                                    while (entries.hasMoreElements()) {
197                                            ZipEntry entry = (ZipEntry) entries.nextElement();
198                                            String name = entry.getName();
199                                            if (name.endsWith("package-list")) {
200                                                    Long size = new Long(entry.getSize());
201                                                    packageLists.put(size, entry);
202                                            }
203                                    }
204                                    if (packageLists.size() == 0)
205                                            throw new FileNotFoundException(
206                                                    "no package-list found in archive");
207    
208                                    // pick the largest package-list file, it's most likely the one we want
209                                    ZipEntry entry =
210                                                    (ZipEntry) packageLists.get(packageLists.lastKey());
211                                    String name = entry.getName();
212                                    base =
213                                                    name.substring(0, name.length()
214                                                                    - "package-list".length());
215                                    InputStream input = zip.getInputStream(entry);
216                                    try {
217                                            String packageListString = streamToString(input);
218                                            parsePackageList(packageListString);
219                                    } finally {
220                                            input.close();
221                                    }
222                            } finally {
223                                    zip.close();
224                            }
225                    } else
226                            throw new IllegalArgumentException(
227                                    archiveOrDirectory.getAbsolutePath()
228                                                    + " is neither a directory nor a file.");
229            }
230    
231            /**
232             * @param url
233             * @throws IOException
234             *             if there was a problem connecting to the remote Javadocs
235             * @throws FileNotFoundException
236             *             if the url does not have a <code>/package-list</code>
237             * @throws NullPointerException
238             *             if any parameter is null
239             */
240            public JavadocParanamer(URL url) throws IOException {
241                    if (url == null)
242                            throw new NullPointerException();
243    
244                    isArchive = false;
245                    isDirectory = false;
246                    isURI = true;
247                    try {
248                            location = new URI(url.toString());
249                    } catch (URISyntaxException e) {
250                            throw new IOException(e.getMessage());
251                    }
252    
253                    // check the package-list
254                    URL packageListURL = new URL(url.toString() + "/package-list");
255                    InputStream input = urlToInputStream(packageListURL);
256                    try {
257                            String packageList = streamToString(input);
258                            parsePackageList(packageList);
259                    } finally {
260                            input.close();
261                    }
262            }
263    
264            public int areParameterNamesAvailable(Class clazz,
265                            String constructorOrMethodName) {
266                    if ((clazz == null) || (constructorOrMethodName == null))
267                            throw new NullPointerException();
268    
269                    // due to general problems with this method, we just delegate
270                    // the first match we find to lookupParameterNames
271                    AccessibleObject accessible = null;
272                    if (constructorOrMethodName.equals("<init>"))
273                            accessible = clazz.getDeclaredConstructors()[0];
274                    else {
275                            Method[] methods = clazz.getMethods();
276                            if (methods == null)
277                                    // this method doesn't exist
278                                    return NO_PARAMETER_NAMES_FOR_CLASS_AND_MEMBER;
279                            for (int i = 0; i < methods.length; i++) {
280                                    if (methods[i].getName().equals(constructorOrMethodName)) {
281                                            accessible = methods[i];
282                                            break;
283                                    }
284                            }
285                    }
286                    if (accessible == null)
287                            // this method doesn't exist
288                            return NO_PARAMETER_NAMES_FOR_CLASS_AND_MEMBER;
289    
290                    try {
291                            lookupParameterNames(accessible);
292                            return PARAMETER_NAMES_FOUND;
293                    } catch (ParameterNamesNotFoundException e) {
294                            if (e == CLASS_NOT_SUPPORTED)
295                                    return NO_PARAMETER_NAMES_FOR_CLASS;
296                            return NO_PARAMETER_NAMES_FOR_CLASS_AND_MEMBER;
297                    }
298            }
299    
300        public String[] lookupParameterNames(AccessibleObject methodOrConstructor) {
301            return lookupParameterNames(methodOrConstructor, true);
302        }
303    
304        public String[] lookupParameterNames(AccessibleObject methodOrConstructor, boolean throwExceptionIfMissing) {
305                    if (methodOrConstructor == null)
306                            throw new NullPointerException();
307    
308                    Class klass;
309                    String name;
310                    Class[] types;
311    
312                    if (methodOrConstructor instanceof Constructor) {
313                            Constructor constructor = (Constructor) methodOrConstructor;
314                            klass = constructor.getDeclaringClass();
315                            name = constructor.getName();
316                            types = constructor.getParameterTypes();
317                    } else if (methodOrConstructor instanceof Method) {
318                            Method method = (Method) methodOrConstructor;
319                            klass = method.getDeclaringClass();
320                            name = method.getName();
321                            types = method.getParameterTypes();
322                    } else
323                            throw new IllegalArgumentException();
324    
325                    // quick check to see if we support the package
326                    if (!packages.contains(klass.getPackage().getName()))
327                            throw CLASS_NOT_SUPPORTED;
328    
329                    try {
330                            String[] names = getParameterNames(klass, name, types);
331                            if (names == null) {
332                    if (throwExceptionIfMissing) {
333                        throw new ParameterNamesNotFoundException(
334                                                methodOrConstructor.toString());
335                    } else {
336                        return Paranamer.EMPTY_NAMES;
337                    }
338                }
339                return names;
340                    } catch (IOException e) {
341                if (throwExceptionIfMissing) {
342                    throw new ParameterNamesNotFoundException(
343                                    methodOrConstructor.toString() + " due to an I/O error: "
344                                                    + e.getMessage());
345                } else {
346                    return Paranamer.EMPTY_NAMES;
347                }
348            }
349            }
350    
351            // throws CLASS_NOT_SUPPORTED if the class file is not found in the javadocs
352            // return null if the parameter names were not found
353            private String[] getParameterNames(Class klass,
354                            String constructorOrMethodName, Class[] types) throws IOException {
355                    // silly request for names of a parameterless method/constructor!
356                    if ((types != null) && (types.length == 0))
357                            return new String[0];
358    
359                    String path = getCanonicalName(klass).replace('.', '/');
360                    if (isArchive) {
361                            ZipFile archive = new ZipFile(new File(location));
362                            ZipEntry entry = archive.getEntry(base + path + ".html");
363                            if (entry == null)
364                                    throw CLASS_NOT_SUPPORTED;
365                            InputStream input = archive.getInputStream(entry);
366                            return getParameterNames2(input, constructorOrMethodName, types);
367                    } else if (isDirectory) {
368                            File file = new File(location.getPath() + "/" + path + ".html");
369                            if (!file.isFile())
370                                    throw CLASS_NOT_SUPPORTED;
371                            FileInputStream input = new FileInputStream(file);
372                            return getParameterNames2(input, constructorOrMethodName, types);
373                    } else if (isURI) {
374                            try {
375                                    URL url = new URL(location.toString() + "/" + path + ".html");
376                                    InputStream input = urlToInputStream(url);
377                                    return getParameterNames2(input, constructorOrMethodName, types);
378                            } catch (FileNotFoundException e) {
379                                    throw CLASS_NOT_SUPPORTED;
380                            }
381                    }
382                    throw new RuntimeException(
383                            "bug in JavadocParanamer. Should not reach here.");
384            }
385    
386            /*
387             * Parse the Javadoc String and return the parameter names for the given constructor
388             * or method. Return null if no method/constructor is found. Note that types will
389             * never have length zero... we already deal with that situation higher up in the
390             * chain. Don't forget to close the input!
391             */
392            private String[] getParameterNames2(InputStream input,
393                            String constructorOrMethodName, Class[] types) throws IOException {
394                    String javadoc = streamToString(input);
395                    input.close();
396    
397                    // String we're looking for is like
398                    // 
399                    // NAME="constructorOrMethodName(obj.ClassName, ...)"...noise...
400                    // <DT><B>Parameters:</B><DD><CODE>parameter_name_1</CODE>...noise...
401                    // <DD><CODE>parameter_name_2</CODE>...noise...
402                    // ...
403                    // <DD><CODE>parameter_name_N</CODE>...noise...
404                    // 
405                    // We cannot rely on the Parameters line existing as it depends on the author
406                    // having correctly marked-up their code. The NAME element is auto-generated
407                    // and should be checked for aggressively.
408                    // 
409                    // Also note that Javadoc parameter names may differ from the names in the source.
410    
411                    // we don't have Pattern/Matcher :-(
412                    StringBuffer regex = new StringBuffer();
413                    regex.append("NAME=\"");
414                    regex.append(constructorOrMethodName);
415                    // quotes needed to escape array brackets
416                    regex.append("\\(\\Q");
417                    for (int i = 0; i < types.length; i++) {
418                            if (i != 0)
419                                    regex.append(", ");
420                            // canonical name deals with arrays
421                            regex.append(getCanonicalName(types[i]));
422                    }
423                    regex.append("\\E\\)\"");
424    
425                    // FIXME: handle Javadoc 1.3, 1.4 and 1.5 as well (this is 1.6)
426    
427                    Pattern pattern = Pattern.compile(regex.toString());
428                    Matcher matcher = pattern.matcher(javadoc);
429                    if (!matcher.find())
430                            // not found
431                            return Paranamer.EMPTY_NAMES;
432    
433                    // found it. Lookup the parameter names.
434                    String[] names = new String[types.length];
435                    // now we're sure we have the right method, find the parameter names!
436                    String regexParams = "<DD><CODE>([^<]*)</CODE>";
437                    Pattern patternParams = Pattern.compile(regexParams);
438                    int start = matcher.end();
439                    Matcher matcherParams = patternParams.matcher(javadoc);
440                    for (int i = 0; i < types.length; i++) {
441                            boolean find = matcherParams.find(start);
442                            if (!find)
443                                    return Paranamer.EMPTY_NAMES;
444                            start = matcherParams.end();
445                            names[i] = matcherParams.group(1);
446                    }
447                    return names;
448            }
449    
450            // doesn't support names of nested classes
451            private String getCanonicalName(Class klass) {
452                    if (klass.isArray())
453                            return getCanonicalName(klass.getComponentType()) + "[]";
454    
455                    return klass.getName();
456            }
457    
458            // storing the list of packages that we support is very lightweight
459            private void parsePackageList(String packageList) throws IOException {
460                    StringReader reader = new StringReader(packageList);
461                    BufferedReader breader = new BufferedReader(reader);
462                    String line;
463                    while ((line = breader.readLine()) != null) {
464                            packages.add(line);
465                    }
466            }
467    
468            // read an InputStream into a UTF-8 String
469            private String streamToString(InputStream input) throws IOException {
470                    InputStreamReader reader;
471                    try {
472                            reader = new InputStreamReader(input, "UTF-8");
473                    } catch (UnsupportedEncodingException e) {
474                            // this should never happen
475                            reader = new InputStreamReader(input);
476                    }
477                    BufferedReader breader = new BufferedReader(reader);
478                    String line;
479                    StringBuffer builder = new StringBuffer();
480                    while ((line = breader.readLine()) != null) {
481                            builder.append(line);
482                            builder.append("\n");
483                    }
484                    return builder.toString();
485            }
486    
487            private InputStream urlToInputStream(URL url) throws IOException {
488                    URLConnection conn = url.openConnection();
489                    // pretend to be IE6
490                    conn.setRequestProperty("User-Agent", IE);
491                    // allow both GZip and Deflate (ZLib) encodings
492                    conn.setRequestProperty("Accept-Encoding", "gzip, deflate");
493                    conn.connect();
494                    String encoding = conn.getContentEncoding();
495                    if ((encoding != null) && encoding.equalsIgnoreCase("gzip"))
496                            return new GZIPInputStream(conn.getInputStream());
497                    else if ((encoding != null) && encoding.equalsIgnoreCase("deflate"))
498                            return new InflaterInputStream(conn.getInputStream(), new Inflater(
499                                    true));
500                    else
501                            return conn.getInputStream();
502            }
503    
504    }