001/* -*- mode: Java; c-basic-offset: 2; indent-tabs-mode: nil; coding: utf-8-unix -*-
002 *
003 * Copyright © 2017 MicroBean.
004 *
005 * Licensed under the Apache License, Version 2.0 (the "License");
006 * you may not use this file except in compliance with the License.
007 * You may obtain a copy of the License at
008 *
009 *     http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
014 * implied.  See the License for the specific language governing
015 * permissions and limitations under the License.
016 */
017package org.microbean.helm.chart;
018
019import java.io.BufferedReader;
020import java.io.File;
021import java.io.IOException;
022import java.io.Reader;
023
024import java.nio.charset.StandardCharsets;
025
026import java.nio.file.Files;
027import java.nio.file.LinkOption; // for javadoc only
028import java.nio.file.Path;
029
030import java.nio.file.PathMatcher;
031
032import java.util.ArrayList;
033import java.util.Collection;
034import java.util.Collections;
035
036import java.util.function.Predicate;
037
038import java.util.regex.Matcher;
039import java.util.regex.Pattern;
040import java.util.regex.PatternSyntaxException;
041
042import java.util.stream.Collectors;
043
044/**
045 * A {@link PathMatcher} and a {@link Predicate Predicate<Path>}
046 * that {@linkplain #matches(Path) matches} paths using the syntax of
047 * a {@code .helmignore} file.
048 *
049 * <p>This class passes <a
050 * href="https://github.com/kubernetes/helm/blob/v2.5.0/pkg/ignore/rules_test.go#L91-L121">all
051 * of the unit tests present</a> in the <a
052 * href="http://godoc.org/k8s.io/helm/pkg/ignore">Helm project's
053 * package concerned with {@code .helmignore} files</a>.  It may
054 * permit richer syntax, but there are no guarantees made regarding
055 * the behavior of this class in such cases.</p>
056 *
057 * <h2>Thread Safety</h2>
058 *
059 * <p>This class is safe for concurrent use by multiple threads.</p>
060 *
061 * @author <a href="https://about.me/lairdnelson"
062 * target="_parent">Laird Nelson</a>
063 *
064 * @see <a href="http://godoc.org/k8s.io/helm/pkg/ignore">The Helm
065 * project's package concerned with {@code .helmignore} files</a>
066 */
067public class HelmIgnorePathMatcher implements PathMatcher, Predicate<Path> {
068
069
070  /*
071   * Instance fields.
072   */
073
074
075  /**
076   * A {@link Collection} of {@link Predicate Predicate&lt;Path&gt;}s,
077   * one of which must {@linkplain #matches(Path) match} for the
078   * {@link #matches(Path)} method to return {@code true}.
079   *
080   * <p>This field is never {@code null}.</p>
081   *
082   * @see #addPatterns(Collection)
083   */
084  private final Collection<Predicate<Path>> rules;
085
086
087  /*
088   * Constructors.
089   */
090
091
092  /**
093   * Creates a new {@link HelmIgnorePathMatcher}.
094   */
095  public HelmIgnorePathMatcher() {
096    super();
097    this.rules = new ArrayList<>();
098    this.addPattern("templates/.?*");
099  }
100
101  /**
102   * Creates a new {@link HelmIgnorePathMatcher}.
103   *
104   * @param stringPatterns a {@link Collection} of <a
105   * href="http://godoc.org/k8s.io/helm/pkg/ignore">valid {@code
106   * .helmignore} patterns</a>; may be {@code null}
107   *
108   * @exception PatternSyntaxException if any of the patterns is
109   * invalid
110   */
111  public HelmIgnorePathMatcher(final Collection<? extends String> stringPatterns) {
112    this();
113    this.addPatterns(stringPatterns);
114  }
115
116  /**
117   * Creates a new {@link HelmIgnorePathMatcher}.
118   *
119   * @param reader a {@link Reader} expected to provide access to a
120   * logical collection of lines of text, each line of which is a <a
121   * href="http://godoc.org/k8s.io/helm/pkg/ignore">valid {@code
122   * .helmignore} pattern</a> (or blank line, or comment); may be
123   * {@code null}; never {@linkplain Reader#close() closed}
124   *
125   * @exception IOException if an error related to the supplied {@code
126   * reader} is encountered
127   *
128   * @exception PatternSyntaxException if any of the patterns is
129   * invalid
130   */
131  public HelmIgnorePathMatcher(final Reader reader) throws IOException {
132    this();
133    if (reader != null) {
134      final BufferedReader bufferedReader;
135      if (reader instanceof BufferedReader) {
136        bufferedReader = (BufferedReader)reader;
137      } else {
138        bufferedReader = new BufferedReader(reader);
139      }
140      assert bufferedReader != null;
141      this.addPatterns(bufferedReader.lines().collect(Collectors.toList()));
142    }
143  }
144
145  /**
146   * Creates a new {@link HelmIgnorePathMatcher}.
147   *
148   * @param helmIgnoreFile a {@link Path} expected to provide access
149   * to a logical collection of lines of text, each line of which is a
150   * <a href="http://godoc.org/k8s.io/helm/pkg/ignore">valid {@code
151   * .helmignore} pattern</a> (or blank line, or comment); may be
152   * {@code null}; never {@linkplain Reader#close() closed}
153   *
154   * @exception IOException if an error related to the supplied {@code
155   * helmIgnoreFile} is encountered
156   *
157   * @exception PatternSyntaxException if any of the patterns is
158   * invalid
159   *
160   * @see #HelmIgnorePathMatcher(Reader)
161   */
162  public HelmIgnorePathMatcher(final Path helmIgnoreFile) throws IOException {
163    this(helmIgnoreFile == null ? (Collection<? extends String>)null : Files.readAllLines(helmIgnoreFile, StandardCharsets.UTF_8));
164  }
165
166
167  /*
168   * Instance methods.
169   */
170
171
172  /**
173   * Calls the {@link #addPatterns(Collection)} method with a
174   * {@linkplain Collections#singleton(Object) singleton
175   * <code>Set</code>} consisting of the supplied {@code
176   * stringPattern}.
177   *
178   * @param stringPattern a <a
179   * href="http://godoc.org/k8s.io/helm/pkg/ignore">valid {@code
180   * .helmignore} pattern</a>; may be {@code null} or {@linkplain
181   * String#isEmpty() empty} or prefixed with a {@code #} character,
182   * in which case no action will be taken
183   *
184   * @see #addPatterns(Collection)
185   *
186   * @see #matches(Path)
187   */
188  public final void addPattern(final String stringPattern) {
189    this.addPatterns(stringPattern == null ? (Collection<? extends String>)null : Collections.singleton(stringPattern));
190  }
191
192  /**
193   * Adds all of the <a
194   * href="http://godoc.org/k8s.io/helm/pkg/ignore">valid {@code
195   * .helmignore} patterns</a> present in the supplied {@link
196   * Collection} of such patterns.
197   *
198   * <p>Overrides must not call {@link #addPattern(String)}.</p>
199   *
200   * @param stringPatterns a {@link Collection} of <a
201   * href="http://godoc.org/k8s.io/helm/pkg/ignore">valid {@code
202   * .helmignore} patterns</a>; may be {@code null} in which case no
203   * action will be taken
204   *
205   * @see #matches(Path)
206   */
207  public void addPatterns(final Collection<? extends String> stringPatterns) {    
208    if (stringPatterns != null && !stringPatterns.isEmpty()) {
209      for (String stringPattern : stringPatterns) {
210        if (stringPattern != null && !stringPattern.isEmpty()) {
211          stringPattern = stringPattern.trim();
212          if (!stringPattern.isEmpty() && !stringPattern.startsWith("#")) {
213
214            if (stringPattern.equals("!") || stringPattern.equals("/")) {
215              throw new IllegalArgumentException("invalid pattern: " + stringPattern);
216            } else if (stringPattern.contains("**")) {
217              throw new IllegalArgumentException("invalid pattern: " + stringPattern + " (double-star (**) syntax is not supported)"); // see rules.go
218            }
219
220            final boolean negate;
221            if (stringPattern.startsWith("!")) {
222              assert stringPattern.length() > 1;
223              negate = true;
224              stringPattern = stringPattern.substring(1);
225            } else {
226              negate = false;
227            }
228
229            final boolean requireDirectory;
230            if (stringPattern.endsWith("/")) {
231              assert stringPattern.length() > 1;
232              requireDirectory = true;
233              stringPattern = stringPattern.substring(0, stringPattern.length() - 1);
234            } else {
235              requireDirectory = false;
236            }
237
238            final boolean basename;
239            final int firstSlashIndex = stringPattern.indexOf('/');
240            if (firstSlashIndex < 0) {
241              basename = true;
242            } else {
243              if (firstSlashIndex == 0) {
244                assert stringPattern.length() > 1;
245                stringPattern = stringPattern.substring(1);
246              }
247              basename = false;
248            }
249
250            final StringBuilder regex = new StringBuilder("^");
251            final char[] chars = stringPattern.toCharArray();
252            assert chars != null;
253            assert chars.length > 0;
254            final int length = chars.length;
255            for (int i = 0; i < length; i++) {
256              final char c = chars[i];
257              switch (c) {
258              case '.':
259                regex.append("\\.");
260                break;
261              case '*':
262                regex.append("[^").append(File.separator).append("]*");
263                break;
264              case '?':
265                regex.append("[^").append(File.separator).append("]?");
266                break;
267              default:
268                regex.append(c);
269                break;
270              }
271            }
272            regex.append("$");
273
274            final Predicate<Path> rule = new RegexRule(Pattern.compile(regex.toString()), requireDirectory, basename);
275            synchronized (this.rules) {
276              this.rules.add(negate ? rule.negate() : rule);
277            }
278          }
279        }
280      }
281    }
282  }
283
284  /**
285   * Calls the {@link #matches(Path)} method with the supplied {@link
286   * Path} and returns its results.
287   *
288   * @param path a {@link Path} to test; may be {@code null}
289   *
290   * @return {@code true} if the supplied {@code path} matches; {@code
291   * false} otherwise
292   *
293   * @see #matches(Path)
294   */
295  @Override
296  public final boolean test(final Path path) {
297    return this.matches(path);
298  }
299
300  /**
301   * Returns {@code true} if at least one of the patterns added via
302   * the {@link #addPatterns(Collection)} method logically matches the
303   * supplied {@link Path}.
304   *
305   * @param path the {@link Path} to match; may be {@code null} in
306   * which case {@code false} will be returned
307   *
308   * @return {@code true} if at least one of the patterns added via
309   * the {@link #addPatterns(Collection)} method logically matches the
310   * supplied {@link Path}; {@code false} otherwise
311   */
312  @Override
313  public boolean matches(final Path path) {
314    boolean returnValue = false;
315    if (path != null) {
316      final String pathString = path.toString();
317      // See https://github.com/kubernetes/helm/issues/1776.
318      if (!pathString.equals(".") && !pathString.equals("./")) {
319        synchronized (this.rules) {
320          for (final Predicate<Path> rule : this.rules) {
321            if (rule != null && rule.test(path)) {
322              returnValue = true;
323              break;
324            }
325          }
326        }
327      }
328    }
329    return returnValue;
330  }
331
332
333  /*
334   * Inner and nested classes.
335   */
336  
337
338  /**
339   * A {@link Predicate Predicate&lt;Path&gt;} that may also apply
340   * {@link Path}-specific tests.
341   *
342   * @author <a href="https://about.me/lairdnelson"
343   * target="_parent">Laird Nelson</a>
344   */
345  private static abstract class Rule implements Predicate<Path> {
346
347
348    /*
349     * Instance fields.
350     */
351
352
353    /**
354     * Whether a {@link Path} must {@linkplain Files#isDirectory(Path,
355     * LinkOption...)  be a directory} in order for this {@link Rule}
356     * to match.
357     */
358    private final boolean requireDirectory;
359
360    /**
361     * Whether the {@linkplain Path#getFileName() final component in a
362     * <code>Path</code>} is matched, or the entire {@link Path}.
363     */
364    private final boolean basename;
365
366
367    /*
368     * Constructors.
369     */
370
371
372    /**
373     * Creates a new {@link Rule}.
374     *
375     * @param requireDirectory whether a {@link Path} must {@linkplain
376     * Files#isDirectory(Path, LinkOption...) be a directory} in order
377     * for this {@link Rule} to match
378     *
379     * @param basename hhether the {@linkplain Path#getFileName()
380     * final component in a <code>Path</code>} is matched, or the
381     * entire {@link Path}
382     */
383    protected Rule(final boolean requireDirectory, final boolean basename) {
384      super();
385      this.requireDirectory = requireDirectory;
386      this.basename = basename;
387    }
388
389    /**
390     * Returns a {@link Path} that can be tested, given a {@link Path}
391     * and the application of the {@code requireDirectory} and {@code
392     * basename} parameters passed to the constructor.
393     *
394     * <p>This method may return {@code null}.</p>
395     *
396     * @param path the {@link Path} to normalize; may be {@code null}
397     * in which case {@code null} will be returned
398     *
399     * @return a {@link Path} to be further tested; or {@code null}
400     */
401    protected final Path normalizePath(final Path path) {
402      Path returnValue = path;
403      if (path != null) {
404        if (this.basename) {
405          returnValue = path.getFileName();
406        }
407        if (this.requireDirectory && !Files.isDirectory(path)) {
408          returnValue = null;
409        }
410      }
411      return returnValue;
412    }
413
414  }
415
416  /**
417   * A {@link Rule} that uses regular expressions to match {@link Path}s.
418   *
419   * @author <a href="https://about.me/lairdnelson"
420   * target="_parent">Laird Nelson</a>
421   *
422   * @see Pattern
423   */
424  private static final class RegexRule extends Rule {
425
426
427    /*
428     * Instance fields.
429     */
430
431
432    /**
433     * The {@link Pattern} specifying what {@link Path} instances
434     * should be matched.
435     *
436     * <p>This field may be {@code null}.</p>
437     */
438    private final Pattern pattern;
439
440
441    /*
442     * Constructors.
443     */
444
445
446    /**
447     * Creates a new {@link RegexRule}.
448     *
449     * @param pattern the {@link Pattern} specifying what {@link Path}
450     * instances should be matched; may be {@code null}
451     *
452     * @param requireDirectory whether only {@link Path} instances
453     * that {@linkplain Files#isDirectory(Path, LinkOption...) are
454     * directories} are subject to further matching
455     *
456     * @param basename whether only {@linkplain Path#getFileName() the
457     * last component of a <code>Path</code>} is considered for
458     * matching
459     *
460     * @see #test(Path)
461     */
462    private RegexRule(final Pattern pattern, final boolean requireDirectory, final boolean basename) {
463      super(requireDirectory, basename);
464      this.pattern = pattern;
465    }
466
467
468    /*
469     * Instance methods.
470     */
471
472
473    /**
474     * Tests the supplied {@link Path} to see if it matches the
475     * conditions supplied at construction time.
476     *
477     * @param path the {@link Path} to test; may be {@code null} in
478     * which case {@code false} will be returned
479     *
480     * @return {@code true} if this {@link RegexRule} matches the
481     * supplied {@link Path}; {@code false} otherwise
482     */
483    @Override
484    public final boolean test(Path path) {
485      boolean returnValue = false;
486      path = this.normalizePath(path);
487      if (path != null) {
488        if (this.pattern == null) {
489          returnValue = true;
490        } else {
491          final Matcher matcher = this.pattern.matcher(path.toString());
492          assert matcher != null;
493          returnValue = matcher.matches();
494        }
495      }
496      return returnValue;
497    }
498  }
499  
500}