diff --git a/src/client/models/parsing-2/lexical_analyzer.coffee b/src/client/models/parsing-2/lexical_analyzer.coffee new file mode 100644 index 000000000..aa59ea37f --- /dev/null +++ b/src/client/models/parsing-2/lexical_analyzer.coffee @@ -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 : , 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] diff --git a/src/client/models/parsing-2/lexical_analyzer.test.coffee b/src/client/models/parsing-2/lexical_analyzer.test.coffee new file mode 100644 index 000000000..6ed06e546 --- /dev/null +++ b/src/client/models/parsing-2/lexical_analyzer.test.coffee @@ -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 diff --git a/src/client/models/parsing-2/parser.coffee b/src/client/models/parsing-2/parser.coffee new file mode 100644 index 000000000..8ac9e597f --- /dev/null +++ b/src/client/models/parsing-2/parser.coffee @@ -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 diff --git a/src/client/models/parsing-2/parser_command.coffee b/src/client/models/parsing-2/parser_command.coffee new file mode 100644 index 000000000..b576ce6ca --- /dev/null +++ b/src/client/models/parsing-2/parser_command.coffee @@ -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 diff --git a/src/client/models/parsing-2/parser_extension.coffee b/src/client/models/parsing-2/parser_extension.coffee new file mode 100644 index 000000000..cf37c315b --- /dev/null +++ b/src/client/models/parsing-2/parser_extension.coffee @@ -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 diff --git a/src/client/models/parsing-2/parser_extensions/current_pe_v1.coffee b/src/client/models/parsing-2/parser_extensions/current_pe_v1.coffee new file mode 100644 index 000000000..0f0c93b7d --- /dev/null +++ b/src/client/models/parsing-2/parser_extensions/current_pe_v1.coffee @@ -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 diff --git a/src/client/models/parsing-2/parser_extensions/current_pe_v1.test.coffee b/src/client/models/parsing-2/parser_extensions/current_pe_v1.test.coffee new file mode 100644 index 000000000..be4c8bb40 --- /dev/null +++ b/src/client/models/parsing-2/parser_extensions/current_pe_v1.test.coffee @@ -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 [] diff --git a/src/client/models/parsing-2/parser_extensions/item_pe_v1.coffee b/src/client/models/parsing-2/parser_extensions/item_pe_v1.coffee new file mode 100644 index 000000000..a646552e8 --- /dev/null +++ b/src/client/models/parsing-2/parser_extensions/item_pe_v1.coffee @@ -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' diff --git a/src/client/models/parsing-2/parser_extensions/item_pe_v1.test.coffee b/src/client/models/parsing-2/parser_extensions/item_pe_v1.test.coffee new file mode 100644 index 000000000..c334d2de3 --- /dev/null +++ b/src/client/models/parsing-2/parser_extensions/item_pe_v1.test.coffee @@ -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 diff --git a/src/client/models/parsing-2/parser_extensions/mod_pe_v1.coffee b/src/client/models/parsing-2/parser_extensions/mod_pe_v1.coffee new file mode 100644 index 000000000..edcfb696c --- /dev/null +++ b/src/client/models/parsing-2/parser_extensions/mod_pe_v1.coffee @@ -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 diff --git a/src/client/models/parsing-2/parser_extensions/mod_pe_v1.test.coffee b/src/client/models/parsing-2/parser_extensions/mod_pe_v1.test.coffee new file mode 100644 index 000000000..771a43a17 --- /dev/null +++ b/src/client/models/parsing-2/parser_extensions/mod_pe_v1.test.coffee @@ -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' diff --git a/src/client/models/parsing-2/parser_extensions/mod_version_pe_v1.coffee b/src/client/models/parsing-2/parser_extensions/mod_version_pe_v1.coffee new file mode 100644 index 000000000..72631d8dc --- /dev/null +++ b/src/client/models/parsing-2/parser_extensions/mod_version_pe_v1.coffee @@ -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' diff --git a/src/client/models/parsing-2/parser_extensions/mod_version_pe_v1.test.coffee b/src/client/models/parsing-2/parser_extensions/mod_version_pe_v1.test.coffee new file mode 100644 index 000000000..529f756d1 --- /dev/null +++ b/src/client/models/parsing-2/parser_extensions/mod_version_pe_v1.test.coffee @@ -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 diff --git a/src/client/models/parsing-2/parser_extensions/recipe_pe_v1.coffee b/src/client/models/parsing-2/parser_extensions/recipe_pe_v1.coffee new file mode 100644 index 000000000..207ec1e72 --- /dev/null +++ b/src/client/models/parsing-2/parser_extensions/recipe_pe_v1.coffee @@ -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 diff --git a/src/client/models/parsing-2/parser_extensions/recipe_pe_v1.test.coffee b/src/client/models/parsing-2/parser_extensions/recipe_pe_v1.test.coffee new file mode 100644 index 000000000..86e09bb86 --- /dev/null +++ b/src/client/models/parsing-2/parser_extensions/recipe_pe_v1.test.coffee @@ -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 [] diff --git a/src/client/models/parsing-2/parser_state.coffee b/src/client/models/parsing-2/parser_state.coffee new file mode 100644 index 000000000..24a36b721 --- /dev/null +++ b/src/client/models/parsing-2/parser_state.coffee @@ -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 diff --git a/src/client/models/parsing-2/parser_state.test.coffee b/src/client/models/parsing-2/parser_state.test.coffee new file mode 100644 index 000000000..50c4135fb --- /dev/null +++ b/src/client/models/parsing-2/parser_state.test.coffee @@ -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