Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Parser.caret #301

Merged
merged 4 commits into from
Nov 12, 2021
Merged
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
48 changes: 48 additions & 0 deletions core/shared/src/main/scala/cats/parse/Caret.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2021 Typelevel
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

package cats.parse

import cats.Order

/** This is a pointer to a zero based row, column, and total offset.
*/
case class Caret(row: Int, col: Int, offset: Int)

object Caret {
val Start: Caret = Caret(0, 0, 0)

implicit val caretOrder: Order[Caret] =
new Order[Caret] {
def compare(left: Caret, right: Caret): Int = {
val c0 = Integer.compare(left.row, right.row)
if (c0 != 0) c0
else {
val c1 = Integer.compare(left.col, right.col)
if (c1 != 0) c1
else Integer.compare(left.offset, right.offset)
}
}
}

implicit val caretOrdering: Ordering[Caret] =
caretOrder.toOrdering
}
46 changes: 32 additions & 14 deletions core/shared/src/main/scala/cats/parse/LocationMap.scala
Original file line number Diff line number Diff line change
Expand Up @@ -60,28 +60,34 @@ class LocationMap(val input: String) {
*/
def lineCount: Int = lines.length

def isValidOffset(offset: Int): Boolean =
(0 <= offset && offset <= input.length)

/** Given a string offset return the line and column If input.length is given (EOF) we return the
* same value as if the string were one character longer (i.e. if we have appended a non-newline
* character at the EOF)
*/
def toLineCol(offset: Int): Option[(Int, Int)] =
if (offset < 0 || offset > input.length) None
else if (offset == input.length) {
if (isValidOffset(offset)) {
val Caret(_, row, col) = toCaretUnsafeImpl(offset)
Some((row, col))
} else None

// This does not do bounds checking because we
// don't want to check twice. Callers to this need to
// do bounds check
private def toCaretUnsafeImpl(offset: Int): Caret =
if (offset == input.length) {
// this is end of line
if (offset == 0) Some((0, 0))
if (offset == 0) Caret.Start
else {
toLineCol(offset - 1)
.map { case (line, col) =>
if (endsWithNewLine) (line + 1, 0)
else (line, col + 1)
}
val Caret(_, line, col) = toCaretUnsafeImpl(offset - 1)
if (endsWithNewLine) Caret(offset, line + 1, 0)
else Caret(offset, line, col + 1)
}
} else {
val idx = Arrays.binarySearch(firstPos, offset)
if (idx == firstPos.length) {
// greater than all elements
None
} else if (idx < 0) {
if (idx < 0) {
// idx = (~(insertion pos) - 1)
// The insertion point is defined as the point at which the key would be
// inserted into the array: the index of the first element greater than
Expand All @@ -92,13 +98,25 @@ class LocationMap(val input: String) {
// so we are pointing into a row
val rowStart = firstPos(row)
val col = offset - rowStart
Some((row, col))
Caret(offset, row, col)
} else {
// idx is exactly the right value because offset is beginning of a line
Some((idx, 0))
Caret(offset, idx, 0)
}
}

/** Convert an offset to a Caret.
* @throws IllegalArgumentException
* if offset is longer than input
*/
def toCaretUnsafe(offset: Int): Caret =
if (isValidOffset(offset)) toCaretUnsafeImpl(offset)
else throw new IllegalArgumentException(s"offset = $offset exceeds ${input.length}")

def toCaret(offset: Int): Option[Caret] =
if (isValidOffset(offset)) Some(toCaretUnsafeImpl(offset))
else None

/** return the line without a newline
*/
def getLine(i: Int): Option[String] =
Expand Down
28 changes: 22 additions & 6 deletions core/shared/src/main/scala/cats/parse/Parser.scala
Original file line number Diff line number Diff line change
Expand Up @@ -1608,7 +1608,7 @@ object Parser {
case str if Impl.matchesString(str) => str.asInstanceOf[Parser0[String]]
case _ =>
Impl.unmap0(pa) match {
case Impl.Pure(_) | Impl.Index => emptyStringParser0
case Impl.Pure(_) | Impl.Index | Impl.GetCaret => emptyStringParser0
case notEmpty => Impl.StringP0(notEmpty)
}
}
Expand Down Expand Up @@ -1662,6 +1662,11 @@ object Parser {
*/
def index: Parser0[Int] = Impl.Index

/** return the current Caret (offset, line, column) this is a bit more expensive that just the
* index
*/
def caret: Parser0[Caret] = Impl.GetCaret

/** succeeds when we are at the start
*/
def start: Parser0[Unit] = Impl.StartParser
Expand Down Expand Up @@ -1696,7 +1701,7 @@ object Parser {
case p1: Parser[_] => as(p1, b)
case _ =>
Impl.unmap0(pa) match {
case Impl.Pure(_) | Impl.Index => pure(b)
case Impl.Pure(_) | Impl.Index | Impl.GetCaret => pure(b)
case notPure =>
Impl.Void0(notPure).map(Impl.ConstFn(b))
}
Expand Down Expand Up @@ -1816,6 +1821,10 @@ object Parser {
var offset: Int = 0
var error: Eval[Chain[Expectation]] = null
var capture: Boolean = true

// This is lazy because we don't want to trigger it
// unless someone uses GetCaret
lazy val locationMap: LocationMap = LocationMap(str)
}

// invariant: input must be sorted
Expand Down Expand Up @@ -1864,8 +1873,9 @@ object Parser {
final def doesBacktrack(p: Parser0[Any]): Boolean =
p match {
case Backtrack0(_) | Backtrack(_) | AnyChar | CharIn(_, _, _) | Str(_) | IgnoreCase(_) |
Length(_) | StartParser | EndParser | Index | Pure(_) | Fail() | FailWith(_) | Not(_) |
StringIn(_) =>
Length(_) | StartParser | EndParser | Index | GetCaret | Pure(_) | Fail() | FailWith(
_
) | Not(_) | StringIn(_) =>
true
case Map0(p, _) => doesBacktrack(p)
case Map(p, _) => doesBacktrack(p)
Expand Down Expand Up @@ -1895,7 +1905,7 @@ object Parser {
// and by construction, a oneOf0 never always succeeds
final def alwaysSucceeds(p: Parser0[Any]): Boolean =
p match {
case Index | Pure(_) => true
case Index | GetCaret | Pure(_) => true
case Map0(p, _) => alwaysSucceeds(p)
case SoftProd0(a, b) => alwaysSucceeds(a) && alwaysSucceeds(b)
case Prod0(a, b) => alwaysSucceeds(a) && alwaysSucceeds(b)
Expand All @@ -1913,7 +1923,7 @@ object Parser {
def unmap0(pa: Parser0[Any]): Parser0[Any] =
pa match {
case p1: Parser[Any] => unmap(p1)
case Pure(_) | Index => Parser.unit
case GetCaret | Index | Pure(_) => Parser.unit
case s if alwaysSucceeds(s) => Parser.unit
case Map0(p, _) =>
// we discard any allocations done by fn
Expand Down Expand Up @@ -2151,6 +2161,12 @@ object Parser {
override def parseMut(state: State): Int = state.offset
}

case object GetCaret extends Parser0[Caret] {
override def parseMut(state: State): Caret =
// This unsafe call is safe because the offset can never go too far
state.locationMap.toCaretUnsafe(state.offset)
}

final def backtrack[A](pa: Parser0[A], state: State): A = {
val offset = state.offset
val a = pa.parseMut(state)
Expand Down
32 changes: 32 additions & 0 deletions core/shared/src/test/scala/cats/parse/LocationMapTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -216,4 +216,36 @@ class LocationMapTest extends munit.ScalaCheckSuite {
assert(s.endsWith(lm.getLine(lm.lineCount - 1).get))
}
}

property("toLineCol and toCaret are consistent") {
forAll { (s: String, other: Int) =>
val lm = LocationMap(s)
(0 to s.length).foreach { offset =>
val c = lm.toCaretUnsafe(offset)
val oc = lm.toCaret(offset)
val lc = lm.toLineCol(offset)

assertEquals(oc, Some(c))
assertEquals(lc, oc.map { case Caret(_, r, c) => (r, c) })
}

if (other < 0 || s.length < other) {
assert(scala.util.Try(lm.toCaretUnsafe(other)).isFailure)
assertEquals(lm.toCaret(other), None)
assertEquals(lm.toLineCol(other), None)
}
}
}

property("Caret ordering matches offset ordering") {
forAll { (s: String, o1: Int, o2: Int) =>
val lm = LocationMap(s)
val c1 = lm.toCaret(o1)
val c2 = lm.toCaret(o2)

if (c1.isDefined && c2.isDefined) {
assertEquals(Ordering[Option[Caret]].compare(c1, c2), Integer.compare(o1, o2))
}
}
}
}
17 changes: 16 additions & 1 deletion core/shared/src/test/scala/cats/parse/ParserTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ object ParserGen {
def map[A, B](ga: Gen[A])(fn: A => B) = ga.map(fn)
}

implicit val cogenCaret: Cogen[Caret] =
Cogen { caret: Caret =>
(caret.offset.toLong << 32) | (caret.col.toLong << 16) | (caret.row.toLong)
}

def arbGen[A: Arbitrary: Cogen]: GenT[Gen] =
GenT(Arbitrary.arbitrary[A])

Expand Down Expand Up @@ -516,7 +521,7 @@ object ParserGen {
(5, expect0),
(1, ignoreCase0),
(5, charIn0),
(1, Gen.oneOf(GenT(Parser.start), GenT(Parser.end), GenT(Parser.index))),
(1, Gen.oneOf(GenT(Parser.start), GenT(Parser.end), GenT(Parser.index), GenT(Parser.caret))),
(1, fail),
(1, failWith),
(1, rec.map(void0(_))),
Expand Down Expand Up @@ -2460,4 +2465,14 @@ class ParserTest extends munit.ScalaCheckSuite {
assertEquals(v1.void, v1)
}
}

property("P.caret is the same as index + toCaretUnsafe") {
forAll(ParserGen.gen, Arbitrary.arbitrary[String]) { (p, input) =>
val v1 = p.fa.void
val lm = LocationMap(input)
val left = (v1 *> Parser.index).map(lm.toCaretUnsafe(_)).parse(input)
val right = (v1 *> Parser.caret).parse(input)
assertEquals(left, right)
}
}
}