Skip to content

Commit 69d363b

Browse files
authored
Merge pull request #11267 from lampepfl/scala3doc/js-tests
Further enhancements of scala3doc searchbar
2 parents c3e26df + 72b0729 commit 69d363b

File tree

9 files changed

+213
-33
lines changed

9 files changed

+213
-33
lines changed

.github/workflows/scaladoc.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ jobs:
3535
with:
3636
java-version: 11
3737

38+
- name: Compile and test scala3doc-js
39+
run: ./project/scripts/sbt scaladoc-js/test
40+
3841
- name: Compile and test
3942
run: ./project/scripts/sbt scaladoc/test
4043

scaladoc-js/resources/scaladoc-searchbar.css

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,10 @@
4141
}
4242

4343
#scaladoc-searchbar {
44-
position: absolute;
44+
position: fixed;
4545
top: 50px;
4646
right: 40px;
47+
z-index: 5;
4748
width: calc(100% - 360px);
4849
box-shadow: 0 2px 16px 0 rgba(0, 42, 76, 0.15);
4950
font-size: 13px;
@@ -71,6 +72,7 @@
7172
}
7273

7374
.scaladoc-searchbar-result {
75+
background: white;
7476
line-height: 32px;
7577
padding-left: 10px;
7678
padding-right: 10px;
@@ -80,7 +82,7 @@
8082
margin-top: 10px;
8183
}
8284

83-
.scaladoc-searchbar-result:hover {
85+
.scaladoc-searchbar-result[selected] {
8486
background-color: #d4edff;
8587
}
8688

scaladoc-js/src/Globals.scala

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,11 @@ import scala.scalajs.js.annotation.JSGlobalScope
77
@JSGlobalScope
88
object Globals extends js.Object {
99
val pathToRoot: String = js.native
10+
}
11+
12+
object StringUtils {
13+
def createCamelCaseTokens(s: String): List[String] =
14+
if s.isEmpty then List.empty
15+
else if s.tail.indexWhere(_.isUpper) == -1 then List(s)
16+
else List(s.take(s.tail.indexWhere(_.isUpper) + 1)) ++ createCamelCaseTokens(s.drop(s.tail.indexWhere(_.isUpper) + 1))
1017
}

scaladoc-js/src/searchbar/PageEntry.scala

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,15 @@ case class PageEntry(
1515
description: String,
1616
location: String,
1717
shortName: String,
18-
acronym: Option[String]
18+
tokens: List[String]
1919
)
2020

2121
object PageEntry {
22-
private def createAcronym(s: String): Option[String] =
23-
s.headOption.map(firstLetter => firstLetter.toString ++ s.tail.filter(_.isUpper))
24-
2522
def apply(jsObj: PageEntryJS): PageEntry = PageEntry(
26-
jsObj.t,
27-
jsObj.d,
28-
jsObj.l,
29-
jsObj.n.toLowerCase,
30-
createAcronym(jsObj.n)
31-
)
23+
jsObj.t,
24+
jsObj.d,
25+
jsObj.l,
26+
jsObj.n.toLowerCase,
27+
StringUtils.createCamelCaseTokens(jsObj.n)
28+
)
3229
}

scaladoc-js/src/searchbar/SearchbarComponent.scala

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ class SearchbarComponent(val callback: (String) => List[PageEntry]):
2222

2323
wrapper.appendChild(resultA)
2424
wrapper.appendChild(location)
25+
wrapper.addEventListener("mouseover", {
26+
case e: MouseEvent => handleHover(wrapper)
27+
})
2528
wrapper
2629

2730
def handleNewQuery(query: String) =
@@ -52,7 +55,10 @@ class SearchbarComponent(val callback: (String) => List[PageEntry]):
5255
if (document.body.contains(rootDiv)) {
5356
document.body.removeChild(rootDiv)
5457
}
55-
else document.body.appendChild(rootDiv)
58+
else {
59+
document.body.appendChild(rootDiv)
60+
input.focus()
61+
}
5662

