|
| 1 | +package codes.quine.labo.lite.gitignore |
| 2 | + |
| 3 | +import java.nio.file.Files |
| 4 | +import java.nio.file.LinkOption |
| 5 | +import java.nio.file.Path |
| 6 | + |
| 7 | +import scala.annotation.tailrec |
| 8 | +import scala.jdk.CollectionConverters._ |
| 9 | + |
| 10 | +import codes.quine.labo.lite.parser.Parser |
| 11 | + |
| 12 | +/** GlobPath is a path matcher including a glob pattern. */ |
| 13 | +sealed abstract class GlobPath extends Product with Serializable { |
| 14 | + |
| 15 | + /** Checks whether or not the given path matches this. */ |
| 16 | + def matches(path: Path): Boolean |
| 17 | +} |
| 18 | + |
| 19 | +object GlobPath { |
| 20 | + |
| 21 | + import Parser._ |
| 22 | + |
| 23 | + /** Parses the given line as a path matcher. |
| 24 | + * When the line is not a valid path matcher (e.g. comment), it returns `None` instead. |
| 25 | + */ |
| 26 | + def parse(line: String, base: Path): Option[(Boolean, GlobPath)] = |
| 27 | + if (line.startsWith("#")) None |
| 28 | + else { |
| 29 | + val isNegated = line.startsWith("!") |
| 30 | + parser(base).parse(line, if (isNegated) 1 else 0) match { |
| 31 | + case Right((_, path)) => path.map((isNegated, _)) |
| 32 | + case Left(_) => { |
| 33 | + // $COVERAGE-OFF$ |
| 34 | + None |
| 35 | + // $COVERAGE-ON$ |
| 36 | + } |
| 37 | + } |
| 38 | + } |
| 39 | + |
| 40 | + private[gitignore] def parser(base: Path): Parser[Option[GlobPath]] = { |
| 41 | + val space = charInWhile(" \t", min = 0) |
| 42 | + |
| 43 | + (Component.parser ~ ('/' ~ Component.parser).rep ~ space ~ end).map { |
| 44 | + case (Glob.Empty, Seq()) => None |
| 45 | + case (glob: Glob, Seq(Glob.Empty)) => Some(FileNameGlobPath(glob, isDir = true)) |
| 46 | + case (glob: Glob, Seq()) => Some(FileNameGlobPath(glob, isDir = false)) |
| 47 | + case (Glob.Empty, cs :+ Glob.Empty) => Some(RelativeGlobPath(cs, isDir = true, base)) |
| 48 | + case (Glob.Empty, cs) => Some(RelativeGlobPath(cs, isDir = false, base)) |
| 49 | + case (c, cs :+ Glob.Empty) => Some(RelativeGlobPath(c +: cs, isDir = true, base)) |
| 50 | + case (c, cs) => Some(RelativeGlobPath(c +: cs, isDir = false, base)) |
| 51 | + } |
| 52 | + } |
| 53 | + |
| 54 | + /** RelativeGlobPath is a path matcher to match a path from a base path. */ |
| 55 | + final case class RelativeGlobPath( |
| 56 | + components: Seq[Component], |
| 57 | + isDir: Boolean, |
| 58 | + base: Path |
| 59 | + ) extends GlobPath { |
| 60 | + def matches(path: Path): Boolean = { |
| 61 | + val rel = base.relativize(path) |
| 62 | + val rels = rel.iterator().asScala.map(_.toString).toVector |
| 63 | + if (rels.isEmpty || rels.head == "..") false |
| 64 | + else { |
| 65 | + @tailrec |
| 66 | + def loop(pos: Int, state: Seq[Component], nextPos: Int, nextState: Seq[Component]): Boolean = |
| 67 | + if (pos >= rels.length && state.isEmpty) true |
| 68 | + else |
| 69 | + state.headOption match { |
| 70 | + case Some(StarStar) => loop(pos, state.tail, pos + 1, state) |
| 71 | + case Some(g: Glob) if pos < rels.size && g.matches(rels(pos)) => |
| 72 | + loop(pos + 1, state.tail, nextPos, nextState) |
| 73 | + case _ if 0 < nextPos && nextPos <= rels.size => |
| 74 | + loop(nextPos, nextState, nextPos, nextState) |
| 75 | + case _ => false |
| 76 | + } |
| 77 | + |
| 78 | + val matched = loop(0, components, 0, components) |
| 79 | + if (matched && isDir) Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS) |
| 80 | + else matched |
| 81 | + } |
| 82 | + } |
| 83 | + } |
| 84 | + |
| 85 | + /** FileNameGlobPath is a path matcher to match a filename of a path. */ |
| 86 | + final case class FileNameGlobPath(glob: Glob, isDir: Boolean) extends GlobPath { |
| 87 | + def matches(path: Path): Boolean = { |
| 88 | + // When `path` is root, `path.getFileName` returns `null`, so it is wrapped by `Option`. |
| 89 | + val matched = Option(path.getFileName).exists(fileName => glob.matches(fileName.toString)) |
| 90 | + if (matched && isDir) Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS) |
| 91 | + else matched |
| 92 | + } |
| 93 | + } |
| 94 | + |
| 95 | + /** Component is a component of a path matcher. */ |
| 96 | + sealed abstract class Component extends Product with Serializable |
| 97 | + |
| 98 | + object Component { |
| 99 | + private[gitignore] lazy val parser: Parser[Component] = StarStar.parser | Glob.parser |
| 100 | + } |
| 101 | + |
| 102 | + /** StarStar is a double star glob. */ |
| 103 | + case object StarStar extends Component { |
| 104 | + lazy val parser: Parser[Component] = "**".as(StarStar) |
| 105 | + } |
| 106 | + |
| 107 | + /** Glob is a glob to match a component of a path. */ |
| 108 | + final case class Glob(chars: Seq[GlobChar]) extends Component { |
| 109 | + |
| 110 | + /** Checks whether or not the given file name matches this. */ |
| 111 | + def matches(fileName: String): Boolean = { |
| 112 | + @tailrec |
| 113 | + def loop(pos: Int, state: Seq[GlobChar], nextPos: Int, nextState: Seq[GlobChar]): Boolean = |
| 114 | + if (pos >= fileName.length && state.isEmpty) true |
| 115 | + else |
| 116 | + state.headOption match { |
| 117 | + case Some(Star) => loop(pos, state.tail, pos + 1, state) |
| 118 | + case Some(c) if pos < fileName.length && c.accepts(fileName(pos)) => |
| 119 | + loop(pos + 1, state.tail, nextPos, nextState) |
| 120 | + case _ if 0 < nextPos && nextPos <= fileName.length => |
| 121 | + loop(nextPos, nextState, nextPos, nextState) |
| 122 | + case _ => false |
| 123 | + } |
| 124 | + loop(0, chars, 0, chars) |
| 125 | + } |
| 126 | + } |
| 127 | + |
| 128 | + object Glob { |
| 129 | + |
| 130 | + /** An empty glob. */ |
| 131 | + val Empty: Glob = Glob(Seq.empty) |
| 132 | + |
| 133 | + private[gitignore] val parser: Parser[Glob] = GlobChar.parser.rep.map(Glob(_)) |
| 134 | + } |
| 135 | + |
| 136 | + /** GlobChar is a character in a glob. */ |
| 137 | + sealed abstract class GlobChar extends Product with Serializable { |
| 138 | + |
| 139 | + /** Checks whether or not this accepts the given character. */ |
| 140 | + private[gitignore] def accepts(c: Char): Boolean |
| 141 | + } |
| 142 | + |
| 143 | + object GlobChar { |
| 144 | + private[gitignore] lazy val parser: Parser[GlobChar] = |
| 145 | + Star.parser | Quest.parser | Range.parser | Literal.parser |
| 146 | + } |
| 147 | + |
| 148 | + /** Star is `*` in a glob. */ |
| 149 | + case object Star extends GlobChar { |
| 150 | + private[gitignore] def accepts(c: Char): Boolean = { |
| 151 | + // $COVERAGE-OFF$ |
| 152 | + sys.error("GlobPath.Star#accepts: invalid call") |
| 153 | + // $COVERAGE-ON$ |
| 154 | + } |
| 155 | + |
| 156 | + private[gitignore] lazy val parser: Parser[GlobChar] = '*'.as(Star) |
| 157 | + } |
| 158 | + |
| 159 | + /** Quest is `?` in a glob. */ |
| 160 | + case object Quest extends GlobChar { |
| 161 | + private[gitignore] def accepts(c: Char): Boolean = true |
| 162 | + |
| 163 | + private[gitignore] lazy val parser: Parser[GlobChar] = '?'.as(Quest) |
| 164 | + } |
| 165 | + |
| 166 | + /** Range is a range of characters in a glob. */ |
| 167 | + final case class Range(isNegated: Boolean, ranges: Seq[(Char, Char)]) extends GlobChar { |
| 168 | + private[gitignore] def accepts(c: Char): Boolean = |
| 169 | + !isNegated == ranges.exists { case (b, e) => b <= c && c <= e } |
| 170 | + } |
| 171 | + |
| 172 | + object Range { |
| 173 | + private[gitignore] lazy val parser: Parser[Range] = { |
| 174 | + val range: Parser[(Char, Char)] = |
| 175 | + ((&!(']') ~ Literal.parser) ~ ('-' ~ (&!(']') ~ Literal.parser)).?).map { |
| 176 | + case (b, Some(e)) => (b.char, e.char) |
| 177 | + case (c, None) => (c.char, c.char) |
| 178 | + } |
| 179 | + |
| 180 | + ('[' ~ ('!'.as(true) | pass(false)) ~ range.rep ~ ']').map { case (ne, rs) => Range(ne, rs) } |
| 181 | + } |
| 182 | + } |
| 183 | + |
| 184 | + /** Literal is a literal character in a glob. */ |
| 185 | + final case class Literal(char: Char) extends GlobChar { |
| 186 | + private[gitignore] def accepts(c: Char): Boolean = c == char |
| 187 | + } |
| 188 | + |
| 189 | + object Literal { |
| 190 | + private[gitignore] lazy val parser: Parser[Literal] = { |
| 191 | + val escape = '\\' ~ satisfy(_ => true).!.map(_.charAt(0)) |
| 192 | + val space = charInWhile(" \t") |
| 193 | + val char = satisfy(_ != '/').!.map(_.charAt(0)) |
| 194 | + |
| 195 | + (escape | (&!(space ~ end) ~ char)).map(Literal(_)) |
| 196 | + } |
| 197 | + } |
| 198 | +} |
0 commit comments