Skip to content

Commit 5ee9ef4

Browse files
committed
Initial implementation
Does not work with no splice. See scala/scala3#5119
1 parent 8f9a807 commit 5ee9ef4

File tree

8 files changed

+329
-52
lines changed

8 files changed

+329
-52
lines changed

src/main/scala/jsx/Jsx.scala

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
package jsx
22

33
object Jsx {
4-
case class Repr(parts: String, arg: Any)
4+
sealed trait Node
5+
6+
case class Element(
7+
name: String,
8+
attributes: List[Attribute],
9+
children: List[Node]
10+
) extends Node
11+
12+
object Element {
13+
def apply(name: String, attributes: List[Attribute], children: Node*): Element =
14+
Element(name, attributes, children.toList)
15+
}
16+
17+
case class Attribute(name: String, value: String)
18+
19+
case class Text(value: String) extends Node
20+
21+
// Compile time only.
22+
// Ideally, we should have two ASTs. An internal one that
23+
// knows about splices and a public one that doesn't.
24+
case class Splice(index: Int) extends Node
525
}

src/main/scala/jsx/JsxQuote.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
package jsx
22

3-
import scala.quoted._
3+
import internal.QuoteImpl
44

55
// Ideally should be an implicit class but the implicit conversion
66
// has to be a rewrite method
77
class JsxQuote(ctx: => StringContext) {
8-
rewrite def jsx(args: => Any*): Jsx.Repr = ~Macros.quoteImpl('(ctx), '(args))
8+
rewrite def jsx(args: => Any*): Jsx.Element = ~QuoteImpl('(ctx), '(args))
99
}
1010