5763
val element = createNestingDiv("search-content")(
5864
createNestingDiv("search-container")(
@@ -69,6 +75,7 @@ class SearchbarComponent(val callback: (String) => List[PageEntry]):
6975
val element = document.createElement("input").asInstanceOf[html.Input]
7076
element.id = "scaladoc-searchbar-input"
7177
element.addEventListener("input", (e) => handleNewQuery(e.target.asInstanceOf[html.Input].value))
78+
element.autocomplete = "off"
7279
element
7380

7481
private val resultsDiv: html.Div =
@@ -94,9 +101,58 @@ class SearchbarComponent(val callback: (String) => List[PageEntry]):
94101
document.body.removeChild(element)
95102
}
96103
)
104+
element.addEventListener("keydown", {
105+
case e: KeyboardEvent =>
106+
if e.keyCode == 40 then handleArrowDown()
107+
else if e.keyCode == 38 then handleArrowUp()
108+
else if e.keyCode == 13 then handleEnter()
109+
})
97110
element.id = "scaladoc-searchbar"
98111
element.appendChild(input)
99112
element.appendChild(resultsDiv)
100113
element
101114

115+
private def handleArrowUp() = {
116+
val selectedElement = resultsDiv.querySelector("[selected]")
117+
if selectedElement != null then {
118+
selectedElement.removeAttribute("selected")
119+
val sibling = selectedElement.previousElementSibling
120+
if sibling != null then {
121+
sibling.setAttribute("selected", "")
122+
resultsDiv.scrollTop = sibling.asInstanceOf[html.Element].offsetTop - (2 * sibling.asInstanceOf[html.Element].clientHeight)
123+
}
124+
}
125+
}
126+
private def handleArrowDown() = {
127+
val selectedElement = resultsDiv.querySelector("[selected]")
128+
if selectedElement != null then {
129+
val sibling = selectedElement.nextElementSibling
130+
if sibling != null then {
131+
selectedElement.removeAttribute("selected")
132+
sibling.setAttribute("selected", "")
133+
resultsDiv.scrollTop = sibling.asInstanceOf[html.Element].offsetTop - (2 * sibling.asInstanceOf[html.Element].clientHeight)
134+
}
135+
} else {
136+
val firstResult = resultsDiv.firstElementChild
137+
if firstResult != null then {
138+
firstResult.setAttribute("selected", "")
139+
resultsDiv.scrollTop = firstResult.asInstanceOf[html.Element].offsetTop - (2 * firstResult.asInstanceOf[html.Element].clientHeight)
140+
}
141+
}
142+
}
143+
private def handleEnter() = {
144+
val selectedElement = resultsDiv.querySelector("[selected] a").asInstanceOf[html.Element]
145+
if selectedElement != null then {
146+
selectedElement.click()
147+
}
148+
}
149+
150+
private def handleHover(elem: html.Element) = {
151+
val selectedElement = resultsDiv.querySelector("[selected]")
152+
if selectedElement != null then {
153+
selectedElement.removeAttribute("selected")
154+
}
155+
elem.setAttribute("selected","")
156+
}
157+
102158
handleNewQuery("")
Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
11
package dotty.tools.scaladoc
22

3-
enum Matchers extends Function1[PageEntry, Int]:
4-
case ByName(query: String)
5-
case ByKind(kind: String)
3+
sealed trait Matchers extends Function1[PageEntry, Int]
64

