-
Notifications
You must be signed in to change notification settings - Fork 0
Subtext recursive descent parser #3
Changes from 5 commits
36c429f
4937917
146b8cd
edd099e
b52c1d2
3f34607
d2c0451
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,209 @@ | ||
// | ||
// Subtext5.swift | ||
// Subconscious | ||
// | ||
// Created by Gordon Brander on 10/25/21. | ||
// | ||
|
||
import Foundation | ||
import SwiftUI | ||
|
||
struct Subtext { | ||
enum Block { | ||
case text(span: Substring, inline: [Inline]) | ||
case list(span: Substring, inline: [Inline]) | ||
case quote(span: Substring, inline: [Inline]) | ||
case heading(span: Substring) | ||
|
||
/// Returns the body of a block, without the leading sigil | ||
func body() -> Substring { | ||
switch self { | ||
case .text(let span, _): | ||
return span | ||
case .quote(let span, _), .list(let span, _), .heading(let span): | ||
return span.dropFirst() | ||
} | ||
} | ||
} | ||
|
||
struct Link { | ||
var span: Substring | ||
} | ||
|
||
struct Bracketlink { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I only know about the hyperlink and slashlink link forms - what is the format/semantics of bracketlink? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @cdata Covered here in the specification: https://github.com/gordonbrander/subtext/blob/main/specification.md#bracketed-urls. TLDR, they are a syntax form that allows linking to non-http/https protocols.
URL syntax is very very open-ended, so it is difficult to auto-link the general form of URLs. Bracket links mean we can autolink http urls, and support other protocols without forcing parsers to maintain a whitelist of exotic protocols. Easy things are easy, difficult things are possible. |
||
var span: Substring | ||
|
||
func body() -> Substring { | ||
span.dropFirst().dropLast() | ||
} | ||
} | ||
|
||
struct Slashlink { | ||
var span: Substring | ||
} | ||
|
||
enum Inline { | ||
case link(Link) | ||
case bracketlink(Bracketlink) | ||
case slashlink(Slashlink) | ||
} | ||
|
||
/// Consume a well-formed bracket link, or else backtrack | ||
private static func consumeBracketLink(tape: inout Tape<Substring>) -> Substring? { | ||
tape.save() | ||
while !tape.isExhausted() { | ||
if tape.consumeMatch(" ") { | ||
tape.backtrack() | ||
return nil | ||
} else if tape.consumeMatch(">") { | ||
return tape.cut() | ||
} else { | ||
tape.consume() | ||
} | ||
} | ||
tape.backtrack() | ||
return nil | ||
} | ||
|
||
private static func consumeInlineWordBoundaryForm( | ||
tape: inout Tape<Substring> | ||
) -> Inline? { | ||
if tape.consumeMatch("<") { | ||
if let link = consumeBracketLink(tape: &tape) { | ||
return .bracketlink(Bracketlink(span: link)) | ||
} else { | ||
return nil | ||
} | ||
} else if tape.consumeMatch("https://") { | ||
tape.consumeUntil(" ") | ||
return .link(Link(span: tape.cut())) | ||
} else if tape.consumeMatch("http://") { | ||
tape.consumeUntil(" ") | ||
return .link(Link(span: tape.cut())) | ||
} else if tape.consumeMatch("/") { | ||
tape.consumeUntil(" ") | ||
return .slashlink(Slashlink(span: tape.cut())) | ||
} else { | ||
return nil | ||
} | ||
} | ||
|
||
private static func parseInline(tape: inout Tape<Substring>) -> [Inline] { | ||
var inlines: [Inline] = [] | ||
|
||
/// Capture word-boundary-delimited forms at beginning of line. | ||
tape.start() | ||
if let inline = consumeInlineWordBoundaryForm(tape: &tape) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It may be possible to satisfy this condition as part of the loop to reduce the method's complexity a little. |
||
inlines.append(inline) | ||
} | ||
|
||
while !tape.isExhausted() { | ||
tape.start() | ||
let curr = tape.consume() | ||
/// Capture word-boundary-delimited forms after space | ||
if curr == " " { | ||
tape.start() | ||
if let inline = consumeInlineWordBoundaryForm(tape: &tape) { | ||
inlines.append(inline) | ||
} | ||
} | ||
} | ||
|
||
return inlines | ||
} | ||
|
||
private static func parseLine(_ line: Substring) -> Block { | ||
if line.hasPrefix("#") { | ||
return Block.heading(span: line) | ||
} else if line.hasPrefix(">") { | ||
var tape = Tape(line) | ||
// Discard prefix | ||
tape.consume() | ||
let inline = parseInline(tape: &tape) | ||
return Block.quote(span: line, inline: inline) | ||
} else if line.hasPrefix("-") { | ||
var tape = Tape(line) | ||
// Discard prefix | ||
tape.consume() | ||
let inline = parseInline(tape: &tape) | ||
return Block.list(span: line, inline: inline) | ||
} else { | ||
var tape = Tape(line) | ||
let inline = parseInline(tape: &tape) | ||
return Block.list(span: line, inline: inline) | ||
} | ||
} | ||
|
||
let base: String | ||
let blocks: [Block] | ||
|
||
init(markup: String) { | ||
self.base = markup | ||
self.blocks = markup.split( | ||
omittingEmptySubsequences: false, | ||
whereSeparator: \.isNewline | ||
).map(Self.parseLine) | ||
} | ||
|
||
/// Render markup verbatim with syntax highlighting and links | ||
func renderMarkup(url: (String) -> String?) -> NSAttributedString { | ||
gordonbrander marked this conversation as resolved.
Show resolved
Hide resolved
|
||
let attributedString = NSMutableAttributedString(string: base) | ||
// Set default styles for entire string | ||
attributedString.addAttribute( | ||
.font, | ||
value: UIFont.appText, | ||
range: NSRange(base.startIndex..<base.endIndex, in: base) | ||
) | ||
|
||
for block in blocks { | ||
switch block { | ||
case let .heading(line): | ||
let nsRange = NSRange(line.range, in: attributedString.string) | ||
attributedString.addAttribute( | ||
.font, | ||
value: UIFont.appTextBold, | ||
range: nsRange | ||
) | ||
case | ||
.list(_, let inline), | ||
.quote(_, let inline), | ||
.text(_, let inline): | ||
for inline in inline { | ||
switch inline { | ||
case let .link(link): | ||
attributedString.addAttribute( | ||
.link, | ||
value: link.span, | ||
range: NSRange( | ||
link.span.range, | ||
in: attributedString.string | ||
) | ||
) | ||
case let .bracketlink(bracketlink): | ||
attributedString.addAttribute( | ||
.link, | ||
value: bracketlink.body(), | ||
range: NSRange( | ||
bracketlink.body().range, | ||
in: attributedString.string | ||
) | ||
) | ||
case let .slashlink(slashlink): | ||
if let url = url(String(slashlink.span)) { | ||
attributedString.addAttribute( | ||
.link, | ||
value: url, | ||
range: NSRange( | ||
slashlink.span.range, | ||
in: attributedString.string | ||
) | ||
) | ||
} | ||
} | ||
} | ||
} | ||
} | ||
|
||
return attributedString | ||
} | ||
} |
This file was deleted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice, no longer versioned 📝