diff options
Diffstat (limited to 'Sources/NorgKit/Parsers')
| -rw-r--r-- | Sources/NorgKit/Parsers/BlockScanner.swift | 21 | ||||
| -rw-r--r-- | Sources/NorgKit/Parsers/InlineScanner.swift | 187 | ||||
| -rw-r--r-- | Sources/NorgKit/Parsers/NorgInlineParser.swift | 214 | ||||
| -rw-r--r-- | Sources/NorgKit/Parsers/NorgParser.swift | 22 |
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 } |