7-
def apply(p: PageEntry): Int = this match {
8-
case ByName(query) => {
9-
val nameOption = Option(p.shortName)
10-
val acronym = p.acronym
11-
//Edge case for empty query string
12-
if query == "" then 1
13-
else {
14-
val results = List(
15-
nameOption.filter(_.contains(query.toLowerCase)).fold(-1)(_.size - query.size),
16-
acronym.filter(_.contains(query)).fold(-1)(_.size - query.size + 1)
17-
)
18-
if results.forall(_ == -1) then -1 else results.filter(_ != -1).min
19-
}
5+
case class ByName(query: String) extends Matchers:
6+
val tokens = StringUtils.createCamelCaseTokens(query)
7+
def apply(p: PageEntry): Int = {
8+
val nameOption = Option(p.shortName.toLowerCase)
9+
//Edge case for empty query string
10+
if query == "" then 1
11+
else {
12+
val results = List(
13+
nameOption.filter(_.contains(query.toLowerCase)).fold(-1)(_.size - query.size),
14+
if p.tokens.size >= tokens.size && p.tokens.zip(tokens).forall( (token, query) => token.startsWith(query))
15+
then p.tokens.size - tokens.size + 1
16+
else -1
17+
//acronym.filter(_.contains(query)).fold(-1)(_.size - query.size + 1)
18+
)
19+
if results.forall(_ == -1) then -1 else results.filter(_ != -1).min
2020
}
21-
case ByKind(kind) => p.fullName.split(" ").headOption.filter(_.equalsIgnoreCase(kind)).fold(-1)(_ => 1)
2221
}
2322

23+
case class ByKind(kind: String) extends Matchers:
24+
def apply(p: PageEntry): Int = p.fullName.split(" ").headOption.filter(_.equalsIgnoreCase(kind)).fold(-1)(_ => 1)

scaladoc-js/src/searchbar/engine/QueryParser.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ class QueryParser:
2121
val escapedRegex = raw"`(.*)`".r
2222

2323
def parse(query: String): List[Matchers] = query match {
24-
case escapedRegex(rest) => List(Matchers.ByName(rest))
25-
case kindRegex(kind, rest) => List(Matchers.ByKind(kind)) ++ parse(rest)
26-
case restRegex(name) => List(Matchers.ByName(name))
24+
case escapedRegex(rest) => List(ByName(rest))
25+
case kindRegex(kind, rest) => List(ByKind(kind)) ++ parse(rest)
26+
case restRegex(name) => List(ByName(name))
2727
case _ => List()
2828
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package dotty.tools.scaladoc
2+
3+
import org.junit.{Test, Assert}
4+
import org.junit.Assert._
5+
6+
class MatchersTest:
7+
private val kinds = Seq(
8+
"class",
9+
"trait",
10+
"enum",
11+
"object",
12+
"def",
13+
"val",
14+
"var",
15+
"package",
16+
"given",
17+
"type"
18+
)
19+
private val names = Seq(
20+
"NullPointerException",
21+
"NPException",
22+
"Seq",
23+
"SeqOps",
24+
"writeBytes",
25+
"lessOrEqual",
26+
"testFuzzySearch1",
27+
"testF",
28+
"testFS"
29+
)
30+
private val pages = for {
31+
kind <- kinds
32+
name <- names
33+
} yield PageEntry(
34+
s"$kind $name",
35+
"",
36+
"",
37+
s"$name",
38+
StringUtils.createCamelCaseTokens(name)
39+
)
40+
41+
private def result(matchers: List[Matchers]) = {
42+
pages.map { p =>
43+
p -> matchers.map(_(p))
44+
}.filterNot { (page, results) =>
45+
results.exists(_ == -1)
46+
}.map((page, results) => page)
47+
}
48+
49+
@Test
50+
def testByKind = kinds.foreach { kind =>
51+
val res = result(List(ByKind(kind)))
52+
val expected = pages.filter(p => p.fullName.startsWith(kind)).toSet
53+
assertEquals(
54+
s"Matchers test error: for kind: $kind should match $expected but matched $res",
55+
expected,
56+
res.toSet
57+
)
58+
}
59+
60+
private def byNameTestCase(query: String, expectedMatch: String*) = expectedMatch.foreach { expMatch =>
61+
assertTrue(
62+
s"Matchers test error: for query: $query expected $expMatch",
63+
result(List(ByName(query))).exists(p => p.shortName.contains(expMatch))
64+
)
65+
}
66+
67+
@Test
68+
def testByName = {
69+
names.foreach(n => byNameTestCase(n, n))
70+
byNameTestCase("NPE", "NPException", "NullPointerException")
71+
byNameTestCase("NullPE", "NullPointerException")
72+
byNameTestCase("tFuzzS", "testFuzzySearch1")
73+
byNameTestCase("SO", "SeqOps")
74+
byNameTestCase("teFS", "testFS")
75+
byNameTestCase("writeBy", "writeBytes")
76+
byNameTestCase("seQ", "Seq")
77+
byNameTestCase("lOrEqu", "lessOrEqual")
78+
byNameTestCase("teF", "testFS", "testF")
79+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package dotty.tools.scaladoc
2+
3+
import org.junit.{Test, Assert}
4+
import org.junit.Assert._
5+
6+
class QueryParserTest:
7+
val queryParser = QueryParser()
8+
val kinds = Seq(
9+
"class",
10+
"trait",
11+
"enum",
12+
"object",
13+
"def",
14+
"val",
15+
"var",
16+
"package",
17+
"given",
18+
"type"
19+
)
20+
private def testCase(query: String, result: List[Matchers]) = {
21+
val parsed = queryParser.parse(query)
22+
assertEquals(
23+
s"Query parser test error: for query: $query expected $result but found $parsed",
24+
parsed,
25+
result
26+
)
27+
}
28+
29+
@Test
30+
def queryParserTests() = {
31+
kinds.foreach(k => testCase(s"$k ", List(ByKind(k), ByName(""))))
32+
testCase("trait", List(ByName("trait")))
33+
testCase("trait A", List(ByKind("trait"), ByName("A")))
34+
testCase("`trait A`", List(ByName("trait A")))
35+
}

0 commit comments

Comments
 (0)