aboutsummaryrefslogtreecommitdiff
path: root/Sources
diff options
context:
space:
mode:
Diffstat (limited to 'Sources')
-rw-r--r--Sources/NorgKit/Lexer/NorgLexer.swift193
-rw-r--r--Sources/NorgKit/Lexer/NorgToken.swift65
-rw-r--r--Sources/NorgKit/Models/DetachedModifier.swift6
-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
7 files changed, 516 insertions, 192 deletions
diff --git a/Sources/NorgKit/Lexer/NorgLexer.swift b/Sources/NorgKit/Lexer/NorgLexer.swift
new file mode 100644
index 0000000..ad9d457
--- /dev/null
+++ b/Sources/NorgKit/Lexer/NorgLexer.swift
@@ -0,0 +1,193 @@
+/// Tokenizes Norg source into `NorgToken`s for syntax highlighting.
+public enum NorgLexer {
+
+ /// Tokenizes text for syntax highlighting.
+ public static func tokenize(_ text: String) -> [NorgToken] {
+ var tokens: [NorgToken] = []
+ let lines = TextHelper.lineSlices(text)
+ var i = 0
+
+ while i < lines.count {
+ let raw = lines[i]
+ let trimmed = TextHelper.whitespaceTrimmed(raw)
+
+ if trimmed.isEmpty {
+ i += 1
+ continue
+ }
+
+ if trimmed.first == "@" {
+ i = tagBlock(lines, at: i, into: &tokens)
+ continue
+ }
+
+ if let delimiter = BlockScanner.delimiter(trimmed) {
+ let kind: NorgToken.Kind
+ switch delimiter {
+ case .weak: kind = .weakDelimiter
+ case .strong: kind = .strongDelimiter
+ case .rule: kind = .horizontalRule
+ }
+ tokens.append(NorgToken(kind: kind, range: trimmed.startIndex..<trimmed.endIndex))
+ i += 1
+ continue
+ }
+
+ if let m = DetachedModifier.parse(raw, accepting: BlockScanner.rangeableMarkers) {
+ detachedTokens(m, into: &tokens)
+ } else if let m = DetachedModifier.parse(raw, accepting: BlockScanner.blockMarkers) {
+ detachedTokens(m, into: &tokens)
+ } else {
+ inlineTokens(in: raw, into: &tokens)
+ }
+ i += 1
+ }
+
+ return tokens
+ }
+
+ // MARK: - Block-level tokens
+
+ private static func tagBlock(
+ _ lines: [Substring], at i: Int, into tokens: inout [NorgToken]
+ ) -> Int {
+ tagHeaderTokens(lines[i], into: &tokens)
+
+ let header = TextHelper.whitespaceTrimmed(TextHelper.whitespaceTrimmed(lines[i]).dropFirst())
+ if header == "end" { return i + 1 }
+
+ var j = i + 1
+ while j < lines.count {
+ if TextHelper.whitespaceTrimmed(lines[j]) == "@end" {
+ tagHeaderTokens(lines[j], into: &tokens)
+ return j + 1
+ }
+ let body = lines[j]
+ if body.startIndex < body.endIndex {
+ tokens.append(NorgToken(kind: .verbatimBlock, range: body.startIndex..<body.endIndex))
+ }
+ j += 1
+ }
+ return j
+ }
+
+ private static func tagHeaderTokens(_ raw: Substring, into tokens: inout [NorgToken]) {
+ guard let at = raw.firstIndex(of: "@") else { return }
+ let afterAt = raw.index(after: at)
+ tokens.append(NorgToken(kind: .tagDelimiter, range: at..<afterAt))
+ let header = TextHelper.whitespaceTrimmed(raw[afterAt...])
+ if !header.isEmpty {
+ tokens.append(NorgToken(kind: .tagName, range: header.startIndex..<header.endIndex))
+ }
+ }
+
+ private static func detachedTokens(_ m: DetachedModifier, into tokens: inout [NorgToken]) {
+ if let kind = markerKind(m.marker, level: m.level) {
+ tokens.append(NorgToken(kind: kind, range: m.markerRange))
+ }
+ if let status = m.status, let statusIndex = m.statusIndex {
+ let base = m.content.base
+ let open = base.index(before: statusIndex)
+ let close = base.index(after: base.index(after: statusIndex))
+ tokens.append(NorgToken(kind: .taskStatus(status), range: open..<close))
+ }
+ inlineTokens(in: m.content, into: &tokens)
+ }
+
+ private static func markerKind(_ marker: Character, level: Int) -> NorgToken.Kind? {
+ switch marker {
+ case "*": return .heading(level: level)
+ case "-": return .unorderedList(level: level)
+ case "~": return .orderedList(level: level)
+ case ">": return .quote(level: level)
+ case "$": return .definition(level: level)
+ case "^": return .footnote(level: level)
+ case ":": return .tableCell(level: level)
+ default: return nil
+ }
+ }
+
+ // MARK: - Inline tokens
+
+ private static func inlineTokens(in slice: Substring, into tokens: inout [NorgToken]) {
+ if slice.isEmpty { return }
+ if !slice.utf8.contains(where: InlineScanner.significant.contains) { return }
+
+ let chars = Array(slice.unicodeScalars)
+ var bound = Array(slice.unicodeScalars.indices)
+ bound.append(slice.endIndex)
+
+ func append(_ kind: NorgToken.Kind, _ from: Int, _ to: Int) {
+ tokens.append(NorgToken(kind: kind, range: bound[from]..<bound[to]))
+ }
+
+ func bracketed(_ b: InlineScanner.Bracketed, body kind: NorgToken.Kind) {
+ append(.linkDelimiter, b.open, b.open + 1)
+ if b.body.lowerBound < b.body.upperBound {
+ append(kind, b.body.lowerBound, b.body.upperBound)
+ }
+ append(.linkDelimiter, b.close, b.close + 1)
+ }
+
+ func emit(_ lo: Int, _ hi: Int, style: InlineStyle) {
+ var runStart = -1
+
+ func flushRun(_ upTo: Int) {
+ if runStart >= 0 {
+ if !style.isEmpty { append(.styledText(style), runStart, upTo) }
+ runStart = -1
+ }
+ }
+
+ var i = lo
+ while i < hi {
+ guard let object = InlineScanner.object(in: chars, at: i, to: hi) else {
+ if runStart < 0 { runStart = i }
+ i += 1
+ continue
+ }
+ flushRun(i)
+
+ switch object {
+ case .escape(let escaped, let end):
+ append(.escape, i, i + 1)
+ if let escaped, !style.isEmpty { append(.styledText(style), escaped, end) }
+
+ case .comment(let open, _, _, let end):
+ append(.comment, open, end)
+
+ case .verbatim(let vstyle, let open, let body, let close, _):
+ let s = style.union(vstyle)
+ append(.modifierDelimiter(s), open, open + 1)
+ if body.lowerBound < body.upperBound {
+ append(.styledText(s), body.lowerBound, body.upperBound)
+ }
+ append(.modifierDelimiter(s), close, close + 1)
+
+ case .modifier(let mstyle, let open, let body, let close, _):
+ let s = style.union(mstyle)
+ append(.modifierDelimiter(s), open, open + 1)
+ emit(body.lowerBound, body.upperBound, style: s)
+ append(.modifierDelimiter(s), close, close + 1)
+
+ case .link(_, let target, let description, _):
+ bracketed(target, body: .linkTarget)
+ if let description { bracketed(description, body: .linkDescription) }
+
+ case .anchor(_, let name, let suffix, _):
+ bracketed(name, body: .linkDescription)
+ switch suffix {
+ case .target(let b): bracketed(b, body: .linkTarget)
+ case .description(let b): bracketed(b, body: .linkDescription)
+ case nil: break
+ }
+ }
+
+ i = object.end
+ }
+ flushRun(hi)
+ }
+
+ emit(0, chars.count, style: [])
+ }
+}
diff --git a/Sources/NorgKit/Lexer/NorgToken.swift b/Sources/NorgKit/Lexer/NorgToken.swift
new file mode 100644
index 0000000..050c37e
--- /dev/null
+++ b/Sources/NorgKit/Lexer/NorgToken.swift
@@ -0,0 +1,65 @@
+/// A lexical token over Norg source.
+public struct NorgToken: Equatable, Sendable {
+
+ /// What a token represents.
+ public enum Kind: Equatable, Sendable {
+
+ // MARK: Block / line level
+
+ /// A heading marker run (`*`…), carrying its level.
+ case heading(level: Int)
+ /// An unordered-list marker run (`-`…).
+ case unorderedList(level: Int)
+ /// An ordered-list marker run (`~`…).
+ case orderedList(level: Int)
+ /// A quote marker run (`>`…).
+ case quote(level: Int)
+ /// A definition marker run (`$`…).
+ case definition(level: Int)
+ /// A footnote marker run (`^`…).
+ case footnote(level: Int)
+ /// A table-cell marker run (`:`…).
+ case tableCell(level: Int)
+ /// A task status marker including its parentheses, eg. `(x)`.
+ case taskStatus(TaskStatus)
+ /// A weak delimiting line (`---`).
+ case weakDelimiter
+ /// A strong delimiting line (`===`).
+ case strongDelimiter
+ /// A horizontal rule (`___`).
+ case horizontalRule
+ /// A ranged-tag fence marker: the `@` of an opener and of `@end`.
+ case tagDelimiter
+ /// A ranged-tag header after `@` (name and parameters), eg. `code swift`.
+ case tagName
+ /// A raw body line inside a ranged tag (`@code` … `@end`).
+ case verbatimBlock
+
+ // MARK: Inline level
+
+ /// An attached- or verbatim-modifier delimiter (`*`, `/`, `` ` ``, `$`, …);
+ /// the style identifies which.
+ case modifierDelimiter(InlineStyle)
+ /// A run of text carrying a non-empty cumulative style (the content of one
+ /// or more nested modifiers).
+ case styledText(InlineStyle)
+ /// An inline comment, markers included (`%…%`).
+ case comment
+ /// An escape: the backslash of `\x` (the escaped character is not a token).
+ case escape
+ /// A link/anchor delimiter: `{`, `}`, `[`, or `]`.
+ case linkDelimiter
+ /// A link/anchor location (inside `{…}`).
+ case linkTarget
+ /// A link/anchor description or label (inside `[…]`).
+ case linkDescription
+ }
+
+ public let kind: Kind
+ public let range: Range<String.Index>
+
+ public init(kind: Kind, range: Range<String.Index>) {
+ self.kind = kind
+ self.range = range
+ }
+}
diff --git a/Sources/NorgKit/Models/DetachedModifier.swift b/Sources/NorgKit/Models/DetachedModifier.swift
index 1f28c36..51d53ff 100644
--- a/Sources/NorgKit/Models/DetachedModifier.swift
+++ b/Sources/NorgKit/Models/DetachedModifier.swift
@@ -6,6 +6,7 @@ struct DetachedModifier {
let status: TaskStatus?
let statusIndex: String.Index?
let content: Substring
+ let markerRange: Range<String.Index>
/// Finds and parses a detached modifier if present. The accepting markers
/// is used to reduce the markers being parsed.
@@ -21,12 +22,14 @@ struct DetachedModifier {
let markerByte = utf8[i]
guard markers.contains(markerByte) else { return nil }
+ let markerStart = i
var level = 0
var run = i
while run < end, utf8[run] == markerByte {
level += 1
run = utf8.index(after: run)
}
+ let markerRange = markerStart..<run
guard run < end, ASCIIHelper.isWhitespace(utf8[run]) else { return nil }
@@ -55,7 +58,8 @@ struct DetachedModifier {
level: level,
status: status,
statusIndex: statusIndex,
- content: TextHelper.whitespaceTrimmed(line[contentStart...])
+ content: TextHelper.whitespaceTrimmed(line[contentStart...]),
+ markerRange: markerRange
)
}
}
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
}