Skip to content
This repository was archived by the owner on Jan 26, 2022. It is now read-only.
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ lazy val root = crossProject(JVMPlatform, JSPlatform, NativePlatform)
.crossType(CrossType.Pure)
.in(file("."))
.settings(commonGlobalSettings)
.aggregate(crazy, delta, gestalt, gimei, grapheme, parser, pfix, romaji, show)
.aggregate(crazy, delta, gestalt, gimei, gitignore, grapheme, parser, pfix, romaji, show)

lazy val rootJVM = root.jvm
lazy val rootJS = root.js
Expand Down Expand Up @@ -171,11 +171,27 @@ lazy val useGimeiDataGenerator = {
)
}

lazy val gitignore = crossProject(JVMPlatform)
.in(file("modules/lite-gitignore"))
.settings(
name := "lite-gitignore",
console / initialCommands += "import java.nio.file.Files\n",
console / initialCommands += "import java.nio.file.Path\n",
console / initialCommands += "import java.nio.file.Paths\n",
console / initialCommands += "\n",
console / initialCommands += "import codes.quine.labo.lite.gitignore._\n",
commonSettings,
useMunit
)
.dependsOn(parser)

lazy val gitignoreJVM = gitignore.jvm

lazy val grapheme = crossProject(JVMPlatform, JSPlatform, NativePlatform)
.in(file("modules/lite-grapheme"))
.settings(
name := "lite-grapheme",
console / initialCommands := "import codes.quine.labo.lite.grapheme._\n",
console / initialCommands += "import codes.quine.labo.lite.grapheme._\n",
commonSettings,
useMunit,
coverageExcludedPackages := "<empty>;codes\\.quine\\.labo\\.lite\\.grapheme\\.Data.*",
Expand Down Expand Up @@ -226,7 +242,7 @@ lazy val parser = crossProject(JVMPlatform, JSPlatform, NativePlatform)
.in(file("modules/lite-parser"))
.settings(
name := "lite-parser",
console / initialCommands := "import codes.quine.labo.lite.parser._\n",
console / initialCommands += "import codes.quine.labo.lite.parser._\n",
commonSettings,
useMunit
)
Expand All @@ -241,7 +257,7 @@ lazy val pfix = crossProject(JVMPlatform, JSPlatform, NativePlatform)
.in(file("modules/lite-pfix"))
.settings(
name := "lite-pfix",
console / initialCommands := "import codes.quine.labo.lite.pfix._\n",
console / initialCommands += "import codes.quine.labo.lite.pfix._\n",
commonSettings,
useMunit
)
Expand All @@ -256,7 +272,7 @@ lazy val romaji = crossProject(JVMPlatform, JSPlatform, NativePlatform)
.in(file("modules/lite-romaji"))
.settings(
name := "lite-romaji",
console / initialCommands := "import codes.quine.labo.lite.romaji._\n",
console / initialCommands += "import codes.quine.labo.lite.romaji._\n",
commonSettings,
useMunit
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package codes.quine.labo.lite.gitignore

import java.nio.file.Files
import java.nio.file.LinkOption
import java.nio.file.Path

import scala.annotation.tailrec
import scala.jdk.CollectionConverters._

import codes.quine.labo.lite.parser.Parser

/** GlobPath is a path matcher including a glob pattern. */
sealed abstract class GlobPath extends Product with Serializable {

/** Checks whether or not the given path matches this. */
def matches(path: Path): Boolean
}

object GlobPath {

import Parser._

/** Parses the given line as a path matcher.
* When the line is not a valid path matcher (e.g. comment), it returns `None` instead.
*/
def parse(line: String, base: Path): Option[(Boolean, GlobPath)] =
if (line.startsWith("#")) None
else {
val isNegated = line.startsWith("!")
parser(base).parse(line, if (isNegated) 1 else 0) match {
case Right((_, path)) => path.map((isNegated, _))
case Left(_) => {
// $COVERAGE-OFF$
None
// $COVERAGE-ON$
}
}
}

private[gitignore] def parser(base: Path): Parser[Option[GlobPath]] = {
val space = charInWhile(" \t", min = 0)

(Component.parser ~ ('/' ~ Component.parser).rep ~ space ~ end).map {
case (Glob.Empty, Seq()) => None
case (glob: Glob, Seq(Glob.Empty)) => Some(FileNameGlobPath(glob, isDir = true))
case (glob: Glob, Seq()) => Some(FileNameGlobPath(glob, isDir = false))
case (Glob.Empty, cs :+ Glob.Empty) => Some(RelativeGlobPath(cs, isDir = true, base))
case (Glob.Empty, cs) => Some(RelativeGlobPath(cs, isDir = false, base))
case (c, cs :+ Glob.Empty) => Some(RelativeGlobPath(c +: cs, isDir = true, base))
case (c, cs) => Some(RelativeGlobPath(c +: cs, isDir = false, base))
}
}

/** RelativeGlobPath is a path matcher to match a path from a base path. */
final case class RelativeGlobPath(
components: Seq[Component],
isDir: Boolean,
base: Path
) extends GlobPath {
def matches(path: Path): Boolean = {
val rel = base.relativize(path)
val rels = rel.iterator().asScala.map(_.toString).toVector
if (rels.isEmpty || rels.head == "..") false
else {
@tailrec
def loop(pos: Int, state: Seq[Component], nextPos: Int, nextState: Seq[Component]): Boolean =
if (pos >= rels.length && state.isEmpty) true
else
state.headOption match {
case Some(StarStar) => loop(pos, state.tail, pos + 1, state)
case Some(g: Glob) if pos < rels.size && g.matches(rels(pos)) =>
loop(pos + 1, state.tail, nextPos, nextState)
case _ if 0 < nextPos && nextPos <= rels.size =>
loop(nextPos, nextState, nextPos, nextState)
case _ => false
}

val matched = loop(0, components, 0, components)
if (matched && isDir) Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)
else matched
}
}
}

/** FileNameGlobPath is a path matcher to match a filename of a path. */
final case class FileNameGlobPath(glob: Glob, isDir: Boolean) extends GlobPath {
def matches(path: Path): Boolean = {
// When `path` is root, `path.getFileName` returns `null`, so it is wrapped by `Option`.
val matched = Option(path.getFileName).exists(fileName => glob.matches(fileName.toString))
if (matched && isDir) Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)
else matched
}
}

/** Component is a component of a path matcher. */
sealed abstract class Component extends Product with Serializable

object Component {
private[gitignore] lazy val parser: Parser[Component] = StarStar.parser | Glob.parser
}

/** StarStar is a double star glob. */
case object StarStar extends Component {
lazy val parser: Parser[Component] = "**".as(StarStar)
}

/** Glob is a glob to match a component of a path. */
final case class Glob(chars: Seq[GlobChar]) extends Component {

/** Checks whether or not the given file name matches this. */
def matches(fileName: String): Boolean = {
@tailrec
def loop(pos: Int, state: Seq[GlobChar], nextPos: Int, nextState: Seq[GlobChar]): Boolean =
if (pos >= fileName.length && state.isEmpty) true
else
state.headOption match {
case Some(Star) => loop(pos, state.tail, pos + 1, state)
case Some(c) if pos < fileName.length && c.accepts(fileName(pos)) =>
loop(pos + 1, state.tail, nextPos, nextState)
case _ if 0 < nextPos && nextPos <= fileName.length =>
loop(nextPos, nextState, nextPos, nextState)
case _ => false
}
loop(0, chars, 0, chars)
}
}

object Glob {

/** An empty glob. */
val Empty: Glob = Glob(Seq.empty)

private[gitignore] val parser: Parser[Glob] = GlobChar.parser.rep.map(Glob(_))
}

/** GlobChar is a character in a glob. */
sealed abstract class GlobChar extends Product with Serializable {

/** Checks whether or not this accepts the given character. */
private[gitignore] def accepts(c: Char): Boolean
}

object GlobChar {
private[gitignore] lazy val parser: Parser[GlobChar] =
Star.parser | Quest.parser | Range.parser | Literal.parser
}

/** Star is `*` in a glob. */
case object Star extends GlobChar {
private[gitignore] def accepts(c: Char): Boolean = {
// $COVERAGE-OFF$
sys.error("GlobPath.Star#accepts: invalid call")
// $COVERAGE-ON$
}

private[gitignore] lazy val parser: Parser[GlobChar] = '*'.as(Star)
}

/** Quest is `?` in a glob. */
case object Quest extends GlobChar {
private[gitignore] def accepts(c: Char): Boolean = true

private[gitignore] lazy val parser: Parser[GlobChar] = '?'.as(Quest)
}

/** Range is a range of characters in a glob. */
final case class Range(isNegated: Boolean, ranges: Seq[(Char, Char)]) extends GlobChar {
private[gitignore] def accepts(c: Char): Boolean =
!isNegated == ranges.exists { case (b, e) => b <= c && c <= e }
}

object Range {
private[gitignore] lazy val parser: Parser[Range] = {
val range: Parser[(Char, Char)] =
((&!(']') ~ Literal.parser) ~ ('-' ~ (&!(']') ~ Literal.parser)).?).map {
case (b, Some(e)) => (b.char, e.char)
case (c, None) => (c.char, c.char)
}

('[' ~ ('!'.as(true) | pass(false)) ~ range.rep ~ ']').map { case (ne, rs) => Range(ne, rs) }
}
}

/** Literal is a literal character in a glob. */
final case class Literal(char: Char) extends GlobChar {
private[gitignore] def accepts(c: Char): Boolean = c == char
}

object Literal {
private[gitignore] lazy val parser: Parser[Literal] = {
val escape = '\\' ~ satisfy(_ => true).!.map(_.charAt(0))
val space = charInWhile(" \t")
val char = satisfy(_ != '/').!.map(_.charAt(0))

(escape | (&!(space ~ end) ~ char)).map(Literal(_))
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package codes.quine.labo.lite.gitignore

import java.nio.file.Path

import codes.quine.labo.lite.gitignore.GlobPath._

class GlobPathSuite extends munit.FunSuite {
val resourcePath: Path = Path.of("modules/lite-gitignore/shared/src/test/resources").toAbsolutePath

test("GlobPath.parse") {
def glob(s: String): Glob = Glob(s.toSeq.map(Literal(_)))
val base = resourcePath.resolve("glob-path")
assertEquals(parse("# comment", base), None)
assertEquals(parse("", base), None)
assertEquals(parse("!", base), None)
assertEquals(parse("foo", base), Some((false, FileNameGlobPath(glob("foo"), false))))
assertEquals(parse("foo ", base), Some((false, FileNameGlobPath(glob("foo"), false))))
assertEquals(parse("foo/", base), Some((false, FileNameGlobPath(glob("foo"), true))))
assertEquals(parse("/foo", base), Some((false, RelativeGlobPath(Seq(glob("foo")), false, base))))
assertEquals(parse("/foo/", base), Some((false, RelativeGlobPath(Seq(glob("foo")), true, base))))
assertEquals(parse("foo/bar", base), Some((false, RelativeGlobPath(Seq(glob("foo"), glob("bar")), false, base))))
assertEquals(parse("foo/bar/", base), Some((false, RelativeGlobPath(Seq(glob("foo"), glob("bar")), true, base))))
assertEquals(parse("/foo/bar", base), Some((false, RelativeGlobPath(Seq(glob("foo"), glob("bar")), false, base))))
assertEquals(parse("/foo/bar/", base), Some((false, RelativeGlobPath(Seq(glob("foo"), glob("bar")), true, base))))
assertEquals(parse("**", base), Some((false, RelativeGlobPath(Seq(StarStar), false, base))))
assertEquals(parse("x/**", base), Some((false, RelativeGlobPath(Seq(glob("x"), StarStar), false, base))))
assertEquals(parse("**/y", base), Some((false, RelativeGlobPath(Seq(StarStar, glob("y")), false, base))))
assertEquals(parse("[a]", base), Some((false, FileNameGlobPath(Glob(Seq(Range(false, Seq(('a', 'a'))))), false))))
assertEquals(parse("[!a]", base), Some((false, FileNameGlobPath(Glob(Seq(Range(true, Seq(('a', 'a'))))), false))))
assertEquals(parse("[a-c]", base), Some((false, FileNameGlobPath(Glob(Seq(Range(false, Seq(('a', 'c'))))), false))))
assertEquals(parse("*", base), Some((false, FileNameGlobPath(Glob(Seq(Star)), false))))
assertEquals(parse("?", base), Some((false, FileNameGlobPath(Glob(Seq(Quest)), false))))
assertEquals(parse("\\\\", base), Some((false, FileNameGlobPath(glob("\\"), false))))
assertEquals(parse("!foo", base), Some((true, FileNameGlobPath(glob("foo"), false))))
}

test("GlobPath#matches") {
val base = resourcePath.resolve("glob-path")
def matches(line: String, path: String): Boolean = parse(line, base).get._2.matches(base.resolve(path))
assertEquals(matches("foo", "foo"), true)
assertEquals(matches("foo", "bar"), false)
assertEquals(matches("foo", "x/foo"), true)
assertEquals(matches("foo", "x/bar"), false)
assertEquals(matches("f*", "foo"), true)
assertEquals(matches("f*", "bar"), false)
assertEquals(matches("*o", "foo"), true)
assertEquals(matches("*o", "bar"), false)
assertEquals(matches("f*o", "foo"), true)
assertEquals(matches("f*o", "bar"), false)
assertEquals(matches("[a-z]oo", "foo"), true)
assertEquals(matches("[a-z]oo", "bar"), false)
assertEquals(matches("????", "fizz"), true)
assertEquals(matches("????", "foo"), false)
assertEquals(matches("????", "bar"), false)
assertEquals(matches("x/foo", "x/foo"), true)
assertEquals(matches("x/foo", "x/bar"), false)
assertEquals(matches("x/foo", ".."), false)
assertEquals(matches("**/foo", "foo"), true)
assertEquals(matches("**/foo", "bar"), false)
assertEquals(matches("**/foo", "x/foo"), true)
assertEquals(matches("**/foo", "x/bar"), false)
assertEquals(matches("**/foo", "x/y/z/foo"), true)
assertEquals(matches("**/foo", "x/y/z/bar"), false)
assertEquals(matches("*/", "x"), true)
assertEquals(matches("*/", "foo"), false)
assertEquals(matches("/*/", "x"), true)
assertEquals(matches("/*/", "foo"), false)
assertEquals(matches("/*/", "x/y"), false)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,12 @@ object Parser {
/** Returns a parser to be failed with the given message. */
def fail(message: String): Parser[Any] = new Fail(message)

/** Returns a positive look-ahead parser. */
def &?[A](parser: Parser[A]): Parser[Unit] = new PosLookAhead(parser)

/** Returns a negative look-ahead parser. */
def &![A](parser: Parser[A]): Parser[Unit] = new NegLookAhead(parser)

/** Returns a delayed parser. */
def delay[A](parser: => Parser[A]): Parser[A] = new Delay(() => parser)

Expand Down Expand Up @@ -378,21 +384,21 @@ object Parser {
}

/** [[Parser.pass]] implementation. */
private class Pass[A](value: A) extends Parser[A] {
private final class Pass[A](value: A) extends Parser[A] {
def unsafeParse(state: State): Unit = {
state.done(state.offset, value)
}
}

/** [[Parser.fail]] implementation. */
private class Fail(message: String) extends Parser[Any] {
private final class Fail(message: String) extends Parser[Any] {
def unsafeParse(state: State): Unit = {
state.failed(Error.Failure(state.offset, message))
}
}

/** [[Parser.delay]] implementation. */
private class Delay[A](var parser0: () => Parser[A]) extends Parser[A] {
private final class Delay[A](var parser0: () => Parser[A]) extends Parser[A] {
lazy val parser: Parser[A] = {
val p = parser0()
parser0 = null
Expand All @@ -401,4 +407,21 @@ object Parser {

def unsafeParse(state: State): Unit = parser.unsafeParse(state)
}

private final class PosLookAhead[A](val parser: Parser[A]) extends Parser[Unit] {
def unsafeParse(state: State): Unit = {
val offset = state.offset
parser.unsafeParse(state)
if (state.isOK) state.done(offset, ())
}
}

private final class NegLookAhead[A](val parser: Parser[A]) extends Parser[Unit] {
def unsafeParse(state: State): Unit = {
val offset = state.offset
parser.unsafeParse(state)
if (!state.isOK) state.done(offset, ())
else state.failed(Error.Unexpected(offset))
}
}
}
Loading