PureYAML/Sources/Parsing/Scanner.Helpers.swift
Mihaela M ba000d90fa Merge tag-handle diagnostics onto main
Per-document %TAG scoping and an undefinedTagHandle diagnostic via
validationReport, with Scanner.State and the tag helpers in their own files.
Resolved against main's Scanner.State by carrying its sawNonPropertyContentOnLine
field into the extracted State.
2026-06-29 09:14:21 +02:00

380 lines
15 KiB
Swift

extension PureYAML.Parsing.Scanner {
/// A blank line inside a block scalar is preserved as empty content so that
/// chomping and folding can apply to it. The guard keeps blank lines outside
/// block scalars unaffected; `isAtLineStart` is true here only when the
/// previous line already ended, so a content line's own terminating break
/// does not emit a blank. Blank lines between the header and the first
/// content line precede the indent token, so they are buffered and emitted
/// once the content indentation is established.
func captureBlockScalarBlankLine(_ state: inout State) {
guard state.isAtLineStart else {
return
}
if state.blockScalarIndentation != nil {
// The prefix holds any surplus indentation past the block's content level for an
// otherwise-empty line (see `preserveBlockScalarSurplusOnBlankLine`); empty otherwise.
state.append(.scalar(value: state.blockScalarContentPrefix, style: .plain))
state.blockScalarContentPrefix = ""
} else if state.pendingBlockScalarIndentation {
state.pendingBlockScalarLeadingBlankWhitespace.append(state.lastLeadingWhitespace)
}
}
func startsWith(
_ prefix: String,
_ reader: PureYAML.Parsing.Reader,
) -> Bool {
for (offset, character) in prefix.enumerated() {
guard reader.peek(offset: offset) == character else {
return false
}
}
return true
}
func isLineBreak(_ character: Character?) -> Bool {
character == "\n" || character == "\r" || character == "\r\n"
}
func isBlockEntry(_ reader: PureYAML.Parsing.Reader) -> Bool {
reader.peek() == "-" && isIndicatorBoundary(reader.peek(offset: 1))
}
func isIndicatorBoundary(_ character: Character?) -> Bool {
guard let character else {
return true
}
// A tab is separation white space in YAML (only indentation forbids it), so it ends an
// indicator just as a space does: `---\tscalar`, `-\titem`, `?\tkey`.
return character == " " || character == "\t" || isLineBreak(character)
}
func isMappingValueBoundary(_ character: Character?) -> Bool {
guard let character else {
return true
}
// A tab is separation white space, so it follows a `:` value indicator as a space does.
return character == " "
|| character == "\t"
|| isLineBreak(character)
|| character == ","
|| character == "]"
|| character == "}"
}
func isBlockMappingValueBoundary(_ character: Character?) -> Bool {
guard let character else {
return true
}
return character == " " || character == "\t" || isLineBreak(character)
}
func isTokenTerminator(_ character: Character) -> Bool {
character == " "
|| isLineBreak(character)
|| character == ","
|| character == "["
|| character == "]"
|| character == "{"
|| character == "}"
|| character == "#"
}
func canStartAnchorName(_ character: Character?) -> Bool {
guard let character else {
return false
}
return !isTokenTerminator(character)
&& character != "*"
&& character != "&"
&& character != "!"
&& character != "\""
&& character != "'"
}
func hasNodeOnSameLine(afterCurrentPositionIn reader: PureYAML.Parsing.Reader) -> Bool {
var offset = 0
while reader.peek(offset: offset) == " " {
offset += 1
}
guard let character = reader.peek(offset: offset) else {
return false
}
return !isLineBreak(character) && character != "#"
}
func isFlowDelimiter(_ character: Character) -> Bool {
character == ","
|| character == "["
|| character == "]"
|| character == "{"
|| character == "}"
}
func isBlockScalarHeaderCharacter(_ character: Character) -> Bool {
character == "+"
|| character == "-"
|| character.isNumber
}
func trimTrailingSpaces(_ value: String) -> String {
var end = value.endIndex
while end > value.startIndex {
let previous = value.index(before: end)
guard value[previous] == " " else {
break
}
end = previous
}
return String(value[..<end])
}
func scanPlainScalarContinuations(
after firstLine: String,
firstLineIndent: Int,
state: inout State,
) -> String {
var value = firstLine
while true {
var probe = state.reader
guard isLineBreak(probe.peek()) else {
return value
}
probe.advance()
let blankLineCount = consumeBlankLines(&probe)
_ = probe.consume { character in
character == " "
}
let indentationWidth = probe.mark.column - 1
guard !probe.isAtEnd, !isLineBreak(probe.peek()), probe.peek() != "#" else {
return value
}
// A plain scalar never spans a document marker (`---` / `...`); stop the continuation at
// one so it begins (or ends) the document rather than folding into the scalar's value.
if indentationWidth == 0, isAtDocumentMarker(probe) {
return value
}
let baseIndentation = plainScalarContinuationBaseIndentation(state: state, firstLineIndent: firstLineIndent)
guard indentationWidth > baseIndentation else {
return value
}
if isBlockEntry(probe), state.validImplicitIndentation.contains(indentationWidth) {
return value
}
var continuationState = state
continuationState.reader = probe
let continuation = scanPlainScalarContinuationLine(&continuationState)
if isAtMappingValueIndicator(state: continuationState) {
// A continuation line ending at `:` is normally a new implicit key, so the scalar
// stops before it. Two cases keep folding instead, with the `:` left to be scanned as
// the value indicator: a block explicit key (`? foo` then `bar :`), where the line is
// still the key's content, and any flow scalar, since a flow key may span lines
// (`{ multi`/`line: v }`) where a block implicit key may not.
let foldsAcrossMappingValue = state.flowDepth > 0 || state.pendingExplicitKey
guard foldsAcrossMappingValue else {
return value
}
state = continuationState
value += blankLineCount == 0 ? " " : String(repeating: "\n", count: blankLineCount)
value += continuation
return value
}
state = continuationState
if blankLineCount == 0 {
value += " "
} else {
value += String(repeating: "\n", count: blankLineCount)
}
value += continuation
}
}
/// Advance `probe` over a run of blank lines (lines that are empty or only spaces), returning
/// how many were consumed; the probe is left at the start of the next non-blank line.
func consumeBlankLines(_ probe: inout PureYAML.Parsing.Reader) -> Int {
var count = 0
while true {
var blankProbe = probe
// A line of only white space is empty for folding, including one that holds a tab: a tab
// is not indentation, but a whitespace-only line is still blank, so consume it here.
_ = blankProbe.consume { character in
character == " " || character == "\t"
}
guard isLineBreak(blankProbe.peek()) else {
break
}
blankProbe.advance()
probe = blankProbe
count += 1
}
return count
}
/// Whether the reader is positioned at a document marker (`---` or `...`) followed by an
/// indicator boundary, i.e. the marker that starts or ends a document.
func isAtDocumentMarker(_ reader: PureYAML.Parsing.Reader) -> Bool {
(startsWith("---", reader) || startsWith("...", reader)) && isIndicatorBoundary(reader.peek(offset: 3))
}
func plainScalarContinuationBaseIndentation(state: State, firstLineIndent: Int) -> Int {
// When the scalar begins exactly at the current indentation top, it is a block value on its
// own line, so its own indent was pushed onto the stack; its continuation lines sit at that
// same indent, and the level they must exceed is the enclosing block's: the indent below on
// the stack, or -1 at the document root, where nothing encloses the scalar and a
// continuation may sit at column 1. For an inline value the top of the stack already is the
// enclosing block's indent, so it is used directly.
if state.indentation.last == firstLineIndent {
return state.indentation.count >= 2 ? state.indentation[state.indentation.count - 2] : -1
}
return state.indentation.last ?? 0
}
func scanPlainScalarContinuationLine(_ state: inout State) -> String {
// Leading white space beyond the line's indentation folds away in a plain scalar. A tab is
// not indentation, so the indentation scan leaves it here; skip it (and any spaces after it)
// so a continuation line like `\t3rd` joins as `3rd`, not `\t3rd`.
while let character = state.reader.peek(), character == " " || character == "\t" {
state.reader.advance()
}
var value = ""
while let character = state.reader.peek(), !isLineBreak(character) {
if character == " ", state.reader.peek(offset: 1) == "#" {
break
}
if character == "#", value.isEmpty || value.last == " " {
break
}
if character == ":", shouldEndPlainScalarAtMappingValue(state: state) {
break
}
if state.flowDepth > 0, isFlowDelimiter(character) {
break
}
value.append(character)
state.reader.advance()
}
return trimTrailingSpaces(value)
}
func skipLine(_ state: inout State) {
_ = state.reader.consume { character in
!isLineBreak(character)
}
}
func shouldEndPlainScalar(
_ character: Character,
state: State,
accumulated: String,
) -> Bool {
if isLineBreak(character) {
return true
}
// A comment opens after white space, a tab as well as a space, so a `#` preceded by either
// ends the plain scalar: `d\t#X` is the value `d` with a trailing comment.
if character == " " || character == "\t", state.reader.peek(offset: 1) == "#" {
return true
}
if character == "#", accumulated.isEmpty || accumulated.last == " " || accumulated.last == "\t" {
return true
}
if character == ":", shouldEndPlainScalarAtMappingValue(state: state) {
return true
}
if state.flowDepth > 0, isFlowDelimiter(character) {
return true
}
return false
}
func shouldEndPlainScalarAtMappingValue(state: State) -> Bool {
if state.flowDepth > 0 {
return state.isExpectingFlowMappingKey
|| isMappingValueBoundary(state.reader.peek(offset: 1))
}
return isBlockMappingValueBoundary(state.reader.peek(offset: 1))
}
func isAtMappingValueIndicator(state: State) -> Bool {
state.reader.peek() == ":" && shouldEndPlainScalarAtMappingValue(state: state)
}
/// Whether a mapping-value indicator (`: ` or `:` at line end) follows, allowing spaces before
/// the colon. Used after a quoted scalar, where the reader stops at the closing quote and any
/// separating spaces remain, unlike a plain scalar that ends at the colon itself.
func isAtMappingValueIndicatorAfterSpaces(state: State) -> Bool {
var offset = 0
while state.reader.peek(offset: offset) == " " {
offset += 1
}
return state.reader.peek(offset: offset) == ":"
&& isBlockMappingValueBoundary(state.reader.peek(offset: offset + 1))
}
@discardableResult
func appendMappingKeyIfNeeded(
start: PureYAML.Parsing.Mark,
state: inout State,
) -> Bool {
guard isAtMappingValueIndicator(state: state), canSynthesizeImplicitBlockKey(state) else {
return false
}
state.append(.mappingKey, mark: start, endMark: start)
return true
}
/// Whether a scalar followed by `:` may start an implicit mapping key here. In block style a
/// line holds at most one mapping pair, so once a block-level `:` has appeared on the line a
/// further key would form a forbidden inline nested mapping; in flow style several pairs per
/// line are allowed, so that restriction does not apply. In flow a scalar that is the content of
/// an open explicit key (`?`) is the key itself, not a new implicit key.
/// Records the implicit indentation levels a block entry with an inline node introduces: the
/// node's own indent (one past the dash), and the dash's indent as a sibling level so an inline
/// item's plain value stops folding at a later entry that returns to it (`: -␣b` then ` - - c`).
/// The sibling level is skipped under a pending explicit key (`? - x`), whose sequence key drives
/// implicit indentation itself.
func recordBlockEntryImplicitIndentation(start: PureYAML.Parsing.Mark, state: inout State) {
guard hasNodeOnSameLine(afterCurrentPositionIn: state.reader) else {
return
}
state.validImplicitIndentation.insert(start.column + 1)
if !state.pendingExplicitKey {
state.validImplicitIndentation.insert(start.column - 1)
}
}
func canSynthesizeImplicitBlockKey(_ state: State) -> Bool {
if state.flowDepth > 0 {
return state.flowContexts.last?.hasExplicitKey != true
}
return !state.sawMappingValueOnLine
}
/// A `:` with no node before it on the line is an implicit empty mapping key, unless it is the
/// value indicator of a pending explicit `?` key. This covers both a bare `: value` and a line
/// whose only prior content is a node property (`&a : value`, `!!str : value`): the property
/// decorates the empty key, which the preceding property tokens bind to. Synthesizes the empty
/// key and its node so the block mapping machinery treats the pair uniformly.
func synthesizeNullMappingKeyIfNeeded(
_ character: Character,
hadNonPropertyContentOnLine: Bool,
state: inout State,
) {
guard character == ":", !hadNonPropertyContentOnLine, state.flowDepth == 0,
!state.pendingExplicitKey, isAtMappingValueIndicator(state: state)
else {
return
}
let mark = state.reader.mark
state.append(.mappingKey, mark: mark, endMark: mark)
state.append(.scalar(value: "", style: .plain), mark: mark, endMark: mark)
}
}