Add first stage of a new single-file parser
This first stage consists of the general framework for the parser as a whole, a lexical analyzers to converts text into commands, and the semantic anlyzer to convert commands into a complete data structure representing the cumulative effect of running the commands.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
#
|
||||
# Crafting Guide - lexical_analyzer.coffee
|
||||
#
|
||||
# Copyright © 2014-2016 by Redwood Labs
|
||||
# All rights reserved.
|
||||
#
|
||||
|
||||
ParserCommand = require './parser_command'
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
module.exports = class LexicalAnalyzer
|
||||
|
||||
constructor: (fileName, rawText)->
|
||||
if not fileName? then throw new Error 'fileName is required'
|
||||
if not rawText? then throw new Error 'rawText is required'
|
||||
|
||||
@_commands = []
|
||||
@_fileName = fileName
|
||||
@_rawText = rawText
|
||||
@_lines = rawText.split '\n'
|
||||
@_lineIndex = -1
|
||||
@_lineNumber = 0
|
||||
|
||||
# Class Methods ################################################################################
|
||||
|
||||
@::COMMAND = /\ *([^:]*):?(.*)/
|
||||
|
||||
@::COMMENT = /([^\\]?)#.*/
|
||||
|
||||
@::FILE = /^#FILE +(.*)/
|
||||
|
||||
# Public Methods ###############################################################################
|
||||
|
||||
next: ->
|
||||
command = @peek()
|
||||
@_commands.shift()
|
||||
return command
|
||||
|
||||
peek: ->
|
||||
while @_commands.length is 0
|
||||
break if @_lineNumber > @_lines.length
|
||||
@_parseLine()
|
||||
|
||||
return null unless @_commands.length > 0
|
||||
return @_commands[0]
|
||||
|
||||
push: (command)->
|
||||
@_commands.unshift command
|
||||
|
||||
# Property Methods #############################################################################
|
||||
|
||||
getIsFinished: ->
|
||||
return @peek() is null
|
||||
|
||||
Object.defineProperties @prototype,
|
||||
isFinished: { get:@::getIsFinished }
|
||||
|
||||
# Private Methods ##############################################################################
|
||||
|
||||
_currentLine: ->
|
||||
return null if @_lineIndex >= @_lines.length
|
||||
return @_lines[@_lineIndex]
|
||||
|
||||
_nextLine: ->
|
||||
if @_lineIndex < @_lines.length
|
||||
while true
|
||||
@_lineIndex += 1
|
||||
@_lineNumber += 1
|
||||
break if @_lineIndex >= @_lines.length
|
||||
|
||||
line = @_lines[@_lineIndex]
|
||||
|
||||
fileMatch = line.match @FILE, '$1'
|
||||
if fileMatch?
|
||||
@_fileName = fileMatch[1]
|
||||
@_lineNumber = 0
|
||||
else
|
||||
line = line.replace @COMMENT, '$1'
|
||||
@_lines[@_lineIndex] = line
|
||||
|
||||
break if line.trim().length > 0
|
||||
|
||||
return @_currentLine()
|
||||
|
||||
_parseHereDoc: ->
|
||||
line = @_currentLine()
|
||||
hereDocIndex = line.indexOf '<<-'
|
||||
return [line, null] unless hereDocIndex isnt -1
|
||||
|
||||
hereDocStopText = line[hereDocIndex+3...line.length]
|
||||
line = line[0...hereDocIndex]
|
||||
|
||||
hereDocLines = []
|
||||
while true
|
||||
nextLine = @_nextLine()
|
||||
break unless nextLine?
|
||||
break if nextLine.trim() is hereDocStopText
|
||||
hereDocLines.push nextLine
|
||||
|
||||
shortestIndent = Number.MAX_VALUE
|
||||
for hereDocLine in hereDocLines
|
||||
continue if hereDocLine.trim().length is 0
|
||||
shortestIndent = Math.min hereDocLine.match(/( *).*/)[1].length, shortestIndent
|
||||
|
||||
for i in [0...hereDocLines.length]
|
||||
hereDocLines[i] = hereDocLines[i][shortestIndent..]
|
||||
|
||||
return [line, null] unless hereDocLines.length > 0
|
||||
return [line, hereDocLines.join('\n')]
|
||||
|
||||
_parseLine: ->
|
||||
line = @_nextLine()
|
||||
return unless line?
|
||||
|
||||
startingLineNumber = @_lineNumber
|
||||
[line, lineHereDoc] = @_parseHereDoc()
|
||||
|
||||
lineParts = (part.trim() for part in line.split(';'))
|
||||
for linePart, index in lineParts
|
||||
continue if linePart.length is 0
|
||||
|
||||
hereDoc = lineHereDoc if index is lineParts.length - 1
|
||||
|
||||
match = @COMMAND.exec linePart
|
||||
if not match? then throw new Error "Expected <command>: <args>, but found: \"#{linePart}\""
|
||||
|
||||
argText = if match[2] then match[2].trim() else ''
|
||||
args = (arg.trim() for arg in argText.split(','))
|
||||
args = (arg for arg in args when arg.length > 0)
|
||||
|
||||
if hereDoc?
|
||||
if argText.length > 0 then argText += ', '
|
||||
argText += hereDoc
|
||||
args.push hereDoc
|
||||
|
||||
@_commands.push new ParserCommand
|
||||
argText: argText
|
||||
args: args
|
||||
fileName: @_fileName
|
||||
hereDoc: hereDoc
|
||||
lineNumber: startingLineNumber
|
||||
name: match[1]
|
||||
@@ -0,0 +1,121 @@
|
||||
#
|
||||
# Crafting Guide - lexical_analyzer.test.coffee
|
||||
#
|
||||
# Copyright © 2014-2016 by Redwood Labs
|
||||
# All rights reserved.
|
||||
#
|
||||
|
||||
LexicalAnalyzer = require './lexical_analyzer'
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
lexer = null
|
||||
|
||||
createLexer = (text)->
|
||||
return new LexicalAnalyzer 'file.cg', text
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
describe 'lexical_analyzer.coffee', ->
|
||||
|
||||
describe 'a document with', ->
|
||||
|
||||
it 'no content returns null immediately', ->
|
||||
lexer = createLexer ''
|
||||
expect(lexer.next()).to.be.null
|
||||
|
||||
it 'only whitespace returns null immediately', ->
|
||||
lexer = createLexer '\n\n \n \n\n'
|
||||
expect(lexer.next()).to.be.null
|
||||
|
||||
it 'a single command returns only that command', ->
|
||||
lexer = createLexer 'alpha: bravo'
|
||||
lexer.next().name.should.equal 'alpha'
|
||||
expect(lexer.next()).to.be.null
|
||||
|
||||
it 'multiple commands on a single line returns all commands', ->
|
||||
lexer = createLexer 'alpha: bravo; charlie: delta; echo: foxtrot'
|
||||
lexer.next().name.should.equal 'alpha'
|
||||
lexer.next().name.should.equal 'charlie'
|
||||
lexer.next().name.should.equal 'echo'
|
||||
expect(lexer.next()).to.be.null
|
||||
|
||||
it 'multiple commands on separate lines returns all commands', ->
|
||||
lexer = createLexer 'alpha: bravo \n charlie: delta \n echo: foxtrot'
|
||||
lexer.next().name.should.equal 'alpha'
|
||||
lexer.next().name.should.equal 'charlie'
|
||||
lexer.next().name.should.equal 'echo'
|
||||
expect(lexer.next()).to.be.null
|
||||
|
||||
describe 'a command with', ->
|
||||
|
||||
it 'only a name has no arguments', ->
|
||||
command = createLexer('alpha:').next()
|
||||
command.argText.should.equal ''
|
||||
command.args.should.eql []
|
||||
|
||||
it 'one argument has only that argument', ->
|
||||
command = createLexer('alpha: bravo').next()
|
||||
command.argText.should.equal 'bravo'
|
||||
command.args.should.eql ['bravo']
|
||||
|
||||
it 'multiple arguments has all of them', ->
|
||||
command = createLexer('alpha: bravo, charlie, delta').next()
|
||||
command.argText.should.equal 'bravo, charlie, delta'
|
||||
command.args.should.eql ['bravo', 'charlie', 'delta']
|
||||
|
||||
it 'only a heredoc has it recorded in the right places', ->
|
||||
command = createLexer('alpha: <<-END\nbravo charlie\nEND').next()
|
||||
command.hereDoc.should.equal 'bravo charlie'
|
||||
command.args.should.eql ['bravo charlie']
|
||||
command.argText.should.equal 'bravo charlie'
|
||||
|
||||
it 'multiple args and a hereDoc is built correctly', ->
|
||||
command = createLexer('alpha: bravo, charlie <<-END\ndelta echo\nfoxtrot\nEND').next()
|
||||
command.hereDoc.should.equal 'delta echo\nfoxtrot'
|
||||
command.argText.should.equal 'bravo, charlie, delta echo\nfoxtrot'
|
||||
command.args.should.eql ['bravo', 'charlie', 'delta echo\nfoxtrot']
|
||||
|
||||
describe 'file name is assigned correctly when', ->
|
||||
|
||||
it 'no file markers are given', ->
|
||||
lexer = createLexer 'alpha: bravo\ncharlie: delta'
|
||||
lexer.next().fileName.should.equal 'file.cg'
|
||||
lexer.next().fileName.should.equal 'file.cg'
|
||||
|
||||
it 'commands appear before the first file marker', ->
|
||||
lexer = createLexer 'alpha: bravo\n#FILE file2.cg\ncharlie: delta'
|
||||
lexer.next().fileName.should.equal 'file.cg'
|
||||
lexer.next().fileName.should.equal 'file2.cg'
|
||||
|
||||
it 'multiple file markers are used', ->
|
||||
lexer = createLexer '#FILE file1.cg\na: b\n#FILE file2.cg\nc: d\n#FILE file3.cg\ne: f'
|
||||
lexer.next().fileName.should.equal 'file1.cg'
|
||||
lexer.next().fileName.should.equal 'file2.cg'
|
||||
lexer.next().fileName.should.equal 'file3.cg'
|
||||
|
||||
describe 'line numbers are assigned correctly when', ->
|
||||
|
||||
it 'commands are listed one after another', ->
|
||||
lexer = createLexer 'alpha: bravo\ncharlie: delta\necho: foxtrot'
|
||||
lexer.next().lineNumber.should.equal 1
|
||||
lexer.next().lineNumber.should.equal 2
|
||||
lexer.next().lineNumber.should.equal 3
|
||||
|
||||
it 'commands are separated by blank lines and comments', ->
|
||||
lexer = createLexer '# foo\n\nalpha: bravo\n\ncharlie: delta\n\n# bar\n\necho: foxtrot'
|
||||
lexer.next().lineNumber.should.equal 3
|
||||
lexer.next().lineNumber.should.equal 5
|
||||
lexer.next().lineNumber.should.equal 9
|
||||
|
||||
it 'commands include hereDocs', ->
|
||||
lexer = createLexer 'alpha: <<-END\nbravo charlie\nEND\ndelta: \n\necho: '
|
||||
lexer.next().lineNumber.should.equal 1
|
||||
lexer.next().lineNumber.should.equal 4
|
||||
lexer.next().lineNumber.should.equal 6
|
||||
|
||||
it 'file markers are used', ->
|
||||
lexer = createLexer '#FILE file1.cg\nalpha: \n#FILE file2.cg\n\nbravo: \n\n#FILE file3.cg\n\n\ncharlie: '
|
||||
lexer.next().lineNumber.should.equal 1
|
||||
lexer.next().lineNumber.should.equal 2
|
||||
lexer.next().lineNumber.should.equal 3
|
||||
@@ -0,0 +1,66 @@
|
||||
#
|
||||
# Crafting Guide - parser.coffee
|
||||
#
|
||||
# Copyright © 2014-2016 by Redwood Labs
|
||||
# All rights reserved.
|
||||
#
|
||||
|
||||
LexicalAnalyzer = require './lexical_analyzer'
|
||||
ParserState = require './parser_state'
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
module.exports = class Parser
|
||||
|
||||
constructor: (state, fileName, rawText)->
|
||||
if not state? then throw new Error 'state is required'
|
||||
|
||||
@_extensions = []
|
||||
@_fileName = fileName
|
||||
@_lexer = new LexicalAnalyzer fileName, rawText
|
||||
@_state = state
|
||||
|
||||
@_loadSchema()
|
||||
|
||||
# Public Methods ###############################################################################
|
||||
|
||||
processNextCommand: w.lift ->
|
||||
command = @_lexer.next()
|
||||
return unless command?
|
||||
|
||||
for extension in @_extensions
|
||||
continue unless extension.accepts command
|
||||
extension.execute command
|
||||
return
|
||||
|
||||
throw new Error "unknown command: #{command.name}"
|
||||
|
||||
# Property Methods #############################################################################
|
||||
|
||||
getIsFinished: ->
|
||||
return @_lexer.isFinished
|
||||
|
||||
getState: ->
|
||||
return @_state
|
||||
|
||||
Object.defineProperties @prototype,
|
||||
isFinished: { get:@::getIsFinished }
|
||||
state: { get:@::getState }
|
||||
|
||||
# Private Methods ##############################################################################
|
||||
|
||||
_loadSchema: ->
|
||||
command = @_lexer.next()
|
||||
if command.name isnt 'schema' then throw new Error "#{@_fileName} must start with the \"schema\" command"
|
||||
|
||||
schema = command.argText
|
||||
if schema is '1'
|
||||
@_use new require './parser_extensions/current_pe_v1'
|
||||
@_use new require './parser_extensions/mod_pe_v1'
|
||||
@_use new require './parser_extensions/mod_version_pe_v1'
|
||||
else
|
||||
throw new Error "unknown schema version: #{schema}"
|
||||
|
||||
_use: (parserExtension)->
|
||||
parserExtension.state = @_state
|
||||
@_extensions.push parserExtension
|
||||
@@ -0,0 +1,33 @@
|
||||
#
|
||||
# Crafting Guide - parser_command.coffee
|
||||
#
|
||||
# Copyright © 2014-2016 by Redwood Labs
|
||||
# All rights reserved.
|
||||
#
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
module.exports = class ParserCommand
|
||||
|
||||
constructor: (options={})->
|
||||
if not options.name? then throw 'options.name is required'
|
||||
if not options.fileName? then throw new 'options.fileName is required'
|
||||
if not options.lineNumber? then throw 'options.lineNumber is required'
|
||||
|
||||
{@name, @fileName, @lineNumber, @argText, @args, @hereDoc} = options
|
||||
|
||||
# Property Methods #############################################################################
|
||||
|
||||
getLocation: ->
|
||||
return "#{@fileName}:#{@lineNumber}"
|
||||
|
||||
Object.defineProperties @prototype,
|
||||
location: { get:@::getLocation }
|
||||
|
||||
# Object Overrides #############################################################################
|
||||
|
||||
toString: ->
|
||||
result = "#{@fileName}:#{@lineNumber} #{@name}: #{@argText}"
|
||||
if @hereDoc?
|
||||
result += " <<- END\n#{@hereDoc}\nEND"
|
||||
return result
|
||||
@@ -0,0 +1,105 @@
|
||||
#
|
||||
# Crafting Guide - parser_extension.coffee
|
||||
#
|
||||
# Copyright © 2014-2016 by Redwood Labs
|
||||
# All rights reserved.
|
||||
#
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
module.exports = class ParserExtension
|
||||
|
||||
constructor: (state)->
|
||||
@state = state
|
||||
|
||||
# Public Methods ###############################################################################
|
||||
|
||||
accepts: (command)->
|
||||
return @_findCommandMethod(command)?
|
||||
|
||||
execute: w.lift (command)->
|
||||
if not @_state? then throw new Error '@state must be defined before executing commands'
|
||||
|
||||
method = @_findCommandMethod command
|
||||
method.call this, command
|
||||
|
||||
# Error Checking Helpers #######################################################################
|
||||
|
||||
alreadyExists: (command, type, id)->
|
||||
obj = @state.get(type, id)
|
||||
if obj?
|
||||
@state.addError command, "a #{type} called #{id} has already been declared"
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
duplicateField: (command, obj, field)->
|
||||
if obj[field]?
|
||||
@state.addError command, "duplicate #{field}"
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
missingCurrent: (command, type)->
|
||||
obj = @state.getCurrent type
|
||||
if not obj?
|
||||
@state.addError command, "you must declare a #{type} before using #{command.name}"
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
missingArgs: (command)->
|
||||
if not command.args or command.args.length is 0
|
||||
@state.addError command, "#{command.name} requires at least one item"
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
missingArgText: (command)->
|
||||
if command.argText.length is 0
|
||||
@state.addError command, "#{command.name} cannot be empty"
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
# Parsing Helpers ##############################################################################
|
||||
|
||||
parseBoolean: (command, text)->
|
||||
return null unless text?
|
||||
|
||||
switch text.toLowerCase()
|
||||
when "yes" then return true
|
||||
when "no" then return false
|
||||
|
||||
@state.addError command, "#{command.name} requires \"yes\" or \"no\""
|
||||
return null
|
||||
|
||||
parseInt: (command, text)->
|
||||
return null unless text?
|
||||
|
||||
value = Number.parseInt text
|
||||
if isNaN value
|
||||
@addError command, "#{command.name} requires a number"
|
||||
return null
|
||||
|
||||
return value
|
||||
|
||||
# Property Methods #############################################################################
|
||||
|
||||
getState: ->
|
||||
return @_state
|
||||
|
||||
setState: (state)->
|
||||
if not state? then throw new Error 'state cannot be undefined'
|
||||
@_state = state
|
||||
|
||||
Object.defineProperties @prototype,
|
||||
state: { get:@::getState, set:@::setState }
|
||||
|
||||
# Private Methods ##############################################################################
|
||||
|
||||
_findCommandMethod: (command)->
|
||||
methodName = "_command_#{command.name}"
|
||||
method = this[methodName]
|
||||
return null unless _.isFunction(method)
|
||||
return method
|
||||
@@ -0,0 +1,35 @@
|
||||
#
|
||||
# Crafting Guide - current_pe_v1.coffee
|
||||
#
|
||||
# Copyright © 2014-2016 by Redwood Labs
|
||||
# All rights reserved.
|
||||
#
|
||||
|
||||
ParserExtension = require '../parser_extension'
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
module.exports = class CurrentParserExtensionV1 extends ParserExtension
|
||||
|
||||
# Command Methods ##############################################################################
|
||||
|
||||
_command_documentationUrl: (command)->
|
||||
current = @state.getCurrent()
|
||||
return if @duplicateField command, current, 'documentationUrl'
|
||||
return if @missingArgText command
|
||||
|
||||
current.documentationUrl = command.argText
|
||||
|
||||
_command_description: (command)->
|
||||
current = @state.getCurrent()
|
||||
return if @duplicateField command, current, 'description'
|
||||
return if @missingArgText command
|
||||
|
||||
current.description = command.argText
|
||||
|
||||
_command_name: (command)->
|
||||
current = @state.getCurrent()
|
||||
return if @duplicateField command, current, 'name'
|
||||
return if @missingArgText command
|
||||
|
||||
current.name = command.argText
|
||||
@@ -0,0 +1,47 @@
|
||||
#
|
||||
# Crafting Guide - current_pe_v1.test.coffee
|
||||
#
|
||||
# Copyright © 2014-2016 by Redwood Labs
|
||||
# All rights reserved.
|
||||
#
|
||||
|
||||
CurrentParserExtensionV1 = require './current_pe_v1'
|
||||
ParserState = require '../parser_state'
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
parser = state = null
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
describe 'current_pe_v1.coffee', ->
|
||||
|
||||
beforeEach ->
|
||||
state = new ParserState
|
||||
parser = new CurrentParserExtensionV1 state
|
||||
|
||||
state.create {}, 'alpha'
|
||||
|
||||
describe 'documentationUrl', ->
|
||||
|
||||
it 'assigns to the current object', ->
|
||||
parser.execute name:'documentationUrl', argText:'bravo'
|
||||
.then ->
|
||||
state.getCurrent().documentationUrl.should.equal 'bravo'
|
||||
state.errors.should.eql []
|
||||
|
||||
describe 'description', ->
|
||||
|
||||
it 'assigns to the current object', ->
|
||||
parser.execute name:'description', argText:'bravo'
|
||||
.then ->
|
||||
state.getCurrent().description.should.equal 'bravo'
|
||||
state.errors.should.eql []
|
||||
|
||||
describe 'name', ->
|
||||
|
||||
it 'assigns to the current object', ->
|
||||
parser.execute name:'name', argText:'bravo'
|
||||
.then ->
|
||||
state.getCurrent().name.should.equal 'bravo'
|
||||
state.errors.should.eql []
|
||||
@@ -0,0 +1,40 @@
|
||||
#
|
||||
# Crafting Guide - item_pe_v1.coffee
|
||||
#
|
||||
# Copyright © 2014-2016 by Redwood Labs
|
||||
# All rights reserved.
|
||||
#
|
||||
|
||||
ParserExtension = require '../parser_extension'
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
module.exports = class ItemParserExtensionV1 extends ParserExtension
|
||||
|
||||
# Command Methods ##############################################################################
|
||||
|
||||
_command_gatherable: (command)->
|
||||
return if @missingArgText command
|
||||
|
||||
return if @missingCurrent command, 'item'
|
||||
|
||||
item = @state.getCurrent 'item'
|
||||
return if @duplicateField command, 'gatherable', item
|
||||
|
||||
gatherable = @parseBoolean command, command.argText
|
||||
return unless gatherable?
|
||||
|
||||
item.gatherable = gatherable
|
||||
|
||||
_command_item: (command)->
|
||||
return if @missingArgText command
|
||||
return if @missingCurrent command, 'modVersion'
|
||||
return if @alreadyExists command, 'item', command.argText
|
||||
|
||||
item = @state.create command, 'item', command.argText
|
||||
item.name = command.argText
|
||||
|
||||
group = @state.getCurrent 'itemGroup'
|
||||
if group? then item.group = group
|
||||
|
||||
item.modVersion = @state.getCurrent 'modVersion'
|
||||
@@ -0,0 +1,46 @@
|
||||
#
|
||||
# Crafting Guide - item_pe_v1.test.coffee
|
||||
#
|
||||
# Copyright © 2014-2016 by Redwood Labs
|
||||
# All rights reserved.
|
||||
#
|
||||
|
||||
ItemParserExtensionV1 = require './item_pe_v1'
|
||||
ParserState = require '../parser_state'
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
parser = state = null
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
describe 'item_pe_v1.coffee', ->
|
||||
|
||||
beforeEach ->
|
||||
state = new ParserState
|
||||
parser = new ItemParserExtensionV1 state
|
||||
|
||||
state.create {}, 'modVersion', 0
|
||||
|
||||
describe 'gatherable', ->
|
||||
|
||||
it 'assigns to the current item', ->
|
||||
state.create {}, 'item', 1
|
||||
|
||||
parser.execute name:'gatherable', argText:'yes'
|
||||
.then ->
|
||||
state.getCurrent('item').gatherable.should.be.true
|
||||
state.errors.should.eql []
|
||||
|
||||
describe 'item', ->
|
||||
|
||||
it 'creates a new item', ->
|
||||
state.create {}, 'itemGroup', 2
|
||||
|
||||
parser.execute name:'item', argText:'alpha'
|
||||
.then ->
|
||||
item = state.getCurrent 'item'
|
||||
item.id.should.equal 'alpha'
|
||||
item.name.should.equal 'alpha'
|
||||
item.modVersion.id.should.equal 0
|
||||
item.group.id.should.equal 2
|
||||
@@ -0,0 +1,47 @@
|
||||
#
|
||||
# Crafting Guide - mod_pe_v1.coffee
|
||||
#
|
||||
# Copyright © 2014-2016 by Redwood Labs
|
||||
# All rights reserved.
|
||||
#
|
||||
|
||||
ParserExtension = require '../parser_extension'
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
module.exports = class ModParserExtensionV1 extends ParserExtension
|
||||
|
||||
# Command Methods ##############################################################################
|
||||
|
||||
_command_author: (command)->
|
||||
return if @missingCurrent command, 'mod'
|
||||
|
||||
mod = @state.getCurrent 'mod'
|
||||
return if @duplicateField command, mod, 'author'
|
||||
return if @missingArgText command
|
||||
|
||||
mod.author = command.argText
|
||||
|
||||
_command_downloadUrl: (command)->
|
||||
return if @missingCurrent command, 'mod'
|
||||
|
||||
mod = @state.getCurrent 'mod'
|
||||
return if @duplicateField command, mod, 'downloadUrl'
|
||||
return if @missingArgText command
|
||||
|
||||
mod.downloadUrl = command.argText
|
||||
|
||||
_command_homePageUrl: (command)->
|
||||
return if @missingCurrent command, 'mod'
|
||||
|
||||
mod = @state.getCurrent 'mod'
|
||||
return if @duplicateField command, mod, 'homePageUrl'
|
||||
return if @missingArgText command
|
||||
|
||||
mod.homePageUrl = command.argText
|
||||
|
||||
_command_mod: (command)->
|
||||
return if @missingArgText command
|
||||
return if @alreadyExists command, 'mod', command.argText
|
||||
|
||||
@state.create command, 'mod', command.argText
|
||||
@@ -0,0 +1,55 @@
|
||||
#
|
||||
# Crafting Guide - mod_pe_v1.test.coffee
|
||||
#
|
||||
# Copyright © 2014-2016 by Redwood Labs
|
||||
# All rights reserved.
|
||||
#
|
||||
|
||||
ModParserExtensionV1 = require './mod_pe_v1'
|
||||
ParserState = require '../parser_state'
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
parser = state = null
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
describe 'mod_pe_v1.coffee', ->
|
||||
|
||||
beforeEach ->
|
||||
state = new ParserState
|
||||
parser = new ModParserExtensionV1 state
|
||||
|
||||
state.create {}, 'mod', 0
|
||||
|
||||
describe 'author', ->
|
||||
|
||||
it 'assigns to the current mod', ->
|
||||
parser.execute name:'author', argText:'alpha'
|
||||
.then ->
|
||||
state.getCurrent('mod').author.should.equal 'alpha'
|
||||
state.errors.should.eql []
|
||||
|
||||
describe 'downloadUrl', ->
|
||||
|
||||
it 'assigns to the current mod', ->
|
||||
parser.execute name:'downloadUrl', argText:'alpha'
|
||||
.then ->
|
||||
state.getCurrent('mod').downloadUrl.should.equal 'alpha'
|
||||
state.errors.should.eql []
|
||||
|
||||
describe 'homePageUrl', ->
|
||||
|
||||
it 'assigns to the current mod', ->
|
||||
parser.execute name:'homePageUrl', argText:'alpha'
|
||||
.then ->
|
||||
state.getCurrent('mod').homePageUrl.should.equal 'alpha'
|
||||
state.errors.should.eql []
|
||||
|
||||
describe 'mod', ->
|
||||
|
||||
it 'creates a new mod', ->
|
||||
parser.execute name:'mod', argText:'alpha'
|
||||
.then ->
|
||||
mod = state.getCurrent 'mod'
|
||||
mod.id.should.equal 'alpha'
|
||||
@@ -0,0 +1,28 @@
|
||||
#
|
||||
# Crafting Guide - mod_version_pe_v1.coffee
|
||||
#
|
||||
# Copyright © 2014-2016 by Redwood Labs
|
||||
# All rights reserved.
|
||||
#
|
||||
|
||||
ParserExtension = require '../parser_extension'
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
module.exports = class ModVersionParserExtensionV1 extends ParserExtension
|
||||
|
||||
# Command Methods ##############################################################################
|
||||
|
||||
_command_group: (command)->
|
||||
return if @missingArgText command
|
||||
return if @missingCurrent command, 'modVersion'
|
||||
|
||||
group = @state.create command, 'itemGroup', command.argText
|
||||
group.modVersion = @state.getCurrent 'modVersion'
|
||||
|
||||
_command_version: (command)->
|
||||
return if @missingArgText command
|
||||
return if @missingCurrent command, 'mod'
|
||||
|
||||
modVersion = @state.create command, 'modVersion', command.argText
|
||||
modVersion.mod = @state.getCurrent 'mod'
|
||||
@@ -0,0 +1,43 @@
|
||||
#
|
||||
# Crafting Guide - mod_version_pe_v1.test.coffee
|
||||
#
|
||||
# Copyright © 2014-2016 by Redwood Labs
|
||||
# All rights reserved.
|
||||
#
|
||||
|
||||
ModVersionParserExtensionV1 = require './mod_version_pe_v1'
|
||||
ParserState = require '../parser_state'
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
parser = state = null
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
describe 'mod_version_pe_v1.coffee', ->
|
||||
|
||||
beforeEach ->
|
||||
state = new ParserState
|
||||
parser = new ModVersionParserExtensionV1 state
|
||||
|
||||
state.create {}, 'mod', 0
|
||||
|
||||
describe 'group', ->
|
||||
|
||||
it 'creates a new item group', ->
|
||||
state.create {}, 'modVersion', 1
|
||||
|
||||
parser.execute name:'group', argText:'alpha'
|
||||
.then ->
|
||||
itemGroup = state.getCurrent('itemGroup')
|
||||
itemGroup.id.should.equal 'alpha'
|
||||
itemGroup.modVersion.id.should.equal 1
|
||||
|
||||
describe 'version', ->
|
||||
|
||||
it 'creates a new mod version', ->
|
||||
parser.execute name:'version', argText:'alpha'
|
||||
.then ->
|
||||
modVersion = state.getCurrent 'modVersion'
|
||||
modVersion.id.should.equal 'alpha'
|
||||
modVersion.mod.id.should.equal 0
|
||||
@@ -0,0 +1,96 @@
|
||||
#
|
||||
# Crafting Guide - recipe_pe_v1.coffee
|
||||
#
|
||||
# Copyright © 2014-2016 by Redwood Labs
|
||||
# All rights reserved.
|
||||
#
|
||||
|
||||
ParserExtension = require '../parser_extension'
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
module.exports = class RecipeParserExtensionV1 extends ParserExtension
|
||||
|
||||
@::PATTERN = /[0-9. ]+/
|
||||
|
||||
@::STACK = /^([0-9]+) +(.+)$/
|
||||
|
||||
# Error Checking Helpers #######################################################################
|
||||
|
||||
invalidPattern: (command)->
|
||||
match = command.argText.match @PATTERN
|
||||
if not match?
|
||||
@state.addError command, "invalid pattern: \"#{command.argText}\""
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
# Command Methods ##############################################################################
|
||||
|
||||
_command_extras: (command)->
|
||||
return if @missingCurrent command, 'recipe'
|
||||
return if @missingArgs command
|
||||
|
||||
recipe = @state.getCurrent 'recipe'
|
||||
return if @duplicateField command, 'extras', recipe
|
||||
|
||||
recipe.extras = command.args
|
||||
|
||||
_command_input: (command)->
|
||||
return if @missingCurrent command, 'recipe'
|
||||
return if @missingArgs command
|
||||
|
||||
recipe = @state.getCurrent 'recipe'
|
||||
return if @duplicateField command, 'input', recipe
|
||||
|
||||
recipe.input = (@_parseStack(arg) for arg in command.args)
|
||||
|
||||
_command_pattern: (command)->
|
||||
return if @missingCurrent command, 'recipe'
|
||||
return if @missingArgText command
|
||||
return if @invalidPattern command
|
||||
|
||||
recipe = @state.getCurrent 'recipe'
|
||||
return if @duplicateField command, 'pattern', recipe
|
||||
|
||||
recipe.pattern = command.argText
|
||||
|
||||
_command_recipe: (command)->
|
||||
return if @missingCurrent command, 'item'
|
||||
|
||||
recipe = @state.create command, 'recipe'
|
||||
recipe.item = @state.getCurrent 'item'
|
||||
|
||||
_command_quantity: (command)->
|
||||
return if @missingCurrent command, 'recipe'
|
||||
return if @missingArgText command
|
||||
|
||||
value = @parseInt command, command.argText
|
||||
return unless value?
|
||||
|
||||
recipe = @state.getCurrent 'recipe'
|
||||
return if @duplicateField command, 'quantity', recipe
|
||||
|
||||
recipe.quantity = value
|
||||
|
||||
_command_tools: (command)->
|
||||
return if @missingCurrent command, 'recipe'
|
||||
return if @missingArgs command
|
||||
|
||||
recipe = @state.getCurrent 'recipe'
|
||||
return if @duplicateField command, 'tools', recipe
|
||||
|
||||
recipe.tools = (@_parseStack(arg) for arg in command.args)
|
||||
|
||||
# Custom Parsers ###############################################################################
|
||||
|
||||
_parseStack: (text)->
|
||||
match = text.match @STACK
|
||||
return name:text, quantity:1 unless match?
|
||||
|
||||
quantity = parseFloat match[1]
|
||||
if isNaN quantity then quantity = 1
|
||||
|
||||
name = match[2]
|
||||
|
||||
return name:name, quantity:quantity
|
||||
@@ -0,0 +1,114 @@
|
||||
#
|
||||
# Crafting Guide - recipe_pe_v1.test.coffee
|
||||
#
|
||||
# Copyright © 2014-2016 by Redwood Labs
|
||||
# All rights reserved.
|
||||
#
|
||||
|
||||
RecipeParserExtensionV1 = require './recipe_pe_v1'
|
||||
ParserState = require '../parser_state'
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
parser = state = null
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
describe 'recipe_pe_v1.coffee', ->
|
||||
|
||||
beforeEach ->
|
||||
state = new ParserState
|
||||
parser = new RecipeParserExtensionV1 state
|
||||
|
||||
state.create {}, 'item', 0
|
||||
|
||||
describe 'extras', ->
|
||||
|
||||
it 'assigns to the current recipe', ->
|
||||
state.create {}, 'recipe'
|
||||
|
||||
parser.execute name:'extras', args:['bravo']
|
||||
.then ->
|
||||
recipe = state.getCurrent 'recipe'
|
||||
recipe.extras.should.eql ['bravo']
|
||||
state.errors.should.eql []
|
||||
|
||||
describe 'input', ->
|
||||
|
||||
it 'correctly reads a single item', ->
|
||||
state.create {}, 'recipe'
|
||||
|
||||
parser.execute name:'input', args:['bravo']
|
||||
.then ->
|
||||
recipe = state.getCurrent 'recipe'
|
||||
recipe.input.should.eql [name:'bravo', quantity:1]
|
||||
state.errors.should.eql []
|
||||
|
||||
it 'correctly reads multiple items', ->
|
||||
state.create {}, 'recipe'
|
||||
|
||||
parser.execute name:'input', args:['bravo', 'charlie']
|
||||
.then ->
|
||||
recipe = state.getCurrent 'recipe'
|
||||
recipe.input.should.eql [{name:'bravo', quantity:1}, {name:'charlie', quantity:1}]
|
||||
state.errors.should.eql []
|
||||
|
||||
it 'correctly reads a stack', ->
|
||||
state.create {}, 'recipe'
|
||||
|
||||
parser.execute name:'input', args:['10 bravo']
|
||||
.then ->
|
||||
recipe = state.getCurrent 'recipe'
|
||||
recipe.input.should.eql [name:'bravo', quantity:10]
|
||||
state.errors.should.eql []
|
||||
|
||||
describe 'pattern', ->
|
||||
|
||||
it 'assigns to the current recipe', ->
|
||||
state.create {}, 'recipe'
|
||||
|
||||
parser.execute name:'pattern', argText:'00. 00. ...'
|
||||
.then ->
|
||||
recipe = state.getCurrent 'recipe'
|
||||
recipe.pattern.should.equal '00. 00. ...'
|
||||
state.errors.should.eql []
|
||||
|
||||
it 'rejects an invalid pattern', ->
|
||||
state.create {}, 'recipe'
|
||||
|
||||
parser.execute name:'pattern', argText:'alpha'
|
||||
.then ->
|
||||
recipe = state.getCurrent 'recipe'
|
||||
expect(recipe.pattern).to.be.undefined
|
||||
(e.message for e in state.errors).should.eql ['invalid pattern: "alpha"']
|
||||
|
||||
describe 'recipe', ->
|
||||
|
||||
it 'creates a new recipe', ->
|
||||
parser.execute name:'recipe'
|
||||
.then ->
|
||||
recipe = state.getCurrent 'recipe'
|
||||
recipe.item.id.should.equal 0
|
||||
state.errors.should.eql []
|
||||
|
||||
describe 'quantity', ->
|
||||
|
||||
it 'assigns to the current recipe', ->
|
||||
state.create {}, 'recipe'
|
||||
|
||||
parser.execute name:'quantity', argText:'42'
|
||||
.then ->
|
||||
recipe = state.getCurrent 'recipe'
|
||||
recipe.quantity.should.equal 42
|
||||
state.errors.should.eql []
|
||||
|
||||
describe 'tools', ->
|
||||
|
||||
it 'assigns to the current recipe', ->
|
||||
state.create {}, 'recipe'
|
||||
|
||||
parser.execute name:'tools', args:['bravo']
|
||||
.then ->
|
||||
recipe = state.getCurrent 'recipe'
|
||||
recipe.tools.should.eql [name:'bravo', quantity:1]
|
||||
state.errors.should.eql []
|
||||
@@ -0,0 +1,76 @@
|
||||
#
|
||||
# Crafting Guide - parser_state.coffee
|
||||
#
|
||||
# Copyright © 2014-2016 by Redwood Labs
|
||||
# All rights reserved.
|
||||
#
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
module.exports = class ParserState
|
||||
|
||||
constructor: ->
|
||||
@clear()
|
||||
|
||||
# Public Methods ###############################################################################
|
||||
|
||||
addError: (command, message)->
|
||||
if not command? then throw new Error 'command is required'
|
||||
message ?= 'is not valid'
|
||||
@_errors.push command:command, message:message
|
||||
|
||||
create: (command, type, id=null)->
|
||||
id ?= _.uniqueId "#{type}-"
|
||||
|
||||
typeData = @_findOrCreateType type
|
||||
itemData = @_current = typeData['current'] = typeData[id] = id:id, command:command, type:type
|
||||
return itemData
|
||||
|
||||
clear: ->
|
||||
@_current = null
|
||||
@_data = {}
|
||||
@_errors = []
|
||||
|
||||
each: (callback)->
|
||||
for type, typeData of @_data
|
||||
for id, itemData of typeData
|
||||
continue if id is 'current'
|
||||
continue if id is 'type'
|
||||
callback itemData
|
||||
|
||||
eachOfType: (type, callback)->
|
||||
typeData = @_findOrCreateType type
|
||||
for id, itemData of typeData
|
||||
continue if id is 'current'
|
||||
continue if id is 'type'
|
||||
callback itemData
|
||||
|
||||
get: (type, id)->
|
||||
typeData = @_findOrCreateType type
|
||||
itemData = typeData[id] or null
|
||||
return itemData
|
||||
|
||||
getCurrent: (type)->
|
||||
return @_current unless type?
|
||||
|
||||
typeData = @_findOrCreateType type
|
||||
current = typeData['current'] or null
|
||||
return current
|
||||
|
||||
# Property Methods #############################################################################
|
||||
|
||||
getErrors: ->
|
||||
return @_errors[..]
|
||||
|
||||
Object.defineProperties @prototype,
|
||||
errors: { get:@::getErrors }
|
||||
|
||||
# Private Methods ##############################################################################
|
||||
|
||||
_findOrCreateType: (type)->
|
||||
typeData = @_data[type]
|
||||
if not typeData?
|
||||
typeData = @_data[type] = {}
|
||||
typeData.type = type
|
||||
|
||||
return typeData
|
||||
@@ -0,0 +1,108 @@
|
||||
#
|
||||
# Crafting Guide - parser_state.test.coffee
|
||||
#
|
||||
# Copyright © 2014-2016 by Redwood Labs
|
||||
# All rights reserved.
|
||||
#
|
||||
|
||||
ParserCommand = require './parser_command'
|
||||
ParserState = require './parser_state'
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
command = state = null
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
describe 'parser_state.coffee', ->
|
||||
|
||||
beforeEach ->
|
||||
state = new ParserState
|
||||
command = new ParserCommand name:'alpha', fileName:'file.cg', lineNumber:1
|
||||
|
||||
describe 'create', ->
|
||||
|
||||
it 'returns an object with the given id', ->
|
||||
alpha = state.create command, 'alpha', 0
|
||||
alpha.id.should.equal 0
|
||||
alpha.command.location.should.equal 'file.cg:1'
|
||||
|
||||
it 'returns an object with a newly created id when none is given', ->
|
||||
alpha = state.create command, 'alpha'
|
||||
alpha.id.should.not.be.null
|
||||
alpha.id.should.match /^alpha-[0-9]+$/
|
||||
alpha.command.location.should.equal 'file.cg:1'
|
||||
|
||||
describe 'each', ->
|
||||
|
||||
it 'ignores the callback when there are no items', ->
|
||||
items = []
|
||||
state.each (item)-> items.push item
|
||||
items.length.should.equal 0
|
||||
|
||||
it 'calls the callback once with a single item', ->
|
||||
state.create command, 'alpha', 0
|
||||
items = []
|
||||
state.each (item)-> items.push item
|
||||
items.length.should.equal 1
|
||||
items[0].id.should.equal 0
|
||||
|
||||
it 'calls the callback for each item of a single type', ->
|
||||
state.create command, 'alpha', 0
|
||||
state.create command, 'alpha', 1
|
||||
state.create command, 'alpha', 2
|
||||
|
||||
ids = []
|
||||
state.each (item)-> ids.push item.id
|
||||
ids.should.eql [0, 1, 2]
|
||||
|
||||
it 'calls the callback for each item across multiple types', ->
|
||||
state.create command, 'alpha', 0
|
||||
state.create command, 'alpha', 1
|
||||
state.create command, 'bravo', 0
|
||||
state.create command, 'bravo', 1
|
||||
|
||||
ids = []
|
||||
state.each (item)-> ids.push "#{item.type}-#{item.id}"
|
||||
ids.should.eql ['alpha-0', 'alpha-1', 'bravo-0', 'bravo-1']
|
||||
|
||||
describe 'eachOfType', ->
|
||||
|
||||
it 'ignores the callback if there are no items of that type', ->
|
||||
state.create command, 'alpha', 0
|
||||
state.create command, 'alpha', 1
|
||||
|
||||
ids = []
|
||||
state.eachOfType 'bravo', (item)-> ids.push "#{item.type}-#{item.id}"
|
||||
ids.length.should.equal 0
|
||||
|
||||
it 'calls the callback once for each item of the given type', ->
|
||||
state.create command, 'alpha', 0
|
||||
state.create command, 'alpha', 1
|
||||
state.create command, 'bravo', 0
|
||||
state.create command, 'bravo', 1
|
||||
|
||||
ids = []
|
||||
state.eachOfType 'bravo', (item)-> ids.push "#{item.type}-#{item.id}"
|
||||
ids.should.eql ['bravo-0', 'bravo-1']
|
||||
|
||||
describe 'get', ->
|
||||
|
||||
it 'returns a previously created item', ->
|
||||
alpha1 = state.create command, 'alpha', 0
|
||||
alpha2 = state.get 'alpha', 0
|
||||
alpha1.should.equal alpha2
|
||||
|
||||
it 'returns null when no such item exists', ->
|
||||
alpha = state.create command, 'alpha', 0
|
||||
expect(state.get 'alpha', 1).to.be.null
|
||||
expect(state.get 'bravo', 0).to.be.null
|
||||
|
||||
describe 'getCurrent', ->
|
||||
|
||||
it 'returns the new item each time one is created', ->
|
||||
alpha1 = state.create command, 'alpha', 0
|
||||
state.getCurrent('alpha').id.should.equal 0
|
||||
|
||||
alpha2 = state.create command, 'alpha', 1
|
||||
state.getCurrent('alpha').id.should.equal 1
|
||||
Reference in New Issue
Block a user