wishthis/node_modules/yamljs/src/Parser.coffee
2022-01-21 09:28:41 +01:00

651 lines
25 KiB
CoffeeScript

Inline = require './Inline'
Pattern = require './Pattern'
Utils = require './Utils'
ParseException = require './Exception/ParseException'
ParseMore = require './Exception/ParseMore'
# Parser parses YAML strings to convert them to JavaScript objects.
#
class Parser
# Pre-compiled patterns
#
PATTERN_FOLDED_SCALAR_ALL: new Pattern '^(?:(?<type>![^\\|>]*)\\s+)?(?<separator>\\||>)(?<modifiers>\\+|\\-|\\d+|\\+\\d+|\\-\\d+|\\d+\\+|\\d+\\-)?(?<comments> +#.*)?$'
PATTERN_FOLDED_SCALAR_END: new Pattern '(?<separator>\\||>)(?<modifiers>\\+|\\-|\\d+|\\+\\d+|\\-\\d+|\\d+\\+|\\d+\\-)?(?<comments> +#.*)?$'
PATTERN_SEQUENCE_ITEM: new Pattern '^\\-((?<leadspaces>\\s+)(?<value>.+?))?\\s*$'
PATTERN_ANCHOR_VALUE: new Pattern '^&(?<ref>[^ ]+) *(?<value>.*)'
PATTERN_COMPACT_NOTATION: new Pattern '^(?<key>'+Inline.REGEX_QUOTED_STRING+'|[^ \'"\\{\\[].*?) *\\:(\\s+(?<value>.+?))?\\s*$'
PATTERN_MAPPING_ITEM: new Pattern '^(?<key>'+Inline.REGEX_QUOTED_STRING+'|[^ \'"\\[\\{].*?) *\\:(\\s+(?<value>.+?))?\\s*$'
PATTERN_DECIMAL: new Pattern '\\d+'
PATTERN_INDENT_SPACES: new Pattern '^ +'
PATTERN_TRAILING_LINES: new Pattern '(\n*)$'
PATTERN_YAML_HEADER: new Pattern '^\\%YAML[: ][\\d\\.]+.*\n', 'm'
PATTERN_LEADING_COMMENTS: new Pattern '^(\\#.*?\n)+', 'm'
PATTERN_DOCUMENT_MARKER_START: new Pattern '^\\-\\-\\-.*?\n', 'm'
PATTERN_DOCUMENT_MARKER_END: new Pattern '^\\.\\.\\.\\s*$', 'm'
PATTERN_FOLDED_SCALAR_BY_INDENTATION: {}
# Context types
#
CONTEXT_NONE: 0
CONTEXT_SEQUENCE: 1
CONTEXT_MAPPING: 2
# Constructor
#
# @param [Integer] offset The offset of YAML document (used for line numbers in error messages)
#
constructor: (@offset = 0) ->
@lines = []
@currentLineNb = -1
@currentLine = ''
@refs = {}
# Parses a YAML string to a JavaScript value.
#
# @param [String] value A YAML string
# @param [Boolean] exceptionOnInvalidType true if an exception must be thrown on invalid types (a JavaScript resource or object), false otherwise
# @param [Function] objectDecoder A function to deserialize custom objects, null otherwise
#
# @return [Object] A JavaScript value
#
# @throw [ParseException] If the YAML is not valid
#
parse: (value, exceptionOnInvalidType = false, objectDecoder = null) ->
@currentLineNb = -1
@currentLine = ''
@lines = @cleanup(value).split "\n"
data = null
context = @CONTEXT_NONE
allowOverwrite = false
while @moveToNextLine()
if @isCurrentLineEmpty()
continue
# Tab?
if "\t" is @currentLine[0]
throw new ParseException 'A YAML file cannot contain tabs as indentation.', @getRealCurrentLineNb() + 1, @currentLine
isRef = mergeNode = false
if values = @PATTERN_SEQUENCE_ITEM.exec @currentLine
if @CONTEXT_MAPPING is context
throw new ParseException 'You cannot define a sequence item when in a mapping'
context = @CONTEXT_SEQUENCE
data ?= []
if values.value? and matches = @PATTERN_ANCHOR_VALUE.exec values.value
isRef = matches.ref
values.value = matches.value
# Array
if not(values.value?) or '' is Utils.trim(values.value, ' ') or Utils.ltrim(values.value, ' ').indexOf('#') is 0
if @currentLineNb < @lines.length - 1 and not @isNextLineUnIndentedCollection()
c = @getRealCurrentLineNb() + 1
parser = new Parser c
parser.refs = @refs
data.push parser.parse(@getNextEmbedBlock(null, true), exceptionOnInvalidType, objectDecoder)
else
data.push null
else
if values.leadspaces?.length and matches = @PATTERN_COMPACT_NOTATION.exec values.value
# This is a compact notation element, add to next block and parse
c = @getRealCurrentLineNb()
parser = new Parser c
parser.refs = @refs
block = values.value
indent = @getCurrentLineIndentation()
if @isNextLineIndented(false)
block += "\n"+@getNextEmbedBlock(indent + values.leadspaces.length + 1, true)
data.push parser.parse block, exceptionOnInvalidType, objectDecoder
else
data.push @parseValue values.value, exceptionOnInvalidType, objectDecoder
else if (values = @PATTERN_MAPPING_ITEM.exec @currentLine) and values.key.indexOf(' #') is -1
if @CONTEXT_SEQUENCE is context
throw new ParseException 'You cannot define a mapping item when in a sequence'
context = @CONTEXT_MAPPING
data ?= {}
# Force correct settings
Inline.configure exceptionOnInvalidType, objectDecoder
try
key = Inline.parseScalar values.key
catch e
e.parsedLine = @getRealCurrentLineNb() + 1
e.snippet = @currentLine
throw e
if '<<' is key
mergeNode = true
allowOverwrite = true
if values.value?.indexOf('*') is 0
refName = values.value[1..]
unless @refs[refName]?
throw new ParseException 'Reference "'+refName+'" does not exist.', @getRealCurrentLineNb() + 1, @currentLine
refValue = @refs[refName]
if typeof refValue isnt 'object'
throw new ParseException 'YAML merge keys used with a scalar value instead of an object.', @getRealCurrentLineNb() + 1, @currentLine
if refValue instanceof Array
# Merge array with object
for value, i in refValue
data[String(i)] ?= value
else
# Merge objects
for key, value of refValue
data[key] ?= value
else
if values.value? and values.value isnt ''
value = values.value
else
value = @getNextEmbedBlock()
c = @getRealCurrentLineNb() + 1
parser = new Parser c
parser.refs = @refs
parsed = parser.parse value, exceptionOnInvalidType
unless typeof parsed is 'object'
throw new ParseException 'YAML merge keys used with a scalar value instead of an object.', @getRealCurrentLineNb() + 1, @currentLine
if parsed instanceof Array
# If the value associated with the merge key is a sequence, then this sequence is expected to contain mapping nodes
# and each of these nodes is merged in turn according to its order in the sequence. Keys in mapping nodes earlier
# in the sequence override keys specified in later mapping nodes.
for parsedItem in parsed
unless typeof parsedItem is 'object'
throw new ParseException 'Merge items must be objects.', @getRealCurrentLineNb() + 1, parsedItem
if parsedItem instanceof Array
# Merge array with object
for value, i in parsedItem
k = String(i)
unless data.hasOwnProperty(k)
data[k] = value
else
# Merge objects
for key, value of parsedItem
unless data.hasOwnProperty(key)
data[key] = value
else
# If the value associated with the key is a single mapping node, each of its key/value pairs is inserted into the
# current mapping, unless the key already exists in it.
for key, value of parsed
unless data.hasOwnProperty(key)
data[key] = value
else if values.value? and matches = @PATTERN_ANCHOR_VALUE.exec values.value
isRef = matches.ref
values.value = matches.value
if mergeNode
# Merge keys
else if not(values.value?) or '' is Utils.trim(values.value, ' ') or Utils.ltrim(values.value, ' ').indexOf('#') is 0
# Hash
# if next line is less indented or equal, then it means that the current value is null
if not(@isNextLineIndented()) and not(@isNextLineUnIndentedCollection())
# Spec: Keys MUST be unique; first one wins.
# But overwriting is allowed when a merge node is used in current block.
if allowOverwrite or data[key] is undefined
data[key] = null
else
c = @getRealCurrentLineNb() + 1
parser = new Parser c
parser.refs = @refs
val = parser.parse @getNextEmbedBlock(), exceptionOnInvalidType, objectDecoder
# Spec: Keys MUST be unique; first one wins.
# But overwriting is allowed when a merge node is used in current block.
if allowOverwrite or data[key] is undefined
data[key] = val
else
val = @parseValue values.value, exceptionOnInvalidType, objectDecoder
# Spec: Keys MUST be unique; first one wins.
# But overwriting is allowed when a merge node is used in current block.
if allowOverwrite or data[key] is undefined
data[key] = val
else
# 1-liner optionally followed by newline
lineCount = @lines.length
if 1 is lineCount or (2 is lineCount and Utils.isEmpty(@lines[1]))
try
value = Inline.parse @lines[0], exceptionOnInvalidType, objectDecoder
catch e
e.parsedLine = @getRealCurrentLineNb() + 1
e.snippet = @currentLine
throw e
if typeof value is 'object'
if value instanceof Array
first = value[0]
else
for key of value
first = value[key]
break
if typeof first is 'string' and first.indexOf('*') is 0
data = []
for alias in value
data.push @refs[alias[1..]]
value = data
return value
else if Utils.ltrim(value).charAt(0) in ['[', '{']
try
return Inline.parse value, exceptionOnInvalidType, objectDecoder
catch e
e.parsedLine = @getRealCurrentLineNb() + 1
e.snippet = @currentLine
throw e
throw new ParseException 'Unable to parse.', @getRealCurrentLineNb() + 1, @currentLine
if isRef
if data instanceof Array
@refs[isRef] = data[data.length-1]
else
lastKey = null
for key of data
lastKey = key
@refs[isRef] = data[lastKey]
if Utils.isEmpty(data)
return null
else
return data
# Returns the current line number (takes the offset into account).
#
# @return [Integer] The current line number
#
getRealCurrentLineNb: ->
return @currentLineNb + @offset
# Returns the current line indentation.
#
# @return [Integer] The current line indentation
#
getCurrentLineIndentation: ->
return @currentLine.length - Utils.ltrim(@currentLine, ' ').length
# Returns the next embed block of YAML.
#
# @param [Integer] indentation The indent level at which the block is to be read, or null for default
#
# @return [String] A YAML string
#
# @throw [ParseException] When indentation problem are detected
#
getNextEmbedBlock: (indentation = null, includeUnindentedCollection = false) ->
@moveToNextLine()
if not indentation?
newIndent = @getCurrentLineIndentation()
unindentedEmbedBlock = @isStringUnIndentedCollectionItem @currentLine
if not(@isCurrentLineEmpty()) and 0 is newIndent and not(unindentedEmbedBlock)
throw new ParseException 'Indentation problem.', @getRealCurrentLineNb() + 1, @currentLine
else
newIndent = indentation
data = [@currentLine[newIndent..]]
unless includeUnindentedCollection
isItUnindentedCollection = @isStringUnIndentedCollectionItem @currentLine
# Comments must not be removed inside a string block (ie. after a line ending with "|")
# They must not be removed inside a sub-embedded block as well
removeCommentsPattern = @PATTERN_FOLDED_SCALAR_END
removeComments = not removeCommentsPattern.test @currentLine
while @moveToNextLine()
indent = @getCurrentLineIndentation()
if indent is newIndent
removeComments = not removeCommentsPattern.test @currentLine
if removeComments and @isCurrentLineComment()
continue
if @isCurrentLineBlank()
data.push @currentLine[newIndent..]
continue
if isItUnindentedCollection and not @isStringUnIndentedCollectionItem(@currentLine) and indent is newIndent
@moveToPreviousLine()
break
if indent >= newIndent
data.push @currentLine[newIndent..]
else if Utils.ltrim(@currentLine).charAt(0) is '#'
# Don't add line with comments
else if 0 is indent
@moveToPreviousLine()
break
else
throw new ParseException 'Indentation problem.', @getRealCurrentLineNb() + 1, @currentLine
return data.join "\n"
# Moves the parser to the next line.
#
# @return [Boolean]
#
moveToNextLine: ->
if @currentLineNb >= @lines.length - 1
return false
@currentLine = @lines[++@currentLineNb];
return true
# Moves the parser to the previous line.
#
moveToPreviousLine: ->
@currentLine = @lines[--@currentLineNb]
return
# Parses a YAML value.
#
# @param [String] value A YAML value
# @param [Boolean] exceptionOnInvalidType true if an exception must be thrown on invalid types false otherwise
# @param [Function] objectDecoder A function to deserialize custom objects, null otherwise
#
# @return [Object] A JavaScript value
#
# @throw [ParseException] When reference does not exist
#
parseValue: (value, exceptionOnInvalidType, objectDecoder) ->
if 0 is value.indexOf('*')
pos = value.indexOf '#'
if pos isnt -1
value = value.substr(1, pos-2)
else
value = value[1..]
if @refs[value] is undefined
throw new ParseException 'Reference "'+value+'" does not exist.', @currentLine
return @refs[value]
if matches = @PATTERN_FOLDED_SCALAR_ALL.exec value
modifiers = matches.modifiers ? ''
foldedIndent = Math.abs(parseInt(modifiers))
if isNaN(foldedIndent) then foldedIndent = 0
val = @parseFoldedScalar matches.separator, @PATTERN_DECIMAL.replace(modifiers, ''), foldedIndent
if matches.type?
# Force correct settings
Inline.configure exceptionOnInvalidType, objectDecoder
return Inline.parseScalar matches.type+' '+val
else
return val
# Value can be multiline compact sequence or mapping or string
if value.charAt(0) in ['[', '{', '"', "'"]
while true
try
return Inline.parse value, exceptionOnInvalidType, objectDecoder
catch e
if e instanceof ParseMore and @moveToNextLine()
value += "\n" + Utils.trim(@currentLine, ' ')
else
e.parsedLine = @getRealCurrentLineNb() + 1
e.snippet = @currentLine
throw e
else
if @isNextLineIndented()
value += "\n" + @getNextEmbedBlock()
return Inline.parse value, exceptionOnInvalidType, objectDecoder
return
# Parses a folded scalar.
#
# @param [String] separator The separator that was used to begin this folded scalar (| or >)
# @param [String] indicator The indicator that was used to begin this folded scalar (+ or -)
# @param [Integer] indentation The indentation that was used to begin this folded scalar
#
# @return [String] The text value
#
parseFoldedScalar: (separator, indicator = '', indentation = 0) ->
notEOF = @moveToNextLine()
if not notEOF
return ''
isCurrentLineBlank = @isCurrentLineBlank()
text = ''
# Leading blank lines are consumed before determining indentation
while notEOF and isCurrentLineBlank
# newline only if not EOF
if notEOF = @moveToNextLine()
text += "\n"
isCurrentLineBlank = @isCurrentLineBlank()
# Determine indentation if not specified
if 0 is indentation
if matches = @PATTERN_INDENT_SPACES.exec @currentLine
indentation = matches[0].length
if indentation > 0
pattern = @PATTERN_FOLDED_SCALAR_BY_INDENTATION[indentation]
unless pattern?
pattern = new Pattern '^ {'+indentation+'}(.*)$'
Parser::PATTERN_FOLDED_SCALAR_BY_INDENTATION[indentation] = pattern
while notEOF and (isCurrentLineBlank or matches = pattern.exec @currentLine)
if isCurrentLineBlank
text += @currentLine[indentation..]
else
text += matches[1]
# newline only if not EOF
if notEOF = @moveToNextLine()
text += "\n"
isCurrentLineBlank = @isCurrentLineBlank()
else if notEOF
text += "\n"
if notEOF
@moveToPreviousLine()
# Remove line breaks of each lines except the empty and more indented ones
if '>' is separator
newText = ''
for line in text.split "\n"
if line.length is 0 or line.charAt(0) is ' '
newText = Utils.rtrim(newText, ' ') + line + "\n"
else
newText += line + ' '
text = newText
if '+' isnt indicator
# Remove any extra space or new line as we are adding them after
text = Utils.rtrim(text)
# Deal with trailing newlines as indicated
if '' is indicator
text = @PATTERN_TRAILING_LINES.replace text, "\n"
else if '-' is indicator
text = @PATTERN_TRAILING_LINES.replace text, ''
return text
# Returns true if the next line is indented.
#
# @return [Boolean] Returns true if the next line is indented, false otherwise
#
isNextLineIndented: (ignoreComments = true) ->
currentIndentation = @getCurrentLineIndentation()
EOF = not @moveToNextLine()
if ignoreComments
while not(EOF) and @isCurrentLineEmpty()
EOF = not @moveToNextLine()
else
while not(EOF) and @isCurrentLineBlank()
EOF = not @moveToNextLine()
if EOF
return false
ret = false
if @getCurrentLineIndentation() > currentIndentation
ret = true
@moveToPreviousLine()
return ret
# Returns true if the current line is blank or if it is a comment line.
#
# @return [Boolean] Returns true if the current line is empty or if it is a comment line, false otherwise
#
isCurrentLineEmpty: ->
trimmedLine = Utils.trim(@currentLine, ' ')
return trimmedLine.length is 0 or trimmedLine.charAt(0) is '#'
# Returns true if the current line is blank.
#
# @return [Boolean] Returns true if the current line is blank, false otherwise
#
isCurrentLineBlank: ->
return '' is Utils.trim(@currentLine, ' ')
# Returns true if the current line is a comment line.
#
# @return [Boolean] Returns true if the current line is a comment line, false otherwise
#
isCurrentLineComment: ->
# Checking explicitly the first char of the trim is faster than loops or strpos
ltrimmedLine = Utils.ltrim(@currentLine, ' ')
return ltrimmedLine.charAt(0) is '#'
# Cleanups a YAML string to be parsed.
#
# @param [String] value The input YAML string
#
# @return [String] A cleaned up YAML string
#
cleanup: (value) ->
if value.indexOf("\r") isnt -1
value = value.split("\r\n").join("\n").split("\r").join("\n")
# Strip YAML header
count = 0
[value, count] = @PATTERN_YAML_HEADER.replaceAll value, ''
@offset += count
# Remove leading comments
[trimmedValue, count] = @PATTERN_LEADING_COMMENTS.replaceAll value, '', 1
if count is 1
# Items have been removed, update the offset
@offset += Utils.subStrCount(value, "\n") - Utils.subStrCount(trimmedValue, "\n")
value = trimmedValue
# Remove start of the document marker (---)
[trimmedValue, count] = @PATTERN_DOCUMENT_MARKER_START.replaceAll value, '', 1
if count is 1
# Items have been removed, update the offset
@offset += Utils.subStrCount(value, "\n") - Utils.subStrCount(trimmedValue, "\n")
value = trimmedValue
# Remove end of the document marker (...)
value = @PATTERN_DOCUMENT_MARKER_END.replace value, ''
# Ensure the block is not indented
lines = value.split("\n")
smallestIndent = -1
for line in lines
continue if Utils.trim(line, ' ').length == 0
indent = line.length - Utils.ltrim(line).length
if smallestIndent is -1 or indent < smallestIndent
smallestIndent = indent
if smallestIndent > 0
for line, i in lines
lines[i] = line[smallestIndent..]
value = lines.join("\n")
return value
# Returns true if the next line starts unindented collection
#
# @return [Boolean] Returns true if the next line starts unindented collection, false otherwise
#
isNextLineUnIndentedCollection: (currentIndentation = null) ->
currentIndentation ?= @getCurrentLineIndentation()
notEOF = @moveToNextLine()
while notEOF and @isCurrentLineEmpty()
notEOF = @moveToNextLine()
if false is notEOF
return false
ret = false
if @getCurrentLineIndentation() is currentIndentation and @isStringUnIndentedCollectionItem(@currentLine)
ret = true
@moveToPreviousLine()
return ret
# Returns true if the string is un-indented collection item
#
# @return [Boolean] Returns true if the string is un-indented collection item, false otherwise
#
isStringUnIndentedCollectionItem: ->
return @currentLine is '-' or @currentLine[0...2] is '- '
module.exports = Parser