Skip to content
This repository was archived by the owner on Jan 26, 2022. It is now read-only.

Commit 4146cd9

Browse files
committed
Implement GlobPath of lite-gitignore
1 parent 89dd94d commit 4146cd9

File tree

10 files changed

+293
-5
lines changed

10 files changed

+293
-5
lines changed

build.sbt

+25-5
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ lazy val root = crossProject(JVMPlatform, JSPlatform, NativePlatform)
5353
.crossType(CrossType.Pure)
5454
.in(file("."))
5555
.settings(commonGlobalSettings)
56-
.aggregate(crazy, delta, gestalt, gimei, grapheme, parser, pfix, romaji, show)
56+
.aggregate(crazy, delta, gestalt, gimei, gitignore, grapheme, parser, pfix, romaji, show)
5757

5858
lazy val rootJVM = root.jvm
5959
lazy val rootJS = root.js
@@ -171,11 +171,31 @@ lazy val useGimeiDataGenerator = {
171171
)
172172
}
173173

174+
lazy val gitignore = crossProject(JVMPlatform, JSPlatform, NativePlatform)
175+
.in(file("modules/lite-gitignore"))
176+
.settings(
177+
name := "lite-gitignore",
178+
console / initialCommands += "import java.nio.file.Files\n",
179+
console / initialCommands += "import java.nio.file.Path\n",
180+
console / initialCommands += "import java.nio.file.Paths\n",
181+
console / initialCommands += "\n",
182+
console / initialCommands += "import codes.quine.labo.lite.gitignore._\n",
183+
commonSettings,
184+
useMunit
185+
)
186+
.jsSettings(commonJSSettings)
187+
.nativeSettings(commonNativeSettings)
188+
.dependsOn(parser)
189+
190+
lazy val gitignoreJVM = gitignore.jvm
191+
lazy val gitignoreJS = gitignore.js
192+
lazy val gitignoreNative = gitignore.native
193+
174194
lazy val grapheme = crossProject(JVMPlatform, JSPlatform, NativePlatform)
175195
.in(file("modules/lite-grapheme"))
176196
.settings(
177197
name := "lite-grapheme",
178-
console / initialCommands := "import codes.quine.labo.lite.grapheme._\n",
198+
console / initialCommands += "import codes.quine.labo.lite.grapheme._\n",
179199
commonSettings,
180200
useMunit,
181201
coverageExcludedPackages := "<empty>;codes\\.quine\\.labo\\.lite\\.grapheme\\.Data.*",
@@ -226,7 +246,7 @@ lazy val parser = crossProject(JVMPlatform, JSPlatform, NativePlatform)
226246
.in(file("modules/lite-parser"))
227247
.settings(
228248
name := "lite-parser",
229-
console / initialCommands := "import codes.quine.labo.lite.parser._\n",
249+
console / initialCommands += "import codes.quine.labo.lite.parser._\n",
230250
commonSettings,
231251
useMunit
232252
)
@@ -241,7 +261,7 @@ lazy val pfix = crossProject(JVMPlatform, JSPlatform, NativePlatform)
241261
.in(file("modules/lite-pfix"))
242262
.settings(
243263
name := "lite-pfix",
244-
console / initialCommands := "import codes.quine.labo.lite.pfix._\n",
264+
console / initialCommands += "import codes.quine.labo.lite.pfix._\n",
245265
commonSettings,
246266
useMunit
247267
)
@@ -256,7 +276,7 @@ lazy val romaji = crossProject(JVMPlatform, JSPlatform, NativePlatform)
256276
.in(file("modules/lite-romaji"))
257277
.settings(
258278
name := "lite-romaji",
259-
console / initialCommands := "import codes.quine.labo.lite.romaji._\n",
279+
console / initialCommands += "import codes.quine.labo.lite.romaji._\n",
260280
commonSettings,
261281
useMunit
262282
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
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+
}

modules/lite-gitignore/shared/src/test/resources/glob-path/bar

Whitespace-only changes.

modules/lite-gitignore/shared/src/test/resources/glob-path/fizz

Whitespace-only changes.

modules/lite-gitignore/shared/src/test/resources/glob-path/foo

Whitespace-only changes.

modules/lite-gitignore/shared/src/test/resources/glob-path/x/bar

Whitespace-only changes.

modules/lite-gitignore/shared/src/test/resources/glob-path/x/foo

Whitespace-only changes.

modules/lite-gitignore/shared/src/test/resources/glob-path/x/y/z/bar

Whitespace-only changes.

modules/lite-gitignore/shared/src/test/resources/glob-path/x/y/z/foo

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package codes.quine.labo.lite.gitignore
2+
3+
import java.nio.file.Path
4+
5+
import codes.quine.labo.lite.gitignore.GlobPath._
6+
7+
class GlobPathSuite extends munit.FunSuite {
8+
val resourcePath: Path = Path.of("modules/lite-gitignore/shared/src/test/resources").toAbsolutePath
9+
10+
test("GlobPath.parse") {
11+
def glob(s: String): Glob = Glob(s.toSeq.map(Literal(_)))
12+
val base = resourcePath.resolve("glob-path")
13+
assertEquals(parse("# comment", base), None)
14+
assertEquals(parse("", base), None)
15+
assertEquals(parse("!", base), None)
16+
assertEquals(parse("foo", base), Some((false, FileNameGlobPath(glob("foo"), false))))
17+
assertEquals(parse("foo ", base), Some((false, FileNameGlobPath(glob("foo"), false))))
18+
assertEquals(parse("foo/", base), Some((false, FileNameGlobPath(glob("foo"), true))))
19+
assertEquals(parse("/foo", base), Some((false, RelativeGlobPath(Seq(glob("foo")), false, base))))
20+
assertEquals(parse("/foo/", base), Some((false, RelativeGlobPath(Seq(glob("foo")), true, base))))
21+
assertEquals(parse("foo/bar", base), Some((false, RelativeGlobPath(Seq(glob("foo"), glob("bar")), false, base))))
22+
assertEquals(parse("foo/bar/", base), Some((false, RelativeGlobPath(Seq(glob("foo"), glob("bar")), true, base))))
23+
assertEquals(parse("/foo/bar", base), Some((false, RelativeGlobPath(Seq(glob("foo"), glob("bar")), false, base))))
24+
assertEquals(parse("/foo/bar/", base), Some((false, RelativeGlobPath(Seq(glob("foo"), glob("bar")), true, base))))
25+
assertEquals(parse("**", base), Some((false, RelativeGlobPath(Seq(StarStar), false, base))))
26+
assertEquals(parse("x/**", base), Some((false, RelativeGlobPath(Seq(glob("x"), StarStar), false, base))))
27+
assertEquals(parse("**/y", base), Some((false, RelativeGlobPath(Seq(StarStar, glob("y")), false, base))))
28+
assertEquals(parse("[a]", base), Some((false, FileNameGlobPath(Glob(Seq(Range(false, Seq(('a', 'a'))))), false))))
29+
assertEquals(parse("[!a]", base), Some((false, FileNameGlobPath(Glob(Seq(Range(true, Seq(('a', 'a'))))), false))))
30+
assertEquals(parse("[a-c]", base), Some((false, FileNameGlobPath(Glob(Seq(Range(false, Seq(('a', 'c'))))), false))))
31+
assertEquals(parse("*", base), Some((false, FileNameGlobPath(Glob(Seq(Star)), false))))
32+
assertEquals(parse("?", base), Some((false, FileNameGlobPath(Glob(Seq(Quest)), false))))
33+
assertEquals(parse("\\\\", base), Some((false, FileNameGlobPath(glob("\\"), false))))
34+
assertEquals(parse("!foo", base), Some((true, FileNameGlobPath(glob("foo"), false))))
35+
}
36+
37+
test("GlobPath#matches") {
38+
val base = resourcePath.resolve("glob-path")
39+
def matches(line: String, path: String): Boolean = parse(line, base).get._2.matches(base.resolve(path))
40+
assertEquals(matches("foo", "foo"), true)
41+
assertEquals(matches("foo", "bar"), false)
42+
assertEquals(matches("foo", "x/foo"), true)
43+
assertEquals(matches("foo", "x/bar"), false)
44+
assertEquals(matches("f*", "foo"), true)
45+
assertEquals(matches("f*", "bar"), false)
46+
assertEquals(matches("*o", "foo"), true)
47+
assertEquals(matches("*o", "bar"), false)
48+
assertEquals(matches("f*o", "foo"), true)
49+
assertEquals(matches("f*o", "bar"), false)
50+
assertEquals(matches("[a-z]oo", "foo"), true)
51+
assertEquals(matches("[a-z]oo", "bar"), false)
52+
assertEquals(matches("????", "fizz"), true)
53+
assertEquals(matches("????", "foo"), false)
54+
assertEquals(matches("????", "bar"), false)
55+
assertEquals(matches("x/foo", "x/foo"), true)
56+
assertEquals(matches("x/foo", "x/bar"), false)
57+
assertEquals(matches("x/foo", ".."), false)
58+
assertEquals(matches("**/foo", "foo"), true)
59+
assertEquals(matches("**/foo", "bar"), false)
60+
assertEquals(matches("**/foo", "x/foo"), true)
61+
assertEquals(matches("**/foo", "x/bar"), false)
62+
assertEquals(matches("**/foo", "x/y/z/foo"), true)
63+
assertEquals(matches("**/foo", "x/y/z/bar"), false)
64+
assertEquals(matches("*/", "x"), true)
65+
assertEquals(matches("*/", "foo"), false)
66+
assertEquals(matches("/*/", "x"), true)
67+
assertEquals(matches("/*/", "foo"), false)
68+
assertEquals(matches("/*/", "x/y"), false)
69+
}
70+
}

0 commit comments

Comments
 (0)