aboutsummaryrefslogtreecommitdiff
path: root/Sources/NorgKit/Parsers
diff options
context:
space:
mode:
Diffstat (limited to 'Sources/NorgKit/Parsers')
-rw-r--r--Sources/NorgKit/Parsers/BlockScanner.swift21
-rw-r--r--Sources/NorgKit/Parsers/InlineScanner.swift187
-rw-r--r--Sources/NorgKit/Parsers/NorgInlineParser.swift214
-rw-r--r--Sources/NorgKit/Parsers/NorgParser.swift22
4 files changed, 253 insertions, 191 deletions
diff --git a/Sources/NorgKit/Parsers/BlockScanner.swift b/Sources/NorgKit/Parsers/BlockScanner.swift
new file mode 100644
index 0000000..7552255
--- /dev/null
+++ b/Sources/NorgKit/Parsers/BlockScanner.swift
@@ -0,0 +1,21 @@
+/// Shared block-level lexical primitives.
+enum BlockScanner {
+
+ static let blockMarkers = ASCIIByteSet("*-~>")
+ static let rangeableMarkers = ASCIIByteSet("$^:")
+
+ enum Delimiter {
+ case weak, strong, rule
+ }
+
+ static func delimiter(_ s: Substring) -> Delimiter? {
+ guard s.count >= 2, let first = s.first, "-=_".contains(first),
+ s.allSatisfy({ $0 == first })
+ else { return nil }
+ switch first {
+ case "-": return .weak
+ case "=": return .strong
+ default: return .rule
+ }
+ }
+}
diff --git a/Sources/NorgKit/Parsers/InlineScanner.swift b/Sources/NorgKit/Parsers/InlineScanner.swift
new file mode 100644
index 0000000..881a069
--- /dev/null
+++ b/Sources/NorgKit/Parsers/InlineScanner.swift
@@ -0,0 +1,187 @@
+/// Scans for inline norg constructs.
+enum InlineScanner {
+
+ struct Bracketed: Equatable {
+ let open: Int
+ let body: Range<Int>
+ let close: Int
+ }
+
+ enum AnchorSuffix: Equatable {
+ case target(Bracketed)
+ case description(Bracketed)
+ }
+
+ enum InlineObject: Equatable {
+ /// `\x` — `escaped` is the index of the literal scalar, or `nil` for a
+ /// trailing backslash at the end of the range.
+ case escape(escaped: Int?, end: Int)
+ /// `%…%`, dropped from rendered output.
+ case comment(open: Int, body: Range<Int>, close: Int, end: Int)
+ /// Verbatim / math: `` `…` `` or `$…$`, inner content taken literally.
+ case verbatim(style: InlineStyle, open: Int, body: Range<Int>, close: Int, end: Int)
+ /// An attached modifier (`*…*`, `/…/`, …) whose body is parsed recursively.
+ case modifier(style: InlineStyle, open: Int, body: Range<Int>, close: Int, end: Int)
+ /// `{target}` optionally followed by `[description]`.
+ case link(open: Int, target: Bracketed, description: Bracketed?, end: Int)
+ /// `[name]` optionally followed by `{target}` or `[description]`.
+ case anchor(open: Int, name: Bracketed, suffix: AnchorSuffix?, end: Int)
+
+ var end: Int {
+ switch self {
+ case .escape(_, let end), .comment(_, _, _, let end), .verbatim(_, _, _, _, let end),
+ .modifier(_, _, _, _, let end), .link(_, _, _, let end), .anchor(_, _, _, let end):
+ return end
+ }
+ }
+ }
+
+ static let modifiers: [Unicode.Scalar: InlineStyle] = [
+ "*": .bold,
+ "/": .italic,
+ "_": .underline,
+ "-": .strikethrough,
+ "^": .superscript,
+ ",": .subscript,
+ "!": .spoiler,
+ ]
+
+ static let literalModifiers: [Unicode.Scalar: InlineStyle] = [
+ "`": .verbatim,
+ "$": .math,
+ ]
+
+ static let significant = ASCIIByteSet("*/_-^,!\u{60}$%{[\\")
+
+ static func object(in chars: [Unicode.Scalar], at i: Int, to hi: Int) -> InlineObject? {
+ let c = chars[i]
+ let prev: Unicode.Scalar? = i > 0 ? chars[i - 1] : nil
+ let next: Unicode.Scalar? = i + 1 < chars.count ? chars[i + 1] : nil
+
+ // Escapes: the next scalar is taken literally.
+ if c == "\\" {
+ return i + 1 < hi ? .escape(escaped: i + 1, end: i + 2) : .escape(escaped: nil, end: i + 1)
+ }
+
+ // Comments are dropped from the rendered output.
+ if c == "%", isOpener(prev: prev, next: next),
+ let close = literalClose(chars, from: i + 1, to: hi, char: "%") {
+ return .comment(open: i, body: (i + 1)..<close, close: close, end: close + 1)
+ }
+
+ // Verbatim / math objects: literal inner content.
+ if let style = literalModifiers[c], isOpener(prev: prev, next: next),
+ let close = literalClose(chars, from: i + 1, to: hi, char: c) {
+ return .verbatim(style: style, open: i, body: (i + 1)..<close, close: close, end: close + 1)
+ }
+
+ // Links: {location} optionally followed by [description].
+ if c == "{", let close = bracketClose(chars, from: i + 1, to: hi, char: "}") {
+ let target = Bracketed(open: i, body: (i + 1)..<close, close: close)
+ var j = close + 1
+ var description: Bracketed?
+ if j < hi, chars[j] == "[", let dclose = bracketClose(chars, from: j + 1, to: hi, char: "]") {
+ description = Bracketed(open: j, body: (j + 1)..<dclose, close: dclose)
+ j = dclose + 1
+ }
+ return .link(open: i, target: target, description: description, end: j)
+ }
+
+ // Anchors: [name] (declaration), [name]{location} (definition), or
+ // [name][description] (declaration with a custom description).
+ if c == "[", let close = bracketClose(chars, from: i + 1, to: hi, char: "]") {
+ let name = Bracketed(open: i, body: (i + 1)..<close, close: close)
+ var j = close + 1
+ var suffix: AnchorSuffix?
+ if j < hi, chars[j] == "{", let tclose = bracketClose(chars, from: j + 1, to: hi, char: "}") {
+ suffix = .target(Bracketed(open: j, body: (j + 1)..<tclose, close: tclose))
+ j = tclose + 1
+ } else if j < hi, chars[j] == "[",
+ let dclose = bracketClose(chars, from: j + 1, to: hi, char: "]") {
+ suffix = .description(Bracketed(open: j, body: (j + 1)..<dclose, close: dclose))
+ j = dclose + 1
+ }
+ return .anchor(open: i, name: name, suffix: suffix, end: j)
+ }
+
+ // Attached modifiers with recursively parsed content.
+ if let style = modifiers[c], isOpener(prev: prev, next: next),
+ let close = modifierClose(chars, from: i + 1, to: hi, char: c) {
+ return .modifier(style: style, open: i, body: (i + 1)..<close, close: close, end: close + 1)
+ }
+
+ return nil
+ }
+
+ // MARK: - Boundary helpers
+
+ private static func isSpace(_ c: Unicode.Scalar?) -> Bool {
+ guard let c else { return true }
+ return c.properties.isWhitespace
+ }
+
+ private static func isBoundary(_ c: Unicode.Scalar?) -> Bool {
+ guard let c else { return true }
+ if c.properties.isWhitespace { return true }
+ switch c.properties.generalCategory {
+ case .connectorPunctuation, .dashPunctuation, .openPunctuation,
+ .closePunctuation, .initialPunctuation, .finalPunctuation, .otherPunctuation,
+ .mathSymbol, .currencySymbol, .modifierSymbol, .otherSymbol:
+ return true
+ default:
+ return false
+ }
+ }
+
+ private static func isOpener(prev: Unicode.Scalar?, next: Unicode.Scalar?) -> Bool {
+ isBoundary(prev) && !isSpace(next)
+ }
+
+ private static func modifierClose(
+ _ chars: [Unicode.Scalar], from start: Int, to hi: Int, char: Unicode.Scalar
+ ) -> Int? {
+ var i = start
+ while i < hi {
+ if chars[i] == "\\" {
+ i += 2
+ continue
+ }
+ if chars[i] == char {
+ let prev: Unicode.Scalar? = i > 0 ? chars[i - 1] : nil
+ let next: Unicode.Scalar? = i + 1 < chars.count ? chars[i + 1] : nil
+ if !isSpace(prev) && isBoundary(next) { return i }
+ }
+ i += 1
+ }
+ return nil
+ }
+
+ private static func literalClose(
+ _ chars: [Unicode.Scalar], from start: Int, to hi: Int, char: Unicode.Scalar
+ ) -> Int? {
+ var i = start
+ while i < hi {
+ if chars[i] == char {
+ let prev: Unicode.Scalar? = i > 0 ? chars[i - 1] : nil
+ if !isSpace(prev) { return i }
+ }
+ i += 1
+ }
+ return nil
+ }
+
+ private static func bracketClose(
+ _ chars: [Unicode.Scalar], from start: Int, to hi: Int, char: Unicode.Scalar
+ ) -> Int? {
+ var i = start
+ while i < hi {
+ if chars[i] == "\\" {
+ i += 2
+ continue
+ }
+ if chars[i] == char { return i }
+ i += 1
+ }
+ return nil
+ }
+}
diff --git a/Sources/NorgKit/Parsers/NorgInlineParser.swift b/Sources/NorgKit/Parsers/NorgInlineParser.swift
index 1f19c43..b47ec50 100644
--- a/Sources/NorgKit/Parsers/NorgInlineParser.swift
+++ b/Sources/NorgKit/Parsers/NorgInlineParser.swift
@@ -3,31 +3,10 @@ import Foundation
/// Converts inline Norg markup into a list of `InlineSpan`s.
public enum NorgInlineParser {
- /// Attached modifiers whose content is parsed recursively for nesting.
- private static let modifiers: [Unicode.Scalar: InlineStyle] = [
- "*": .bold,
- "/": .italic,
- "_": .underline,
- "-": .strikethrough,
- "^": .superscript,
- ",": .subscript,
- "!": .spoiler,
- ]
-
- /// Modifiers whose content is taken verbatim.
- private static let literalModifiers: [Unicode.Scalar: InlineStyle] = [
- "`": .verbatim,
- "$": .math,
- ]
-
- /// Bytes that can begin an inline object. If none are found, it's plain
- /// text.
- private static let significant = ASCIIByteSet("*/_-^,!\u{60}$%{[\\")
-
/// Parses a string to a list of `InlineSpan`s
public static func parse(_ text: String) -> [InlineSpan] {
if text.isEmpty { return [] }
- if !text.utf8.contains(where: significant.contains) {
+ if !text.utf8.contains(where: InlineScanner.significant.contains) {
return [InlineSpan(text: text, styles: [])]
}
@@ -38,7 +17,7 @@ public enum NorgInlineParser {
/// Renders the text as plain text, without styling or markup.
static func plainText(_ text: Substring) -> String {
if text.isEmpty { return "" }
- if !text.utf8.contains(where: significant.contains) {
+ if !text.utf8.contains(where: InlineScanner.significant.contains) {
return String(text)
}
@@ -59,179 +38,68 @@ public enum NorgInlineParser {
var i = lo
while i < hi {
- let c = chars[i]
- // `prev`/`next` deliberately peek outside `[lo, hi)`: within a nested
- // range the enclosing modifier (e.g. the `_` around `_/x/_`) is a
- // valid boundary, so boundary detection uses the whole line.
- let prev: Unicode.Scalar? = i > 0 ? chars[i - 1] : nil
- let next: Unicode.Scalar? = i + 1 < chars.count ? chars[i + 1] : nil
-
- // Escapes: the next scalar is taken literally.
- if c == "\\" {
- if i + 1 < hi {
- buffer.append(chars[i + 1])
- i += 2
- } else {
- i += 1
- }
+ guard let object = InlineScanner.object(in: chars, at: i, to: hi) else {
+ buffer.append(chars[i])
+ i += 1
continue
}
- // Comments are dropped from the rendered output.
- if c == "%", isOpener(prev: prev, next: next),
- let close = literalClose(chars, from: i + 1, to: hi, char: "%") {
+ switch object {
+ case .escape(let escaped, _):
+ if let escaped { buffer.append(chars[escaped]) }
+
+ case .comment:
flush()
- i = close + 1
- continue
- }
- // Verbatim / math objects: literal inner content.
- if let style = literalModifiers[c], isOpener(prev: prev, next: next),
- let close = literalClose(chars, from: i + 1, to: hi, char: c) {
+ case .verbatim(let style, _, let body, _, _):
flush()
- spans.append(InlineSpan(text: slice(chars, i + 1, close), styles: base.union(style)))
- i = close + 1
- continue
- }
+ spans.append(InlineSpan(text: slice(chars, body), styles: base.union(style)))
- // Links: {location} optionally followed by [description].
- if c == "{", let close = bracketClose(chars, from: i + 1, to: hi, char: "}") {
- let target = slice(chars, i + 1, close)
- var j = close + 1
- var label = linkLabel(target)
- if j < hi, chars[j] == "[", let dclose = bracketClose(chars, from: j + 1, to: hi, char: "]") {
- // An explicit description replaces the derived label verbatim.
- label = slice(chars, j + 1, dclose)
- j = dclose + 1
- }
+ case .modifier(let style, _, let body, _, _):
flush()
spans.append(
- InlineSpan(text: label, styles: base, link: InlineLink(kind: .link, target: target)))
- i = j
- continue
- }
+ contentsOf: parse(
+ chars, from: body.lowerBound, to: body.upperBound, base: base.union(style)))
- // Anchors: [name] (declaration), [name]{location} (definition), or
- // [name][description] (declaration with a custom description).
- if c == "[", let close = bracketClose(chars, from: i + 1, to: hi, char: "]") {
- let name = slice(chars, i + 1, close)
- var j = close + 1
- var label = name
- var target: String?
- if j < hi, chars[j] == "{", let tclose = bracketClose(chars, from: j + 1, to: hi, char: "}") {
- target = slice(chars, j + 1, tclose)
- j = tclose + 1
- } else if j < hi, chars[j] == "[",
- let dclose = bracketClose(chars, from: j + 1, to: hi, char: "]") {
- label = slice(chars, j + 1, dclose)
- j = dclose + 1
- }
+ case .link(_, let target, let description, _):
flush()
+ let targetText = slice(chars, target.body)
+ let label = description.map { slice(chars, $0.body) } ?? linkLabel(targetText)
spans.append(
- InlineSpan(text: label, styles: base, link: InlineLink(kind: .anchor, target: target)))
- i = j
- continue
- }
+ InlineSpan(text: label, styles: base, link: InlineLink(kind: .link, target: targetText)))
- // Attached modifiers with recursively parsed content.
- if let style = modifiers[c], isOpener(prev: prev, next: next),
- let close = modifierClose(chars, from: i + 1, to: hi, char: c) {
+ case .anchor(_, let name, let suffix, _):
flush()
- spans.append(contentsOf: parse(chars, from: i + 1, to: close, base: base.union(style)))
- i = close + 1
- continue
+ let nameText = slice(chars, name.body)
+ let label: String
+ let target: String?
+ switch suffix {
+ case .target(let bracket):
+ label = nameText
+ target = slice(chars, bracket.body)
+ case .description(let bracket):
+ label = slice(chars, bracket.body)
+ target = nil
+ case nil:
+ label = nameText
+ target = nil
+ }
+ spans.append(
+ InlineSpan(text: label, styles: base, link: InlineLink(kind: .anchor, target: target)))
}
- buffer.append(c)
- i += 1
+ i = object.end
}
flush()
return spans
}
- // MARK: - Boundary helpers
-
- /// Builds a `String` from a half-open scalar range `[from, to)`.
- private static func slice(_ chars: [Unicode.Scalar], _ from: Int, _ to: Int) -> String {
- String(String.UnicodeScalarView(chars[from..<to]))
- }
-
- private static func isSpace(_ c: Unicode.Scalar?) -> Bool {
- guard let c else { return true }
- return c.properties.isWhitespace
- }
-
- /// Whether a scalar (or the absence of one, at a line edge) counts as a
- /// modifier boundary: whitespace, punctuation, or a symbol.
- private static func isBoundary(_ c: Unicode.Scalar?) -> Bool {
- guard let c else { return true }
- if c.properties.isWhitespace { return true }
- switch c.properties.generalCategory {
- case .connectorPunctuation, .dashPunctuation, .openPunctuation,
- .closePunctuation, .initialPunctuation, .finalPunctuation, .otherPunctuation,
- .mathSymbol, .currencySymbol, .modifierSymbol, .otherSymbol:
- return true
- default:
- return false
- }
- }
-
- /// A valid opener is preceded by a boundary and followed by non-whitespace.
- private static func isOpener(prev: Unicode.Scalar?, next: Unicode.Scalar?) -> Bool {
- isBoundary(prev) && !isSpace(next)
- }
+ // MARK: - Helpers
- /// Finds the closing modifier of the same character: preceded by
- /// non-whitespace and followed by a boundary. Honours escapes.
- private static func modifierClose(
- _ chars: [Unicode.Scalar], from start: Int, to hi: Int, char: Unicode.Scalar
- ) -> Int? {
- var i = start
- while i < hi {
- if chars[i] == "\\" {
- i += 2
- continue
- }
- if chars[i] == char {
- let prev: Unicode.Scalar? = i > 0 ? chars[i - 1] : nil
- let next: Unicode.Scalar? = i + 1 < chars.count ? chars[i + 1] : nil
- if !isSpace(prev) && isBoundary(next) { return i }
- }
- i += 1
- }
- return nil
- }
-
- /// Finds the closing character for verbatim/comment content. No escapes.
- private static func literalClose(
- _ chars: [Unicode.Scalar], from start: Int, to hi: Int, char: Unicode.Scalar
- ) -> Int? {
- var i = start
- while i < hi {
- if chars[i] == char {
- let prev: Unicode.Scalar? = i > 0 ? chars[i - 1] : nil
- if !isSpace(prev) { return i }
- }
- i += 1
- }
- return nil
- }
-
- /// Finds a matching closing bracket, honouring escapes.
- private static func bracketClose(
- _ chars: [Unicode.Scalar], from start: Int, to hi: Int, char: Unicode.Scalar
- ) -> Int? {
- var i = start
- while i < hi {
- if chars[i] == "\\" {
- i += 2
- continue
- }
- if chars[i] == char { return i }
- i += 1
- }
- return nil
+ /// Builds a `String` from a half-open scalar range.
+ private static func slice(_ chars: [Unicode.Scalar], _ range: Range<Int>) -> String {
+ String(String.UnicodeScalarView(chars[range]))
}
/// Produces display text for a link target that has no explicit description
diff --git a/Sources/NorgKit/Parsers/NorgParser.swift b/Sources/NorgKit/Parsers/NorgParser.swift
index aad66f4..20a76a0 100644
--- a/Sources/NorgKit/Parsers/NorgParser.swift
+++ b/Sources/NorgKit/Parsers/NorgParser.swift
@@ -1,10 +1,8 @@
/// Parses a Norg document into a list of `NorgBlock`s.
public enum NorgParser {
- private enum Delimiter { case weak, strong, rule }
-
- private static let blockMarkers = ASCIIByteSet("*-~>")
- private static let rangeableMarkers = ASCIIByteSet("$^:")
+ private static let blockMarkers = BlockScanner.blockMarkers
+ private static let rangeableMarkers = BlockScanner.rangeableMarkers
private struct Source {
let raw: [Substring]
@@ -51,7 +49,7 @@ public enum NorgParser {
// Delimiting modifiers (lines of two or more identical -, = or _).
// All three are emitted as blocks: `___` renders as a rule, while
// `---`/`===` are reset signals the tree fold consumes.
- if let delimiter = delimiter(line) {
+ if let delimiter = BlockScanner.delimiter(line) {
switch delimiter {
case .rule: blocks.append(.horizontalRule(line: lineNo))
case .weak: blocks.append(.weakDelimiter(line: lineNo))
@@ -264,22 +262,10 @@ public enum NorgParser {
// MARK: - Helpers
- /// Identifies a delimiting-modifier line (two or more identical `-`, `=`, `_`).
- private static func delimiter(_ s: Substring) -> Delimiter? {
- guard s.count >= 2, let first = s.first, "-=_".contains(first),
- s.allSatisfy({ $0 == first })
- else { return nil }
- switch first {
- case "-": return .weak
- case "=": return .strong
- default: return .rule
- }
- }
-
/// Whether a trimmed line starts a new block (used to break paragraphs).
private static func isBlockStart(_ trimmed: Substring) -> Bool {
if trimmed.hasPrefix("@") { return true }
- if delimiter(trimmed) != nil { return true }
+ if BlockScanner.delimiter(trimmed) != nil { return true }
if DetachedModifier.parse(trimmed[...], accepting: blockMarkers) != nil { return true }
return DetachedModifier.parse(trimmed[...], accepting: rangeableMarkers) != nil
}