From 36c429f7a938e19b66b4dcd6a72524750923b569 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Tue, 26 Oct 2021 10:30:29 -0600 Subject: [PATCH 1/7] Begin implementing Subtext via recursive descent --- .../Shared/Components/AppView.swift | 6 +- .../Shared/Library/Subtext5.swift | 141 ++++++++++++++++++ xcode/Subconscious/Shared/Library/Tape.swift | 134 +++++++++++++++++ .../Subconscious.xcodeproj/project.pbxproj | 18 ++- 4 files changed, 293 insertions(+), 6 deletions(-) create mode 100644 xcode/Subconscious/Shared/Library/Subtext5.swift create mode 100644 xcode/Subconscious/Shared/Library/Tape.swift diff --git a/xcode/Subconscious/Shared/Components/AppView.swift b/xcode/Subconscious/Shared/Components/AppView.swift index e85c7540..e92850d0 100644 --- a/xcode/Subconscious/Shared/Components/AppView.swift +++ b/xcode/Subconscious/Shared/Components/AppView.swift @@ -111,10 +111,8 @@ struct AppModel: Updatable { markup: String, selection: NSRange ) -> NSAttributedString { - Subtext4( - markup: markup, - range: selection - ).renderMarkup(url: Slashlink.slashlinkToURLString) + Subtext5(markup: markup) + .renderMarkup(url: Slashlink.slashlinkToURLString) } // MARK: Update diff --git a/xcode/Subconscious/Shared/Library/Subtext5.swift b/xcode/Subconscious/Shared/Library/Subtext5.swift new file mode 100644 index 00000000..45895629 --- /dev/null +++ b/xcode/Subconscious/Shared/Library/Subtext5.swift @@ -0,0 +1,141 @@ +// +// Subtext5.swift +// Subconscious +// +// Created by Gordon Brander on 10/25/21. +// + +import Foundation +import SwiftUI + +struct Subtext5 { + enum Block { + case text(line: Substring, inline: Inline) + case list(line: Substring, inline: Inline) + case quote(line: Substring, inline: Inline) + case heading(line: Substring) + + /// Returns the body of a block, without the leading sigil + func body() -> Substring { + switch self { + case .text(let line, _): + return line + case .quote(let line, _), .list(let line, _), .heading(let line): + return line.dropFirst() + } + } + } + + struct Inline { + var links: [Substring] = [] + var bracketLinks: [Substring] = [] + var slashlinks: [Substring] = [] + } + + static func parseInline(tape: inout Tape) -> Inline { + var inline = Inline() + while !tape.isExhausted() { + tape.start() + if tape.consumeMatch("https://") { + tape.consumeUntil(" ") + inline.links.append(tape.cut()) + } else if tape.consumeMatch("http://") { + tape.consumeUntil(" ") + inline.links.append(tape.cut()) + } else if tape.consumeMatch("<") { + tape.consumeUntil(">") + if tape.consumeMatch(">") { + inline.bracketLinks.append(tape.cut()) + } + } else if tape.consumeMatch("/") { + tape.consumeUntil(" ") + inline.slashlinks.append(tape.cut()) + } else { + tape.consume() + } + } + return inline + } + + static func parseLine(_ line: Substring) -> Block { + if line.hasPrefix("#") { + return Block.heading(line: line) + } else if line.hasPrefix(">") { + var tape = Tape(line) + // Discard prefix + tape.consume() + let inline = parseInline(tape: &tape) + return Block.quote(line: line, inline: inline) + } else if line.hasPrefix("-") { + var tape = Tape(line) + // Discard prefix + tape.consume() + let inline = parseInline(tape: &tape) + return Block.list(line: line, inline: inline) + } else { + var tape = Tape(line) + let inline = parseInline(tape: &tape) + return Block.list(line: 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 { + let attributedString = NSMutableAttributedString(string: base) + // Set default styles for entire string + attributedString.addAttribute( + .font, + value: UIFont.appText, + range: NSRange(base.startIndex.. +where T: Collection, + T.SubSequence: Equatable +{ + private(set) var savedIndex: T.Index + private(set) var startIndex: T.Index + private(set) var currentIndex: T.Index + let collection: T + + init(_ collection: T) { + self.collection = collection + self.startIndex = collection.startIndex + self.currentIndex = collection.startIndex + self.savedIndex = collection.startIndex + } + + /// Get current subsequence + var subsequence: T.SubSequence { + collection[startIndex.. Bool { + return self.currentIndex >= self.collection.endIndex + } + + /// Sets the start of the current range to the current index + /// Generally called at the beginning of each loop. + mutating func start() { + startIndex = currentIndex + } + + /// Get current subsequence, and advance start index to current index. + /// Conceptually like snipping off a piece of tape so that you have the piece up until the cut, + /// and the cut becomes the new start of the tape. + mutating func cut() -> T.SubSequence { + let subsequence = collection[startIndex.. T.SubSequence { + let subsequence = collection[currentIndex...currentIndex] + self.collection.formIndex( + after: &self.currentIndex + ) + return subsequence + } + + /// Peek forward, and consume if match + mutating func consumeMatch(_ subsequence: T.SubSequence) -> Bool { + if let endIndex = collection.index( + currentIndex, + offsetBy: subsequence.count, + limitedBy: collection.endIndex + ) { + if collection[currentIndex.. T.SubSequence { + while !self.isExhausted() { + if self.peek(next: delimiter.count) == delimiter { + if includeDelimiter { + self.consume() + } + return self.subsequence + } else { + self.consume() + } + } + return self.subsequence + } + + /// Get a single-item SubSequence offset by `forward` of the `currentStartIndex`. + /// Returns a single-item SubSequence, or nil if offset is invalid. + func peek(_ offset: Int = 0) -> T.SubSequence? { + if + let startIndex = collection.index( + currentIndex, + offsetBy: offset, + limitedBy: collection.endIndex + ), + let endIndex = collection.index( + currentIndex, + offsetBy: offset + 1, + limitedBy: collection.endIndex + ) + { + return collection[startIndex.. T.SubSequence? { + if let endIndex = collection.index( + currentIndex, + offsetBy: offset, + limitedBy: collection.endIndex + ) { + return collection[currentIndex.. Date: Tue, 26 Oct 2021 15:22:03 -0600 Subject: [PATCH 2/7] Only capture bracket link if it ends in a > Otherwise we backtrack, because this is just prose. --- .../Shared/Library/Subtext5.swift | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/xcode/Subconscious/Shared/Library/Subtext5.swift b/xcode/Subconscious/Shared/Library/Subtext5.swift index 45895629..af80cdaf 100644 --- a/xcode/Subconscious/Shared/Library/Subtext5.swift +++ b/xcode/Subconscious/Shared/Library/Subtext5.swift @@ -28,25 +28,41 @@ struct Subtext5 { struct Inline { var links: [Substring] = [] - var bracketLinks: [Substring] = [] + var bracketlinks: [Substring] = [] var slashlinks: [Substring] = [] } + /// Consume a well-formed bracket link, or else backtrack + static func consumeBracketLink(tape: inout Tape) -> 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 + } + static func parseInline(tape: inout Tape) -> Inline { var inline = Inline() while !tape.isExhausted() { tape.start() - if tape.consumeMatch("https://") { + if tape.consumeMatch("<") { + if let link = consumeBracketLink(tape: &tape) { + inline.bracketlinks.append(link) + } + } else if tape.consumeMatch("https://") { tape.consumeUntil(" ") inline.links.append(tape.cut()) } else if tape.consumeMatch("http://") { tape.consumeUntil(" ") inline.links.append(tape.cut()) - } else if tape.consumeMatch("<") { - tape.consumeUntil(">") - if tape.consumeMatch(">") { - inline.bracketLinks.append(tape.cut()) - } } else if tape.consumeMatch("/") { tape.consumeUntil(" ") inline.slashlinks.append(tape.cut()) @@ -88,6 +104,8 @@ struct Subtext5 { omittingEmptySubsequences: false, whereSeparator: \.isNewline ).map(Self.parseLine) + + print(self.blocks) } /// Render markup verbatim with syntax highlighting and links From 146b8cd72eeb8a2e56619f45000dbe8e230edde6 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Tue, 26 Oct 2021 15:46:16 -0600 Subject: [PATCH 3/7] Render body of bracket links --- .../Shared/Library/Subtext5.swift | 99 ++++++++++++------- 1 file changed, 64 insertions(+), 35 deletions(-) diff --git a/xcode/Subconscious/Shared/Library/Subtext5.swift b/xcode/Subconscious/Shared/Library/Subtext5.swift index af80cdaf..46c03923 100644 --- a/xcode/Subconscious/Shared/Library/Subtext5.swift +++ b/xcode/Subconscious/Shared/Library/Subtext5.swift @@ -10,26 +10,42 @@ import SwiftUI struct Subtext5 { enum Block { - case text(line: Substring, inline: Inline) - case list(line: Substring, inline: Inline) - case quote(line: Substring, inline: Inline) - case heading(line: Substring) + 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 line, _): - return line - case .quote(let line, _), .list(let line, _), .heading(let line): - return line.dropFirst() + case .text(let span, _): + return span + case .quote(let span, _), .list(let span, _), .heading(let span): + return span.dropFirst() } } } - struct Inline { - var links: [Substring] = [] - var bracketlinks: [Substring] = [] - var slashlinks: [Substring] = [] + struct Link { + var span: Substring + } + + struct Bracketlink { + 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 @@ -49,23 +65,23 @@ struct Subtext5 { return nil } - static func parseInline(tape: inout Tape) -> Inline { - var inline = Inline() + static func parseInline(tape: inout Tape) -> [Inline] { + var inline: [Inline] = [] while !tape.isExhausted() { tape.start() if tape.consumeMatch("<") { if let link = consumeBracketLink(tape: &tape) { - inline.bracketlinks.append(link) + inline.append(.bracketlink(Bracketlink(span: link))) } } else if tape.consumeMatch("https://") { tape.consumeUntil(" ") - inline.links.append(tape.cut()) + inline.append(.link(Link(span: tape.cut()))) } else if tape.consumeMatch("http://") { tape.consumeUntil(" ") - inline.links.append(tape.cut()) + inline.append(.link(Link(span: tape.cut()))) } else if tape.consumeMatch("/") { tape.consumeUntil(" ") - inline.slashlinks.append(tape.cut()) + inline.append(.slashlink(Slashlink(span: tape.cut()))) } else { tape.consume() } @@ -75,23 +91,23 @@ struct Subtext5 { static func parseLine(_ line: Substring) -> Block { if line.hasPrefix("#") { - return Block.heading(line: line) + 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(line: line, inline: inline) + 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(line: line, inline: inline) + return Block.list(span: line, inline: inline) } else { var tape = Tape(line) let inline = parseInline(tape: &tape) - return Block.list(line: line, inline: inline) + return Block.list(span: line, inline: inline) } } @@ -131,26 +147,39 @@ struct Subtext5 { .list(_, let inline), .quote(_, let inline), .text(_, let inline): - for slashlink in inline.slashlinks { - if let url = url(String(slashlink)) { + for inline in inline { + switch inline { + case let .link(link): attributedString.addAttribute( .link, - value: url, + value: link.span, range: NSRange( - slashlink.range, in: attributedString.string + 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 + ) + ) + } } - } - for link in inline.links { - attributedString.addAttribute( - .link, - value: link, - range: NSRange(link.range, in: attributedString.string) - ) - } - } } From edd099e7ea8b5bef9ddda9616debd1477ff2fc25 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 28 Oct 2021 16:14:48 -0700 Subject: [PATCH 4/7] Delimit links, slashlinks at word boundary --- .../Shared/Library/Subtext5.swift | 63 ++++++++++++------- 1 file changed, 42 insertions(+), 21 deletions(-) diff --git a/xcode/Subconscious/Shared/Library/Subtext5.swift b/xcode/Subconscious/Shared/Library/Subtext5.swift index 46c03923..ea2d027f 100644 --- a/xcode/Subconscious/Shared/Library/Subtext5.swift +++ b/xcode/Subconscious/Shared/Library/Subtext5.swift @@ -49,7 +49,7 @@ struct Subtext5 { } /// Consume a well-formed bracket link, or else backtrack - static func consumeBracketLink(tape: inout Tape) -> Substring? { + private static func consumeBracketLink(tape: inout Tape) -> Substring? { tape.save() while !tape.isExhausted() { if tape.consumeMatch(" ") { @@ -65,31 +65,54 @@ struct Subtext5 { return nil } - static func parseInline(tape: inout Tape) -> [Inline] { - var inline: [Inline] = [] + private static func consumeInlineWordBoundaryForm( + tape: inout Tape + ) -> 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) -> [Inline] { + var inlines: [Inline] = [] + + /// Capture word-boundary-delimited forms at beginning of line. + tape.start() + if let inline = consumeInlineWordBoundaryForm(tape: &tape) { + inlines.append(inline) + } + while !tape.isExhausted() { tape.start() - if tape.consumeMatch("<") { - if let link = consumeBracketLink(tape: &tape) { - inline.append(.bracketlink(Bracketlink(span: link))) + let curr = tape.consume() + /// Capture word-boundary-delimited forms after space + if curr == " " { + tape.start() + if let inline = consumeInlineWordBoundaryForm(tape: &tape) { + inlines.append(inline) } - } else if tape.consumeMatch("https://") { - tape.consumeUntil(" ") - inline.append(.link(Link(span: tape.cut()))) - } else if tape.consumeMatch("http://") { - tape.consumeUntil(" ") - inline.append(.link(Link(span: tape.cut()))) - } else if tape.consumeMatch("/") { - tape.consumeUntil(" ") - inline.append(.slashlink(Slashlink(span: tape.cut()))) - } else { - tape.consume() } } - return inline + + return inlines } - static func parseLine(_ line: Substring) -> Block { + private static func parseLine(_ line: Substring) -> Block { if line.hasPrefix("#") { return Block.heading(span: line) } else if line.hasPrefix(">") { @@ -120,8 +143,6 @@ struct Subtext5 { omittingEmptySubsequences: false, whereSeparator: \.isNewline ).map(Self.parseLine) - - print(self.blocks) } /// Render markup verbatim with syntax highlighting and links From b52c1d2e37a1272ed9b93d02e0966fae44e8da4f Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Mon, 1 Nov 2021 10:29:24 -0700 Subject: [PATCH 5/7] Rename Subtext5 -> Subtext, remove old implementation --- .../Shared/Components/AppView.swift | 2 +- .../Library/{Subtext5.swift => Subtext.swift} | 2 +- .../Shared/Library/Subtext4.swift | 130 ------------------ .../Subconscious.xcodeproj/project.pbxproj | 18 +-- 4 files changed, 8 insertions(+), 144 deletions(-) rename xcode/Subconscious/Shared/Library/{Subtext5.swift => Subtext.swift} (99%) delete mode 100644 xcode/Subconscious/Shared/Library/Subtext4.swift diff --git a/xcode/Subconscious/Shared/Components/AppView.swift b/xcode/Subconscious/Shared/Components/AppView.swift index e92850d0..c812ab6c 100644 --- a/xcode/Subconscious/Shared/Components/AppView.swift +++ b/xcode/Subconscious/Shared/Components/AppView.swift @@ -111,7 +111,7 @@ struct AppModel: Updatable { markup: String, selection: NSRange ) -> NSAttributedString { - Subtext5(markup: markup) + Subtext(markup: markup) .renderMarkup(url: Slashlink.slashlinkToURLString) } diff --git a/xcode/Subconscious/Shared/Library/Subtext5.swift b/xcode/Subconscious/Shared/Library/Subtext.swift similarity index 99% rename from xcode/Subconscious/Shared/Library/Subtext5.swift rename to xcode/Subconscious/Shared/Library/Subtext.swift index ea2d027f..56846a14 100644 --- a/xcode/Subconscious/Shared/Library/Subtext5.swift +++ b/xcode/Subconscious/Shared/Library/Subtext.swift @@ -8,7 +8,7 @@ import Foundation import SwiftUI -struct Subtext5 { +struct Subtext { enum Block { case text(span: Substring, inline: [Inline]) case list(span: Substring, inline: [Inline]) diff --git a/xcode/Subconscious/Shared/Library/Subtext4.swift b/xcode/Subconscious/Shared/Library/Subtext4.swift deleted file mode 100644 index 3543d404..00000000 --- a/xcode/Subconscious/Shared/Library/Subtext4.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// Subtext4.swift -// Subconscious -// -// Created by Gordon Brander on 9/29/21. -// - -import Foundation -import SwiftUI - -struct Subtext4: Equatable { - static let heading = try! NSRegularExpression( - pattern: #"^#.*$"#, - options: .anchorsMatchLines - ) - - /// Slashlink pattern - static let slashlink = try! NSRegularExpression( - pattern: #"(^|\s)(/[a-zA-Z0-9/\-\_]+)"#, - options: .anchorsMatchLines - ) - - static let barelink = try! NSRegularExpression( - pattern: #"(^|\s)(https?://[^\s>]+)[\.,;]?"# - ) - - static let bracketlink = try! NSRegularExpression( - pattern: #"<([^>\s]+)>"# - ) - - /// Static property for empty document - static let empty = Self(markup: "") - - let base: String - let headings: Set - let slashlinks: Set - let links: Set - - init( - markup: String, - cursor: String.Index? = nil - ) { - let nsRange = NSRange(markup.startIndex.. String?) -> NSAttributedString { - let attributedString = NSMutableAttributedString(string: base) - // Set default styles for entire string - attributedString.addAttribute( - .font, - value: UIFont.appText, - range: NSRange(base.startIndex.. Date: Mon, 1 Nov 2021 11:43:55 -0700 Subject: [PATCH 6/7] Fix comment --- xcode/Subconscious/Shared/Library/Tape.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xcode/Subconscious/Shared/Library/Tape.swift b/xcode/Subconscious/Shared/Library/Tape.swift index 5d01f711..a986bf02 100644 --- a/xcode/Subconscious/Shared/Library/Tape.swift +++ b/xcode/Subconscious/Shared/Library/Tape.swift @@ -99,8 +99,8 @@ where T: Collection, return self.subsequence } - /// Get a single-item SubSequence offset by `forward` of the `currentStartIndex`. - /// Returns a single-item SubSequence, or nil if offset is invalid. + /// Get a single-item SubSequence offset by `offset` of the `currentStartIndex`. + /// Returns a single-item SubSequence, or nil if `offset` is invalid. func peek(_ offset: Int = 0) -> T.SubSequence? { if let startIndex = collection.index( From d2c0451523b4ee5aabc6ab5cc5f3a5840a68ec66 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Mon, 1 Nov 2021 11:44:06 -0700 Subject: [PATCH 7/7] Factor out rendering into extension --- xcode/Subconscious/Shared/Library/Subtext.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/xcode/Subconscious/Shared/Library/Subtext.swift b/xcode/Subconscious/Shared/Library/Subtext.swift index 56846a14..c4a2e0a8 100644 --- a/xcode/Subconscious/Shared/Library/Subtext.swift +++ b/xcode/Subconscious/Shared/Library/Subtext.swift @@ -144,7 +144,9 @@ struct Subtext { whereSeparator: \.isNewline ).map(Self.parseLine) } +} +extension Subtext { /// Render markup verbatim with syntax highlighting and links func renderMarkup(url: (String) -> String?) -> NSAttributedString { let attributedString = NSMutableAttributedString(string: base)