550 lines
26 KiB
Swift
550 lines
26 KiB
Swift
import Foundation
|
|
import Markdown
|
|
|
|
// MARK: - AST Walker
|
|
//
|
|
// SpanCollector walks the swift-markdown AST and records a Span for each inline
|
|
// construct it recognizes (headings, emphasis, code, links, tables, lists, …),
|
|
// translating swift-markdown SourceRanges into NSRanges over the original text.
|
|
// Constructs swift-markdown does not model (==highlight==, $math$, indented
|
|
// list items) are handled by the regex passes in +CustomParsers.
|
|
|
|
extension SyntaxHighlighter {
|
|
|
|
struct SpanCollector: MarkupWalker {
|
|
let source: String
|
|
private let lines: [String]
|
|
var spans: [Span] = []
|
|
|
|
/// Track nesting depth so we can detect bold-inside-italic (= boldItalic)
|
|
/// and avoid emitting duplicate spans. `insideEmphasis`/`insideStrong`
|
|
/// are internal (not private) so the inline visitors in
|
|
/// SyntaxHighlighter+WalkerInline can read and set them.
|
|
var insideEmphasis = false
|
|
var insideStrong = false
|
|
private var insideOrderedList = false
|
|
/// >0 while walking inside a *plain* block quote. Nested block-level
|
|
/// constructs (code blocks, headings, lists, tables, rules, nested
|
|
/// quotes) are left literal there — only inline emphasis renders.
|
|
/// Callouts are not walked at all (their bodies are rendered
|
|
/// recursively by the styling layer), so this never counts them.
|
|
private var plainQuoteDepth = 0
|
|
|
|
init(source: String) {
|
|
self.source = source
|
|
self.lines = source.components(separatedBy: "\n")
|
|
}
|
|
|
|
// MARK: - Source offset conversion
|
|
|
|
/// Converts a SourceLocation (1-indexed line, 1-indexed UTF-8 column)
|
|
/// to a UTF-16 offset suitable for NSRange.
|
|
func utf16Offset(for loc: SourceLocation) -> Int {
|
|
var utf8Offset = 0
|
|
for i in 0..<(loc.line - 1) {
|
|
if i < lines.count {
|
|
utf8Offset += lines[i].utf8.count + 1
|
|
}
|
|
}
|
|
utf8Offset += loc.column - 1
|
|
|
|
let utf8View = source.utf8
|
|
let targetIdx = utf8View.index(utf8View.startIndex,
|
|
offsetBy: min(utf8Offset, utf8View.count))
|
|
return source.utf16.distance(
|
|
from: source.utf16.startIndex,
|
|
to: String.Index(targetIdx, within: source.utf16) ?? source.utf16.endIndex
|
|
)
|
|
}
|
|
|
|
func nsRange(for range: SourceRange) -> NSRange {
|
|
let start = utf16Offset(for: range.lowerBound)
|
|
let end = utf16Offset(for: range.upperBound)
|
|
return NSRange(location: start, length: max(0, end - start))
|
|
}
|
|
|
|
/// Computes delimiter ranges by subtracting direct child ranges from parent.
|
|
func delimiterRanges(parent: NSRange, children: some Sequence<Markup>) -> [NSRange] {
|
|
// Collect child ranges (only direct children with source ranges)
|
|
var childRanges: [NSRange] = []
|
|
for child in children {
|
|
if let cr = child.range {
|
|
childRanges.append(nsRange(for: cr))
|
|
}
|
|
}
|
|
guard !childRanges.isEmpty else { return [] }
|
|
|
|
var delims: [NSRange] = []
|
|
let firstChild = childRanges[0]
|
|
if firstChild.location > parent.location {
|
|
delims.append(NSRange(location: parent.location,
|
|
length: firstChild.location - parent.location))
|
|
}
|
|
let lastChild = childRanges[childRanges.count - 1]
|
|
if lastChild.upperBound < parent.upperBound {
|
|
delims.append(NSRange(location: lastChild.upperBound,
|
|
length: parent.upperBound - lastChild.upperBound))
|
|
}
|
|
return delims
|
|
}
|
|
|
|
/// Trims delimiter ranges to the expected width for emphasis types.
|
|
/// When cmark includes unmatched delimiter characters in the emphasis
|
|
/// node's source range (e.g. `**here*` → italic with opening `**`),
|
|
/// this trims them so only the real delimiter chars are styled.
|
|
/// Returns adjusted (fullRange, delimiterRanges).
|
|
func trimEmphasisDelimiters(
|
|
expectedWidth: Int, full: NSRange, delims: [NSRange]
|
|
) -> (NSRange, [NSRange]) {
|
|
guard delims.count == 2 else { return (full, delims) }
|
|
var trimmedDelims = delims
|
|
var trimmedFull = full
|
|
|
|
// Opening delimiter: keep only the last `expectedWidth` chars
|
|
if delims[0].length > expectedWidth {
|
|
let excess = delims[0].length - expectedWidth
|
|
trimmedDelims[0] = NSRange(location: delims[0].location + excess,
|
|
length: expectedWidth)
|
|
trimmedFull = NSRange(location: trimmedFull.location + excess,
|
|
length: trimmedFull.length - excess)
|
|
}
|
|
|
|
// Closing delimiter: keep only the first `expectedWidth` chars
|
|
if delims[1].length > expectedWidth {
|
|
let excess = delims[1].length - expectedWidth
|
|
trimmedDelims[1] = NSRange(location: delims[1].location,
|
|
length: expectedWidth)
|
|
trimmedFull = NSRange(location: trimmedFull.location,
|
|
length: trimmedFull.length - excess)
|
|
}
|
|
|
|
return (trimmedFull, trimmedDelims)
|
|
}
|
|
|
|
/// Compute content range from full range and delimiter ranges.
|
|
func contentRange(full: NSRange, delims: [NSRange]) -> NSRange {
|
|
var start = full.location
|
|
var end = full.upperBound
|
|
if let first = delims.first, first.location == full.location {
|
|
start = first.upperBound
|
|
}
|
|
if let last = delims.last, last.upperBound == full.upperBound {
|
|
end = last.location
|
|
}
|
|
return NSRange(location: start, length: max(0, end - start))
|
|
}
|
|
|
|
// MARK: - Visitors
|
|
|
|
mutating func visitHeading(_ heading: Heading) {
|
|
if plainQuoteDepth > 0 { return } // literal inside a plain quote
|
|
guard let range = heading.range else { return }
|
|
let full = nsRange(for: range)
|
|
let text = (source as NSString).substring(with: full)
|
|
|
|
// Setext heading (`Title\n===`, or `Foo\nbar\n===` per GFM Example
|
|
// 51 — content can span multiple lines): no `#` prefix. Everything
|
|
// up to the *last* line is the content; that last line is the
|
|
// underline delimiter (hidden when rendered, dimmed when active).
|
|
// The `\n`s stay untouched so the line structure survives.
|
|
if !text.drop(while: { $0 == " " }).hasPrefix("#") {
|
|
let nl = (text as NSString).range(of: "\n", options: .backwards)
|
|
guard nl.location != NSNotFound else { return } // setext is 2+ lines
|
|
spans.append(Span(
|
|
kind: .heading(heading.level),
|
|
fullRange: full,
|
|
contentRange: NSRange(location: full.location, length: nl.location),
|
|
delimiterRanges: [NSRange(location: full.location + nl.upperBound,
|
|
length: full.length - nl.upperBound)]
|
|
))
|
|
descendInto(heading) // inline children style at heading size
|
|
return
|
|
}
|
|
|
|
let delimLen = heading.level + 1
|
|
// cmark already recognizes and trims a valid optional closing
|
|
// sequence (GFM 4.2, e.g. `# foo ###`) out of `heading.range` —
|
|
// it can even trim `full` down to just the opening `#` run when
|
|
// the heading is otherwise empty (`## ##`), shorter than
|
|
// `delimLen`. Clamp so an empty-content heading doesn't push
|
|
// `cStart` past what `full` actually covers.
|
|
let openDelimLen = min(delimLen, full.length)
|
|
let cStart = full.location + openDelimLen
|
|
let cLen = max(0, full.length - openDelimLen)
|
|
var delimiterRanges = [NSRange(location: full.location, length: openDelimLen)]
|
|
|
|
// Whatever raw text follows `full` to the end of this
|
|
// single-line block is exactly what cmark trimmed as the
|
|
// closing sequence (its required separating whitespace, the
|
|
// `#` run, and any trailing whitespace) — hide it too.
|
|
let nsSource = source as NSString
|
|
let lineEnd = nsSource.length
|
|
if full.upperBound < lineEnd {
|
|
delimiterRanges.append(NSRange(location: full.upperBound, length: lineEnd - full.upperBound))
|
|
}
|
|
|
|
spans.append(Span(
|
|
kind: .heading(heading.level),
|
|
fullRange: full,
|
|
contentRange: NSRange(location: cStart, length: cLen),
|
|
delimiterRanges: delimiterRanges
|
|
))
|
|
// The heading span is appended first, so inner spans read the
|
|
// heading font as their context and keep its size.
|
|
descendInto(heading)
|
|
}
|
|
|
|
// MARK: - Code Blocks
|
|
|
|
mutating func visitCodeBlock(_ codeBlock: CodeBlock) {
|
|
if plainQuoteDepth > 0 { return } // literal inside a plain quote
|
|
guard let range = codeBlock.range else { return }
|
|
let full = nsRange(for: range)
|
|
guard full.length > 0 else { return }
|
|
|
|
let nsSource = source as NSString
|
|
let blockText = nsSource.substring(with: full) as NSString
|
|
|
|
// Indented code block: no fence, so no delimiters — every
|
|
// character (indentation included) is content. swift-markdown's
|
|
// node range starts *after* the first line's 4-space indent, so
|
|
// expand back over the leading whitespace or those characters
|
|
// would keep the body font and misalign the first line.
|
|
let opener = (blockText as String).drop(while: { $0 == " " })
|
|
if !(opener.hasPrefix("```") || opener.hasPrefix("~~~")) {
|
|
var start = full.location
|
|
while start > 0 {
|
|
let c = nsSource.character(at: start - 1)
|
|
guard c == 0x20 || c == 0x09 else { break }
|
|
start -= 1
|
|
}
|
|
let expanded = NSRange(location: start, length: full.upperBound - start)
|
|
spans.append(Span(
|
|
kind: .codeBlock(language: nil),
|
|
fullRange: expanded,
|
|
contentRange: expanded,
|
|
delimiterRanges: []
|
|
))
|
|
return
|
|
}
|
|
|
|
var delims: [NSRange] = []
|
|
var cStart = full.location
|
|
var cEnd = full.upperBound
|
|
|
|
let firstNL = blockText.range(of: "\n")
|
|
if firstNL.location != NSNotFound {
|
|
// Opening fence line (including newline)
|
|
let openLen = firstNL.location + 1
|
|
delims.append(NSRange(location: full.location, length: openLen))
|
|
cStart = full.location + openLen
|
|
|
|
// Look for closing fence line
|
|
let lastNL = blockText.range(of: "\n", options: .backwards)
|
|
if lastNL.location != NSNotFound && lastNL.location != firstNL.location {
|
|
let lastLineStart = lastNL.location + 1
|
|
if lastLineStart < blockText.length {
|
|
let lastLine = blockText.substring(from: lastLineStart)
|
|
.trimmingCharacters(in: .whitespaces)
|
|
if lastLine.hasPrefix("```") || lastLine.hasPrefix("~~~") {
|
|
let closeStart = full.location + lastNL.location
|
|
delims.append(NSRange(location: closeStart,
|
|
length: full.upperBound - closeStart))
|
|
cEnd = closeStart
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Single line (shouldn't normally happen with fenced code blocks)
|
|
delims.append(full)
|
|
cStart = full.upperBound
|
|
}
|
|
|
|
let content = NSRange(location: cStart, length: max(0, cEnd - cStart))
|
|
spans.append(Span(
|
|
kind: .codeBlock(language: codeBlock.language),
|
|
fullRange: full,
|
|
contentRange: content,
|
|
delimiterRanges: delims
|
|
))
|
|
}
|
|
|
|
// MARK: - Block Quotes
|
|
|
|
mutating func visitBlockQuote(_ blockQuote: BlockQuote) {
|
|
guard let range = blockQuote.range else {
|
|
descendInto(blockQuote)
|
|
return
|
|
}
|
|
let full = nsRange(for: range)
|
|
let nsSource = source as NSString
|
|
let opensCallout = Self.quoteOpensCallout(firstLineOf: full, in: nsSource)
|
|
|
|
// A callout nested inside a plain quote is fully literal: emit no
|
|
// span and don't descend (matches showing `> [!note]` raw inside an
|
|
// outer quote — callouts render via their own recursive splice,
|
|
// which isn't set up to stack with an enclosing quote's bar). A
|
|
// nested *plain* quote is the one exception to "nested blocks stay
|
|
// literal": it gets its own span (below) so its marker hides and it
|
|
// draws its own bar, stacked with its ancestors'.
|
|
if plainQuoteDepth > 0 && opensCallout { return }
|
|
|
|
// `plainQuoteDepth` doubles as this quote's nesting depth (0 =
|
|
// outermost) — captured before incrementing for our own descent.
|
|
let depth = plainQuoteDepth
|
|
|
|
// Scan each line within the blockquote for its OWN "> " marker.
|
|
// swift-markdown's nested-BlockQuote `range` only skips ancestor
|
|
// markers on the *first* line (its start position lands right on
|
|
// this quote's own `>`) — every subsequent line is the raw source
|
|
// verbatim, ancestor markers and all. So each later line must peel
|
|
// exactly `depth` ancestor markers before this quote's own can be
|
|
// at hand.
|
|
//
|
|
// A no-marker line is CommonMark "lazy continuation". Which quote
|
|
// it belongs to depends on depth: at the OUTERMOST quote (depth 0)
|
|
// it continues *this* quote's paragraph — BlockParser already
|
|
// merged it into the block, so keep it in the span (the bar extends
|
|
// over it) with no marker to hide. Deeper in (depth > 0), a line
|
|
// that runs out of ancestor markers, or lacks this quote's own,
|
|
// belongs to a shallower ancestor's span — clip `fullRange` before
|
|
// it.
|
|
var delims: [NSRange] = []
|
|
var cursor = full.location
|
|
var clippedEnd = full.upperBound
|
|
var isFirstLine = true
|
|
while cursor < clippedEnd {
|
|
let remaining = NSRange(location: cursor, length: clippedEnd - cursor)
|
|
let nlRange = nsSource.range(of: "\n", options: [], range: remaining)
|
|
let lineEnd = nlRange.location != NSNotFound ? nlRange.location : clippedEnd
|
|
|
|
var p = cursor
|
|
var ranOut = false
|
|
for _ in 0..<(isFirstLine ? 0 : depth) {
|
|
guard let after = Self.peelOneMarker(nsSource, from: p, lineEnd: lineEnd) else {
|
|
ranOut = true
|
|
break
|
|
}
|
|
p = after
|
|
}
|
|
if ranOut {
|
|
// Ran out of ancestor markers: line belongs to a shallower
|
|
// ancestor (nested lazy continuation). Clip here.
|
|
clippedEnd = cursor
|
|
break
|
|
}
|
|
if let markerEnd = Self.peelOneMarker(nsSource, from: p, lineEnd: lineEnd) {
|
|
delims.append(NSRange(location: p, length: markerEnd - p))
|
|
} else if depth == 0 {
|
|
// Lazy continuation of the outermost quote: keep it in the
|
|
// span, no marker to hide, and keep scanning.
|
|
} else {
|
|
// Nested quote missing its own marker: belongs to a
|
|
// shallower ancestor. Clip here.
|
|
clippedEnd = cursor
|
|
break
|
|
}
|
|
|
|
cursor = nlRange.location != NSNotFound ? nlRange.location + 1 : clippedEnd
|
|
isFirstLine = false
|
|
}
|
|
|
|
let clippedFull = NSRange(location: full.location, length: clippedEnd - full.location)
|
|
let content = contentRange(full: clippedFull, delims: delims)
|
|
|
|
spans.append(Span(
|
|
kind: .blockquote(depth: depth),
|
|
fullRange: clippedFull,
|
|
contentRange: content,
|
|
delimiterRanges: delims
|
|
))
|
|
|
|
// A callout's body is rendered recursively by the styling layer
|
|
// (which strips the `>` prefixes and re-parses), so don't descend —
|
|
// doing so would emit nested spans over `>`-prefixed source ranges.
|
|
// A plain quote descends, but with a depth guard so only inline
|
|
// content and further nested plain quotes render (other nested
|
|
// blocks stay literal).
|
|
if opensCallout { return }
|
|
plainQuoteDepth += 1
|
|
descendInto(blockQuote)
|
|
plainQuoteDepth -= 1
|
|
}
|
|
|
|
/// Peels one `>` marker (optional leading spaces, `>`, optional single
|
|
/// trailing space) starting at `from`, returning the position right
|
|
/// after it — or `nil` if `[from, lineEnd)` doesn't start with `>`
|
|
/// (after skipping spaces). Used both to skip `depth` ancestor
|
|
/// markers and to locate this quote's own marker on a line (see
|
|
/// `visitBlockQuote`).
|
|
private static func peelOneMarker(_ source: NSString, from: Int, lineEnd: Int) -> Int? {
|
|
var q = from
|
|
while q < lineEnd, source.character(at: q) == 0x20 { q += 1 }
|
|
guard q < lineEnd, source.character(at: q) == 0x3E else { return nil }
|
|
q += 1
|
|
if q < lineEnd, source.character(at: q) == 0x20 { q += 1 }
|
|
return q
|
|
}
|
|
|
|
/// Whether the first line of a block quote (its source `range`) opens a
|
|
/// callout — `> [!type]` (known or unknown type). Mirrors
|
|
/// `BlockParser.quoteRunOpensCallout` over an NSRange.
|
|
private static func quoteOpensCallout(firstLineOf range: NSRange, in source: NSString) -> Bool {
|
|
let bound = NSRange(location: range.location, length: range.length)
|
|
let nl = source.range(of: "\n", options: [], range: bound)
|
|
let lineEnd = nl.location == NSNotFound ? range.upperBound : nl.location
|
|
let line = source.substring(with: NSRange(location: range.location,
|
|
length: lineEnd - range.location))
|
|
let trimmed = line.drop(while: { $0 == " " })
|
|
guard trimmed.first == ">" else { return false }
|
|
return Callout.parseMarker(String(trimmed.dropFirst())) != nil
|
|
}
|
|
|
|
// MARK: - Tables
|
|
|
|
mutating func visitTable(_ table: Table) {
|
|
if plainQuoteDepth > 0 { return } // literal inside a plain quote
|
|
guard let range = table.range else {
|
|
descendInto(table)
|
|
return
|
|
}
|
|
let full = nsRange(for: range)
|
|
|
|
// Compute gaps between child rows (head/body) as delimiters
|
|
// (this captures the separator row between head and body)
|
|
var childRanges: [NSRange] = []
|
|
for child in table.children {
|
|
if let cr = child.range {
|
|
childRanges.append(nsRange(for: cr))
|
|
}
|
|
}
|
|
|
|
var delims: [NSRange] = []
|
|
if let first = childRanges.first, first.location > full.location {
|
|
delims.append(NSRange(location: full.location,
|
|
length: first.location - full.location))
|
|
}
|
|
for i in 0..<(childRanges.count - 1) {
|
|
let gapStart = childRanges[i].upperBound
|
|
let gapEnd = childRanges[i + 1].location
|
|
if gapEnd > gapStart {
|
|
delims.append(NSRange(location: gapStart,
|
|
length: gapEnd - gapStart))
|
|
}
|
|
}
|
|
if let last = childRanges.last, last.upperBound < full.upperBound {
|
|
delims.append(NSRange(location: last.upperBound,
|
|
length: full.upperBound - last.upperBound))
|
|
}
|
|
|
|
spans.append(Span(
|
|
kind: .table,
|
|
fullRange: full,
|
|
contentRange: full,
|
|
delimiterRanges: delims
|
|
))
|
|
}
|
|
|
|
// MARK: - Lists
|
|
|
|
mutating func visitOrderedList(_ orderedList: OrderedList) {
|
|
if plainQuoteDepth > 0 { return } // literal inside a plain quote
|
|
insideOrderedList = true
|
|
descendInto(orderedList)
|
|
insideOrderedList = false
|
|
}
|
|
|
|
mutating func visitListItem(_ listItem: ListItem) {
|
|
if plainQuoteDepth > 0 { return } // literal inside a plain quote
|
|
guard let range = listItem.range else {
|
|
descendInto(listItem)
|
|
return
|
|
}
|
|
let full = nsRange(for: range)
|
|
var delims = delimiterRanges(parent: full, children: listItem.children)
|
|
// An empty list item (just "- " / "1. " / "- [ ] " with no content,
|
|
// e.g. a marker freshly created by pressing Return) has no child
|
|
// nodes, so `delimiterRanges` finds no marker and the whole item is
|
|
// treated as content. That collapses the marker's width to zero and
|
|
// pushes the freshly-typed marker a full slot too deep. Synthesize
|
|
// the marker delimiter from the leading text so the content begins
|
|
// after it, matching a non-empty item.
|
|
if delims.isEmpty, let markerLen = Self.leadingListMarkerLength(in: source, range: full) {
|
|
delims = [NSRange(location: full.location, length: markerLen)]
|
|
}
|
|
let content = contentRange(full: full, delims: delims)
|
|
|
|
// swift-markdown flags an item as a task list item via `checkbox`,
|
|
// but it reports the STATE by scanning the whole line for `[x]` — so
|
|
// an unchecked `- [ ]` whose body merely contains `[x]` (e.g. in a
|
|
// code span) is wrongly reported as checked. Take only the "is this a
|
|
// task item" signal from swift-markdown and read the actual state
|
|
// from the leading `[ ]`/`[x]` marker ourselves.
|
|
let checkbox: Span.Kind.CheckboxState?
|
|
if listItem.checkbox != nil {
|
|
let markerLen = max(0, content.location - full.location)
|
|
let marker = (source as NSString).substring(
|
|
with: NSRange(location: full.location, length: markerLen))
|
|
checkbox = Self.leadingCheckboxState(inMarker: marker)
|
|
?? (listItem.checkbox == .checked ? .checked : .unchecked)
|
|
} else {
|
|
checkbox = nil
|
|
}
|
|
|
|
spans.append(Span(
|
|
kind: .listItem(ordered: insideOrderedList, checkbox: checkbox),
|
|
fullRange: full,
|
|
contentRange: content,
|
|
delimiterRanges: delims
|
|
))
|
|
descendInto(listItem)
|
|
}
|
|
|
|
/// Matches a list item's leading marker (optional indentation +
|
|
/// `-`/`*`/`+` or `N.`, plus an optional `[ ]`/`[x]` checkbox), used to
|
|
/// recover the marker range for an empty item that has no child nodes.
|
|
private static let listMarkerRegex = try! NSRegularExpression(
|
|
pattern: #"^[ \t]*(?:[-*+][ \t]+(?:\[[ xX]\][ \t]*)?|\d+\.[ \t]+)"#)
|
|
|
|
/// Length (UTF-16) of the leading list marker within `range` of `source`,
|
|
/// or nil if the text there doesn't begin with a marker.
|
|
private static func leadingListMarkerLength(in source: String, range: NSRange) -> Int? {
|
|
let line = (source as NSString).substring(with: range)
|
|
let m = listMarkerRegex.firstMatch(
|
|
in: line, range: NSRange(location: 0, length: (line as NSString).length))
|
|
guard let m, m.range.location == 0, m.range.length > 0 else { return nil }
|
|
return m.range.length
|
|
}
|
|
|
|
/// Reads a task-list checkbox state from the item's leading marker text
|
|
/// (e.g. `"- [ ] "` → unchecked, `"1. [x] "` → checked) by inspecting the
|
|
/// character inside the first `[...]`. Returns nil if no bracket is found.
|
|
private static func leadingCheckboxState(inMarker marker: String)
|
|
-> Span.Kind.CheckboxState? {
|
|
let ns = marker as NSString
|
|
let open = ns.range(of: "[")
|
|
guard open.location != NSNotFound, open.upperBound < ns.length else { return nil }
|
|
switch ns.substring(with: NSRange(location: open.upperBound, length: 1)) {
|
|
case "x", "X": return .checked
|
|
case " ": return .unchecked
|
|
default: return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Thematic Break
|
|
|
|
mutating func visitThematicBreak(_ thematicBreak: ThematicBreak) {
|
|
if plainQuoteDepth > 0 { return } // literal inside a plain quote
|
|
guard let range = thematicBreak.range else { return }
|
|
let full = nsRange(for: range)
|
|
|
|
spans.append(Span(
|
|
kind: .thematicBreak,
|
|
fullRange: full,
|
|
contentRange: full,
|
|
delimiterRanges: [full]
|
|
))
|
|
}
|
|
}
|
|
}
|