001// Copyright 2012 The Apache Software Foundation
002//
003// Licensed under the Apache License, Version 2.0 (the "License");
004// you may not use this file except in compliance with the License.
005// You may obtain a copy of the License at
006//
007//     http://www.apache.org/licenses/LICENSE-2.0
008//
009// Unless required by applicable law or agreed to in writing, software
010// distributed under the License is distributed on an "AS IS" BASIS,
011// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
012// See the License for the specific language governing permissions and
013// limitations under the License.
014
015package org.apache.tapestry5.ioc.internal.services;
016
017import org.apache.tapestry5.commons.util.CollectionFactory;
018import org.apache.tapestry5.commons.util.Stack;
019import org.apache.tapestry5.ioc.internal.util.InternalUtils;
020import org.apache.tapestry5.ioc.services.ClasspathMatcher;
021import org.apache.tapestry5.ioc.services.ClasspathScanner;
022import org.apache.tapestry5.ioc.services.ClasspathURLConverter;
023
024import java.io.*;
025import java.net.JarURLConnection;
026import java.net.URL;
027import java.net.URLConnection;
028import java.util.Enumeration;
029import java.util.Set;
030import java.util.jar.JarEntry;
031import java.util.jar.JarFile;
032import java.util.regex.Pattern;
033
034public class ClasspathScannerImpl implements ClasspathScanner
035{
036    private final ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
037
038    private final ClasspathURLConverter converter;
039
040    private static final Pattern FOLDER_NAME_PATTERN = Pattern.compile("^\\p{javaJavaIdentifierStart}[\\p{javaJavaIdentifierPart}]*$", Pattern.CASE_INSENSITIVE);
041
042
043    public ClasspathScannerImpl(ClasspathURLConverter converter)
044    {
045        this.converter = converter;
046    }
047
048    /**
049     * Scans the indicated package path for matches.
050     *
051     * @param packagePath
052     *         a package path (like a package name, but using '/' instead of '.', and ending with '/')
053     * @param matcher
054     *         passed a resource path from the package (or a sub-package), returns true if the provided
055     *         path should be included in the returned collection
056     * @return collection of matching paths, in no specified order
057     * @throws java.io.IOException
058     */
059    @Override
060    public Set<String> scan(String packagePath, ClasspathMatcher matcher) throws IOException
061    {
062        assert packagePath != null && packagePath.endsWith("/");
063        assert matcher != null;
064
065        return new Job(matcher, contextClassLoader, converter).findMatches(packagePath);
066    }
067
068    /**
069     * Check whether container supports opening a stream on a dir/package to get a list of its contents.
070     */
071    private static boolean supportsDirStream(URL packageURL)
072    {
073        InputStream is = null;
074
075        try
076        {
077            is = packageURL.openStream();
078
079            return true;
080        } catch (FileNotFoundException ex)
081        {
082            return false;
083        } catch (IOException ex)
084        {
085            return false;
086        } finally
087        {
088            InternalUtils.close(is);
089        }
090    }
091
092    /**
093     * For URLs to JARs that do not use JarURLConnection - allowed by the servlet spec - attempt to produce a JarFile
094     * object all the same. Known servlet engines that function like this include Weblogic and OC4J. This is not a full
095     * solution, since an unpacked WAR or EAR will not have JAR "files" as such.
096     *
097     * @param url
098     *         URL of jar
099     * @return JarFile or null
100     * @throws java.io.IOException
101     *         If error occurs creating jar file
102     */
103    private static JarFile getAlternativeJarFile(URL url) throws IOException
104    {
105        String urlFile = url.getFile();
106        // Trim off any suffix - which is prefixed by "!/" on Weblogic
107        int separatorIndex = urlFile.indexOf("!/");
108
109        // OK, didn't find that. Try the less safe "!", used on OC4J
110        if (separatorIndex == -1)
111        {
112            separatorIndex = urlFile.indexOf('!');
113        }
114
115        if (separatorIndex != -1)
116        {
117            String jarFileUrl = urlFile.substring(0, separatorIndex);
118            // And trim off any "file:" prefix.
119            if (jarFileUrl.startsWith("file:"))
120            {
121                jarFileUrl = jarFileUrl.substring("file:".length());
122            }
123
124            return new JarFile(jarFileUrl);
125        }
126
127        return null;
128    }
129
130    /**
131     * Variation of {@link Runnable} that throws {@link IOException}.  Still think checked exceptions are a good idea?
132     */
133    interface IOWork
134    {
135        void run() throws IOException;
136    }
137
138    /**
139     * Encapsulates the data, result, and queue of deferred operations for performing the scan.
140     */
141    static class Job
142    {
143        final ClasspathMatcher matcher;
144
145        final ClasspathURLConverter converter;
146
147        final ClassLoader classloader;
148
149        final Set<String> matches = CollectionFactory.newSet();
150
151        /**
152         * Explicit queue used to avoid deep tail-recursion.
153         */
154        final Stack<IOWork> queue = CollectionFactory.newStack();
155
156
157        Job(ClasspathMatcher matcher, ClassLoader classloader, ClasspathURLConverter converter)
158        {
159            this.matcher = matcher;
160            this.classloader = classloader;
161            this.converter = converter;
162        }
163
164        Set<String> findMatches(String packagePath) throws IOException
165        {
166
167            Enumeration<URL> urls = classloader.getResources(packagePath);
168
169            while (urls.hasMoreElements())
170            {
171                URL url = urls.nextElement();
172
173                URL converted = converter.convert(url);
174
175                scanURL(packagePath, converted);
176
177                while (!queue.isEmpty())
178                {
179                    IOWork queued = queue.pop();
180
181                    queued.run();
182                }
183            }
184
185            return matches;
186        }
187
188        void scanURL(final String packagePath, final URL url) throws IOException
189        {
190            URLConnection connection = url.openConnection();
191
192            JarFile jarFile;
193
194            if (connection instanceof JarURLConnection)
195            {
196                jarFile = ((JarURLConnection) connection).getJarFile();
197            } else
198            {
199                jarFile = getAlternativeJarFile(url);
200            }
201
202            if (jarFile != null)
203            {
204                scanJarFile(packagePath, jarFile);
205            } else if (supportsDirStream(url))
206            {
207                queue.push(new IOWork()
208                {
209                    @Override
210                    public void run() throws IOException
211                    {
212                        scanDirStream(packagePath, url);
213                    }
214                });
215            } else
216            {
217                // Try scanning file system.
218
219                scanDir(packagePath, new File(url.getFile()));
220            }
221
222        }
223
224        /**
225         * Scan a dir for classes. Will recursively look in the supplied directory and all sub directories.
226         *
227         * @param packagePath
228         *         Name of package that this directory corresponds to.
229         * @param packageDir
230         *         Dir to scan for classes.
231         */
232        private void scanDir(String packagePath, File packageDir)
233        {
234            if (packageDir.exists() && packageDir.isDirectory())
235            {
236                for (final File file : packageDir.listFiles())
237                {
238                    String fileName = file.getName();
239
240                    if (file.isDirectory())
241                    {
242                        final String nestedPackagePath = packagePath + fileName + "/";
243
244                        queue.push(new IOWork()
245                        {
246                            @Override
247                            public void run() throws IOException
248                            {
249                                scanDir(nestedPackagePath, file);
250                            }
251                        });
252                    }
253
254                    if (matcher.matches(packagePath, fileName))
255                    {
256                        matches.add(packagePath + fileName);
257                    }
258                }
259            }
260        }
261
262        private void scanDirStream(String packagePath, URL packageURL) throws IOException
263        {
264            InputStream is;
265
266            try
267            {
268                is = new BufferedInputStream(packageURL.openStream());
269            } catch (FileNotFoundException ex)
270            {
271                // This can happen for certain application servers (JBoss 4.0.5 for example), that
272                // export part of the exploded WAR for deployment, but leave part (WEB-INF/classes)
273                // unexploded.
274
275                return;
276            }
277
278            Reader reader = new InputStreamReader(is);
279            LineNumberReader lineReader = new LineNumberReader(reader);
280
281            try
282            {
283                while (true)
284                {
285                    String line = lineReader.readLine();
286
287                    if (line == null) break;
288
289                    if (matcher.matches(packagePath, line))
290                    {
291                        matches.add(packagePath + line);
292                    } else
293                    {
294
295                        // This should match just directories.  It may also match files that have no extension;
296                        // when we read those, none of the lines should look like class files.
297
298                        if (FOLDER_NAME_PATTERN.matcher(line).matches())
299                        {
300                            final URL newURL = new URL(packageURL.toExternalForm() + line + "/");
301                            final String nestedPackagePath = packagePath + line + "/";
302
303                            queue.push(new IOWork()
304                            {
305                                @Override
306                                public void run() throws IOException
307                                {
308                                    scanURL(nestedPackagePath, newURL);
309                                }
310                            });
311                        }
312                    }
313                }
314
315                lineReader.close();
316                lineReader = null;
317            } finally
318            {
319                InternalUtils.close(lineReader);
320            }
321
322        }
323
324        private void scanJarFile(String packagePath, JarFile jarFile)
325        {
326            Enumeration<JarEntry> e = jarFile.entries();
327
328            while (e.hasMoreElements())
329            {
330                String name = e.nextElement().getName();
331
332                if (!name.startsWith(packagePath)) continue;
333
334                int lastSlashx = name.lastIndexOf('/');
335
336                String filePackagePath = name.substring(0, lastSlashx + 1);
337                String fileName = name.substring(lastSlashx + 1);
338
339                if (matcher.matches(filePackagePath, fileName))
340                {
341                    matches.add(name);
342                }
343            }
344        }
345    }
346}