zio.http.codec.Doc.scala Maven / Gradle / Ivy
The newest version!
/*
* Copyright 2021 - 2023 Sporta Technologies PVT LTD & the ZIO HTTP contributors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package zio.http.codec
import zio.Chunk
import zio.schema.Schema
import zio.http.codec.Doc.Span.CodeStyle
import zio.http.template
/**
* A `Doc` models documentation for an endpoint or input.
*/
sealed trait Doc { self =>
import Doc._
def +(that: Doc): Doc =
(self, that) match {
case (self, that) if self.isEmpty => that
case (self, that) if that.isEmpty => self
case _ => Doc.Sequence(self, that)
}
def isEmpty: Boolean =
self match {
case Doc.Empty => true
case Doc.DescriptionList(xs) => xs.forall(_._2.isEmpty)
case Doc.Sequence(left, right) => left.isEmpty && right.isEmpty
case Doc.Listing(xs, _) => xs.forall(_.isEmpty)
case Doc.Raw(value, _) => value.isEmpty
case _ => false
}
private[zio] def flattened: Chunk[Doc] =
self match {
case Doc.Empty => Chunk.empty
case Doc.Sequence(left, right) => left.flattened ++ right.flattened
case x => Chunk(x)
}
def tag(tags: Seq[String]): Doc = self match {
case Tagged(doc, existingTags) => Tagged(doc, existingTags ++ tags)
case _ => Tagged(self, tags.toList)
}
def tag(tag: String): Doc =
self match {
case Tagged(doc, tags) => Tagged(doc, tags :+ tag)
case _ => Tagged(self, List(tag))
}
def tag(tag: String, tags: String*): Doc = self.tag(tag +: tags)
def tags: List[String] = self match {
case Tagged(_, tags) => tags
case _ => Nil
}
def toCommonMark: String = {
val writer = new StringBuilder
def renderSpan(span: Span, indent: Int): String = {
def render(s: String): String = " " * indent + s
span match {
case Span.Text(value) => render(value)
case Span.Code(value, CodeStyle.Block) => render(s"```${value.trim}\n```")
case Span.Code(value, CodeStyle.Inline) => render(s"`$value`")
case Span.Link(value, text) => render(s"[${text.getOrElse(value)}]($value)")
case Span.Bold(value) =>
s"${render("**")}${renderSpan(value, indent).trim}${render("**")}"
case Span.Italic(value) =>
s"${render("*")}${renderSpan(value, indent).trim}${render("*")}"
case Span.Error(value) =>
s"${render(s"""""")}${render(value)}${render("")}"
case Span.Sequence(left, right) =>
val l = renderSpan(left, indent)
val r = renderSpan(right, indent)
s"$l$r"
}
}
def render(doc: Doc, indent: Int = 0): Unit = {
def append(s: String): Unit = {
writer.append(" " * indent).append(s)
()
}
doc match {
case Doc.Empty => ()
case Doc.Header(value, level) =>
if (writer.nonEmpty && writer.last != '\n') append("\n")
append(s"${"#" * level} $value\n\n")
case Doc.Paragraph(value) =>
writer.append(renderSpan(value, indent))
writer.append("\n\n")
()
case Doc.DescriptionList(definitions) =>
definitions.foreach { case (span, helpDoc) =>
writer.append(renderSpan(span, indent))
append(":\n")
render(helpDoc, indent)
}
case Doc.Listing(elements, listingType) =>
elements.zipWithIndex.foreach { case (doc, i) =>
if (listingType == ListingType.Ordered) append(s"${i + 1}. ") else append("- ")
doc match {
case Listing(_, _) =>
render(doc, indent + 1)
writer.append("\n")
case Sequence(left, right) =>
render(left, indent)
writer.deleteCharAt(writer.length - 1)
render(right, indent + 1)
case _ =>
render(doc, indent)
}
writer.deleteCharAt(writer.length - 1)
}
case Doc.Sequence(left, right) =>
render(left, indent)
render(right, indent)
case Doc.Raw(value, RawDocType.CommonMark) =>
writer.append(value)
case Doc.Raw(_, docType) =>
throw new IllegalArgumentException(s"Unsupported raw doc type: $docType")
case Doc.Tagged(_, _) =>
}
}
render(this)
val tags = self.tags
if (tags.nonEmpty) {
// Add all tags to the end of the document as an unordered list
render(Doc.unorderedListing(tags.map(Doc.p): _*))
}
writer.toString()
}
def toHtml: template.Html = {
import template._
val html: Html = self match {
case Doc.Empty =>
Html.Empty
case Header(value, level) =>
level match {
case 1 => h1(value)
case 2 => h2(value)
case 3 => h3(value)
case 4 => h4(value)
case 5 => h5(value)
case 6 => h6(value)
case _ => throw new IllegalArgumentException(s"Invalid header level: $level")
}
case Paragraph(value) =>
p(value.toHtml)
case DescriptionList(definitions) =>
dl(
definitions.flatMap { case (span, helpDoc) =>
Seq(
dt(span.toHtml),
dd(helpDoc.toHtml),
)
},
)
case Sequence(left, right) =>
left.toHtml ++ right.toHtml
case Listing(elements, _) if elements.isEmpty =>
Html.Empty
case Listing(elements, listingType) =>
val elementsHtml =
elements.map { doc =>
li(doc.toHtml)
}
listingType match {
case ListingType.Unordered => ul(elementsHtml)
case ListingType.Ordered => ol(elementsHtml)
}
case Raw(value, RawDocType.Html) =>
Html.fromString(value)
case Raw(_, docType) =>
throw new IllegalArgumentException(s"Unsupported raw doc type: $docType")
case Tagged(doc, _) =>
doc.toHtml
}
html ++ (if (tags.nonEmpty) Doc.unorderedListing(self.tags.map(Doc.p): _*).toHtml else Html.Empty)
}
def toHtmlSnippet: String =
toHtml.encode(2).toString
def toPlaintext(columnWidth: Int = 100, color: Boolean = true): String = {
val _ = color
val writer = DocWriter(0, columnWidth)
var uppercase = false
var styles = List.empty[String]
var lastStyle = Console.RESET
var printedSep = 0
def setStyle(style: String): Unit = styles = style :: styles
def currentStyle(): String = styles.headOption.getOrElse(Console.RESET)
def resetStyle(): Unit = styles = styles.drop(1)
def renderText(text: String): Unit =
renderSpan(Span.text(text))
def renderNewline(): Unit =
if (printedSep < 2) {
printedSep += 1
val _ = writer.append("\n")
}
def clearSep() = printedSep = 0
def renderHelpDoc(helpDoc: Doc): Unit =
helpDoc match {
case Empty =>
case Doc.Header(value, _) =>
writer.unindent()
renderNewline()
uppercase = true
setStyle(Console.BOLD)
renderSpan(Span.text(value))
resetStyle()
uppercase = false
renderNewline()
renderNewline()
writer.indent(2)
case Doc.Paragraph(value) =>
renderSpan(value)
renderNewline()
renderNewline()
case Doc.DescriptionList(definitions) =>
definitions.zipWithIndex.foreach { case ((span, helpDoc), _) =>
setStyle(Console.BOLD)
renderSpan(span)
resetStyle()
renderNewline()
writer.indent(2)
renderHelpDoc(helpDoc)
writer.unindent()
renderNewline()
}
case Doc.Listing(elements, listingType) =>
elements.zipWithIndex.foreach { case (helpDoc, i) =>
if (listingType == ListingType.Ordered) renderText(s"${i + 1}. ") else renderText("- ")
helpDoc match {
case Doc.Listing(_, _) =>
writer.indent(2)
renderHelpDoc(helpDoc)
writer.unindent()
case Sequence(left, right) =>
renderHelpDoc(left)
writer.deleteLastChar()
writer.indent(2)
renderHelpDoc(right)
writer.unindent()
case _ =>
renderHelpDoc(helpDoc)
writer.deleteLastChar()
}
}
writer.unindent()
case Doc.Sequence(left, right) =>
renderHelpDoc(left)
renderHelpDoc(right)
case Doc.Raw(value, RawDocType.Plain) =>
writer.append(value): Unit
case Doc.Raw(_, docType) =>
throw new IllegalArgumentException(s"Unsupported raw doc type: $docType")
case Tagged(doc, _) =>
renderHelpDoc(doc)
}
def renderSpan(span: Span): Unit = {
clearSep()
val _ = span match {
case Span.Text(value) =>
if (color && (lastStyle != currentStyle())) {
writer.append(currentStyle())
lastStyle = currentStyle()
}
writer.append(if (uppercase) value.toUpperCase() else value)
case Span.Code(value, _) =>
setStyle(Console.WHITE)
writer.append(value)
resetStyle()
case Span.Error(value) =>
setStyle(Console.RED)
renderSpan(Span.text(value))
resetStyle()
case Span.Italic(value) =>
setStyle(Console.BOLD)
renderSpan(value)
resetStyle()
case Span.Bold(value) =>
setStyle(Console.BOLD)
renderSpan(value)
resetStyle()
case Span.Link(value, text) =>
setStyle(Console.UNDERLINED)
renderSpan(Span.text(text.map(t => s"[$t](${value.toASCIIString})").getOrElse(value.toASCIIString)))
resetStyle()
case Span.Sequence(left, right) =>
renderSpan(left)
renderSpan(right)
}
}
renderHelpDoc(this)
val tags = self.tags
if (tags.nonEmpty) {
writer.append("\n")
renderHelpDoc(Doc.unorderedListing(tags.map(Doc.p): _*))
}
writer.toString() + (if (color) Console.RESET else "")
}
}
object Doc {
implicit val schemaDocSchema: Schema[Doc] =
Schema[String].transform(
fromCommonMark,
_.toCommonMark,
)
def fromCommonMark(commonMark: String): Doc =
Doc.Raw(commonMark, RawDocType.CommonMark)
private sealed trait RawDocType
private object RawDocType {
case object Plain extends RawDocType
case object CommonMark extends RawDocType
case object Html extends RawDocType
}
case object Empty extends Doc
private final case class Raw(value: String, docType: RawDocType) extends Doc
final case class Header(value: String, level: Int) extends Doc
final case class Paragraph(value: Span) extends Doc
final case class DescriptionList(definitions: List[(Span, Doc)]) extends Doc
final case class Sequence(left: Doc, right: Doc) extends Doc
final case class Listing(elements: List[Doc], listingType: ListingType) extends Doc
final case class Tagged(doc: Doc, tgs: List[String]) extends Doc
sealed trait ListingType
object ListingType {
case object Unordered extends ListingType
case object Ordered extends ListingType
}
def blocks(bs: Iterable[Doc]): Doc =
if (bs.isEmpty) Doc.Empty else blocks(bs.head, bs.tail.toSeq: _*)
def blocks(helpDoc: Doc, helpDocs0: Doc*): Doc =
helpDocs0.foldLeft(helpDoc)(_ + _)
def descriptionList(definitions: (Span, Doc)*): Doc = Doc.DescriptionList(definitions.toList)
val empty: Doc = Empty
def orderedListing(elements: Doc*): Doc =
Doc.Listing(elements.toList, ListingType.Ordered)
def unorderedListing(elements: Doc*): Doc =
Doc.Listing(elements.toList, ListingType.Unordered)
def h1(t: String): Doc = Header(t, 1)
def h2(t: String): Doc = Header(t, 2)
def h3(t: String): Doc = Header(t, 3)
def h4(t: String): Doc = Header(t, 4)
def h5(t: String): Doc = Header(t, 5)
def h6(t: String): Doc = Header(t, 6)
def p(t: String): Doc = Doc.Paragraph(Span.text(t))
def p(span: Span): Doc = Doc.Paragraph(span)
sealed trait Span { self =>
final def +(that: Span): Span =
if (self.isEmpty) that
else if (that.isEmpty) self
else Span.Sequence(self, that)
final def isEmpty: Boolean = self.size == 0
final def size: Int =
self match {
case Span.Text(value) => value.length
case Span.Code(value, _) => value.length
case Span.Error(value) => value.length
case Span.Bold(value) => value.size
case Span.Italic(value) => value.size
case Span.Link(value, _) => value.toString.length
case Span.Sequence(left, right) => left.size + right.size
}
def toHtml: template.Html = {
import template._
self match {
case Span.Text(value) => value
case Span.Code(value, CodeStyle.Block) => pre(code(value))
case Span.Code(value, CodeStyle.Inline) => code(value)
case Span.Error(value) => span(styleAttr := "color: red", value)
case Span.Bold(value) => b(value.toHtml)
case Span.Italic(value) => i(value.toHtml)
case Span.Link(value, text) =>
a(href := value.toASCIIString, Html.fromString(text.getOrElse(value.toASCIIString)))
case Span.Sequence(left, right) => left.toHtml ++ right.toHtml
}
}
}
object Span {
final case class Text(value: String) extends Span
final case class Code(value: String, codeStyle: CodeStyle) extends Span
final case class Error(value: String) extends Span
final case class Bold(value: Span) extends Span
final case class Italic(value: Span) extends Span
final case class Link(value: java.net.URI, text: Option[String]) extends Span
final case class Sequence(left: Span, right: Span) extends Span
sealed trait CodeStyle
object CodeStyle {
case object Inline extends CodeStyle
case object Block extends CodeStyle
}
def code(t: String): Span = Span.Code(t, CodeStyle.Inline)
def codeBlock(t: String): Span = Span.Code(t, CodeStyle.Block)
def empty: Span = Span.text("")
def error(t: String): Span = Span.Error(t)
def bold(span: Span): Span = Span.Bold(span)
def bold(t: String): Span = Span.Bold(text(t))
def italic(span: Span): Span = Span.Italic(span)
def italic(t: String): Span = Span.Italic(text(t))
def text(t: String): Span = Span.Text(t)
def link(uri: java.net.URI): Span = Span.Link(uri, None)
def link(uri: java.net.URI, text: String): Span = Span.Link(uri, Some(text).filter(_.nonEmpty))
def spans(span: Span, spans0: Span*): Span = spans(span :: spans0.toList)
def spans(spans: Iterable[Span]): Span = spans.toList.foldLeft(empty)(_ + _)
}
}
private[codec] class DocWriter(stringBuilder: StringBuilder, startOffset: Int, columnWidth: Int) { self =>
private var marginStack: List[Int] = List(self.startOffset)
def deleteLastChar(): Unit = stringBuilder.deleteCharAt(stringBuilder.length - 1)
def append(s: String): DocWriter = {
if (s.isEmpty) self
else
DocWriter.splitNewlines(s) match {
case None =>
if (self.currentColumn + s.length > self.columnWidth) {
val remainder = self.columnWidth - self.currentColumn
val lastSpace = {
val lastSpace = s.take(remainder + 1).lastIndexOf(' ')
if (lastSpace == -1) remainder else lastSpace
}
val before = s.take(lastSpace)
val after = s.drop(lastSpace).dropWhile(_ == ' ')
append(before)
append("\n")
append(after)
} else {
val padding = self.currentMargin - self.currentColumn
if (padding > 0) {
self.stringBuilder.append(DocWriter.margin(padding))
self.currentColumn += padding
}
self.stringBuilder.append(s)
self.currentColumn += s.length
}
case Some(pieces) =>
pieces.zipWithIndex.foreach { case (piece, _) =>
append(piece)
self.stringBuilder.append("\n")
self.currentColumn = 0
}
}
this
}
def currentMargin: Int = self.marginStack.sum
var currentColumn: Int = self.startOffset
def indent(adjust: Int): Unit = self.marginStack = adjust :: self.marginStack
override def toString: String = stringBuilder.toString()
def unindent(): Unit = self.marginStack = self.marginStack.drop(1)
}
private[codec] object DocWriter {
private def margin(n: Int): String = if (n <= 0) "" else List.fill(n)(" ").mkString
def splitNewlines(s: String): Option[Array[String]] = {
val count = s.count(_ == '\n')
if (count == 0) None
else
Some {
val size = if (count == s.length) count else count + 1
val array = Array.ofDim[String](size)
var i = 0
var j = 0
while (i < s.length) {
val search = s.indexOf('\n', i)
val endIndex = if (search == -1) s.length else search
array(j) = s.substring(i, endIndex)
i = endIndex + 1
j = j + 1
}
if (j < array.length) array(j) = ""
array
}
}
def apply(startOffset: Int, columnWidth: Int): DocWriter = {
val builder = new StringBuilder
builder.append(margin(startOffset))
new DocWriter(builder, startOffset, if (columnWidth <= 0) startOffset + 1 else columnWidth)
}
}