1111
object JsxQuote {

src/main/scala/jsx/Macros.scala

Lines changed: 0 additions & 47 deletions
This file was deleted.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package jsx.internal
2+
3+
object Hole {
4+
// withing private use area
5+
private final val HoleStart = '\uE000'
6+
private final val HoleChar = '\uE001'
7+
8+
def isHoleStart(ch: Char) = ch == HoleStart
9+
def isHoleChar(ch: Char) = isHoleStart(ch) || ch == HoleChar
10+
11+
def encode(i: Int): String = HoleStart + HoleChar.toString * i
12+
def decode(cs: String): Int = cs.takeWhile(isHoleChar).length - 1
13+
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package jsx.internal
2+
3+
import jsx.Jsx._
4+
5+
final class JsxParser(in: String) {
6+
import JsxParser._
7+
8+
private[this] var offset = 0
9+
private def next(): Unit = offset += 1
10+
private def isAtEnd = offset >= in.length
11+
12+
private def error(msg: String, pos: Int = offset): Nothing =
13+
throw new ParseError(msg, pos)
14+
15+
private def ch_unsafe = in.charAt(offset)
16+
17+
private def ch =
18+
if (isAtEnd)
19+
error("unexpected end of input")
20+
else
21+
ch_unsafe
22+
23+
private def nextChar =
24+
if (offset + 1 < in.length) Some(in.charAt(offset + 1))
25+
else None
26+
27+
private def accept(expected: Char): Unit =
28+
if (isAtEnd)
29+
error(s"expected: '$expected', found end of input")
30+
else if (ch_unsafe != expected)
31+
error(s"expected: '$expected', found: '$ch_unsafe'")
32+
else
33+
next()
34+
35+
private def takeWhile(pred: Char => Boolean): String = {
36+
val value = new StringBuilder()
37+
38+
while (!isAtEnd && pred(ch_unsafe)) {
39+
value += ch_unsafe
40+
next()
41+
}
42+
43+
value.toString
44+
}
45+
46+
private def spaces(): Unit = takeWhile(_.isWhitespace)
47+
48+
def parse(): Element = {
49+
spaces()
50+
val elem = element()
51+
spaces()
52+
53+
if (!isAtEnd)
54+
error(s"expected end of input, found $ch")
55+
56+
elem
57+
}
58+
59+
private def element(): Element = {
60+
accept('<')
61+
val name = identifier()
62+
spaces()
63+
val atts = attributes()
64+
spaces()
65+
66+
if (ch == '/') { // closing element (e.g. <div/>)
67+
accept('/')
68+
accept('>')
69+
return Element(name, atts, children = Nil)
70+
}
71+
accept('>')
72+
73+
val cs = children()
74+
75+
accept('<')
76+
spaces()
77+
accept('/')
78+
spaces()
79+
val cname = identifier()
80+
if (name != cname)
81+
error(s"names of opening element and closing element should match: $name != $cname")
82+
spaces()
83+
accept('>')
84+
85+
Element(name, atts, cs)
86+
}
87+
88+
private def isNameStart(ch: Char) = ch.isLetter
89+
90+
private def identifier(): String = {
91+
def isNameChar(ch: Char) = isNameStart(ch) || ch == '-'
92+
93+
val id = takeWhile(isNameChar)
94+
95+
if (id.isEmpty)
96+
error("identifier expected")
97+
else if (!isNameStart(id.head))
98+
error("unexpected element name start")
99+
100+
id
101+
}
102+
103+
/** attributes ::= { attribute }
104+
*/
105+
private def attributes(): List[Attribute] = {
106+
val atts = List.newBuilder[Attribute]
107+
108+
def isAttributeStart = !isAtEnd && isNameStart(ch_unsafe)
109+
110+
while (isAttributeStart) {
111+
atts += attribute()
112+
spaces()
113+
}
114+
115+
atts.result()
116+
}
117+
118+
/** attribute ::= identifier = attribute_value
119+
*
120+
* attribute_value ::= hole
121+
* | "DoubleStringCharacters"
122+
* | 'SingleStringCharacters'
123+
*/
124+
private def attribute(): Attribute = {
125+
val name = identifier()
126+
127+
spaces()
128+
accept('=')
129+
spaces()
130+
131+
val value = ch match {
132+
case '"' =>
133+
takeWhile(_ != '"')
134+
case '\'' =>
135+
takeWhile(_ != '\'')
136+
case other =>
137+
error(s"""expected: '"' or ''', found: '$other'""")
138+
}
139+
next()
140+
141+
Attribute(name, value)
142+
}
143+
144+
private def children(): List[Node] = {
145+
val cs = List.newBuilder[Node]
146+
var done = false
147+
148+
while (!done && !isAtEnd) {
149+
ch_unsafe match {
150+
case '<' =>
151+
nextChar match {
152+
case None =>
153+
error("closing tag or identifier expected", pos = offset + 1)
154+
case Some('/') =>
155+
done = true
156+
case _ =>
157+
cs += element()
158+
}
159+
160+
case c if Hole.isHoleStart(c) =>
161+
val hole = takeWhile(Hole.isHoleChar)
162+
cs += Splice(Hole.decode(hole))
163+
164+
case _ =>
165+
val text = takeWhile(_ != '<')
166+
cs += Text(text)
167+
}
168+
}
169+
170+
cs.result()
171+
}
172+
}
173+
174+
object JsxParser {
175+
final case class ParseError(msg: String, pos: Int) extends Exception(msg)
176+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package jsx.internal
2+
3+
import scala.quoted._
4+
5+
import jsx.Jsx._
6+
7+
trait Lifter {
8+
9+
// Only support string splices for now
10+
def liftSplice(index: Int): Expr[String]
11+
12+
def liftElement(elem: Element): Expr[Element] = elem.toExpr
13+
14+
private implicit val nodeLiftable: Liftable[Node] = {
15+
case Splice(index) =>
16+
val value = liftSplice(index)
17+
'(Text(~value))
18+
case Text(value) =>
19+
'(Text(~value.toExpr))
20+
case elem: Element =>
21+
elem.toExpr
22+
}
23+
24+
private implicit val elementLiftable: Liftable[Element] = {
25+
case Element(name, attributes, children) =>
26+
'(Element(~name.toExpr, ~attributes.toExpr, ~children.toExpr))
27+
}
28+
29+
private implicit val attributeLiftable: Liftable[Attribute] =
30+
(att: Attribute) => '(Attribute(~att.name.toExpr, ~att.value.toExpr))
31+
32+
private implicit def listLiftable[T : Liftable : Type]: Liftable[List[T]] = {
33+
case x :: xs => '{ ~x.toExpr :: ~xs.toExpr }
34+
case Nil => '(Nil)
35+
}
36+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package jsx.internal
2+
3+
import scala.quoted._
4+
import scala.tasty.Tasty
5+
6+
import jsx.Jsx.Element
7+
8+
final class QuoteImpl(tasty: Tasty) {
9+
import tasty._
10+
11+
// for debugging purpose
12+
private def pp(tree: Tree): Unit = {
13+
println(tree.show)
14+
println(tasty.showSourceCode.showTree(tree))
15+
}
16+
17+
// TODO: figure out position
18+
private def abort(msg: String, pos: Int): Nothing =
19+
throw new QuoteError(msg)
20+
21+
private def mkString(parts: List[String]): String = {
22+
val sb = new StringBuilder
23+
sb.append(parts.head)
24+
for ((part, i) <- parts.tail.zipWithIndex) {
25+
sb.append(Hole.encode(i))
26+
.append(part)
27+
}
28+
sb.toString
29+
}
30+
31+
def expr(sc: Expr[StringContext], args: Expr[Seq[Any]]): Expr[Element] = {
32+
import Term._
33+
34+
def isStringConstant(tree: Term) = tree match {
35+
case Literal(_) => true // can only be a String, otherwise would not typecheck
36+
case _ => false
37+
}
38+
39+
// _root_.scala.StringContext.apply([p0, ...]: String*)
40+
val parts = sc.toTasty match {
41+
case Inlined(_, _,
42+
Apply(
43+
Select(Select(Select(Ident("_root_"), "scala", _), "StringContext", _), "apply", _),
44+
List(Typed(Repeated(values), _)))) if values.forall(isStringConstant) =>
45+
values.collect { case Literal(Constant.String(value)) => value }
46+
case tree =>
47+
// TODO: figure out position
48+
pp(tree)
49+
abort("String literal expected", 0)
50+
}
51+
52+
val elem =
53+
try new JsxParser(mkString(parts)).parse()
54+
catch {
55+
case JsxParser.ParseError(msg, pos) =>
56+
abort(s"Parsing error at pos $pos: $msg", pos)
57+
}
58+
59+
// [a0, ...]: Any*
60+
val Inlined(_, _, Typed(Repeated(args0), _)) = args.toTasty
61+
val lifter = new Lifter {
62+
def liftSplice(index: Int) = {
63+
val splice = args0(index)
64+
if (splice.tpe <:< definitions.AnyType) // TODO: should be definitions.StringType
65+
splice.toExpr[String]
66+
else
67+
abort(s"Type missmatch: expected String, found ${splice.tpe.show}", 0) // TODO: splice.pos
68+
}
69+
}
70+
lifter.liftElement(elem)
71+
}
72+
}
73+
74+
object QuoteImpl {
75+
def apply(sc: Expr[StringContext], args: Expr[Seq[Any]])(implicit tasty: Tasty): Expr[Element] =
76+
new QuoteImpl(tasty).expr(sc, args)
77+
}

0 commit comments

Comments
 (0)