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<Path>}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<Path>} 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}