Skip to content

Commit

Permalink
Add Parser.caret (#301)
Browse files Browse the repository at this point in the history
* Add Parser.caret

* actually add Caret

* fix 2.11 compilation

* respond to review
  • Loading branch information
johnynek committed Nov 12, 2021
1 parent 55a146e commit 22b34f1
Show file tree
Hide file tree
Showing 5 changed files with 150 additions and 21 deletions.
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 @@ -1629,7 +1629,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 @@ -1683,6 +1683,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 @@ -1717,7 +1722,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 @@ -1837,6 +1842,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 @@ -1885,8 +1894,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 @@ -1916,7 +1926,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 @@ -1934,7 +1944,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 @@ -2172,6 +2182,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 @@ -2479,4 +2484,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)
}
}
}

0 comments on commit 22b34f1

Please sign in to comment.