diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a1b3c69 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/node_modules +.DS_Store + diff --git a/README.md b/README.md index 6effe22..ab8fddb 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ A tool for creating sequence diagrams from a Domain-Specific Language. -[See it in action!](https://davidje13.github.io/SequenceDiagram/) +[See it in action!](https://davidje13.github.io/SequenceDiagram/editor.htm) This project includes a web page for editing the diagrams, but the core -logic is available as separate components which can be included in -other projects. +logic is available as a component which can be +[included in other projects](https://davidje13.github.io/SequenceDiagram/). ## Examples @@ -298,11 +298,13 @@ run a local HTTP server to ensure linting is successful. One option if you have NPM installed is: ```shell -# Setup -npm install http-server -g; +npm run serve; +``` -# Then -http-server; +It is also good to rebuild the minified library when committing: + +```shell +npm run minify; ``` The current status of the tests on the master branch can be checked at diff --git a/editor.htm b/editor.htm new file mode 100644 index 0000000..e02b36a --- /dev/null +++ b/editor.htm @@ -0,0 +1,69 @@ + + + + + +Sequence Diagram + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/index.html b/index.html index e02b36a..a90729b 100644 --- a/index.html +++ b/index.html @@ -4,66 +4,503 @@ -Sequence Diagram +Sequence Diagram Plugin - + + - + - + +// Example 1: +(() => { + const diagram = new SequenceDiagram(); + diagram.set('A -> B\nB -> A'); + diagram.dom().setAttribute('class', 'sequence-diagram'); + document.getElementById('hold1').appendChild(diagram.dom()); + diagram.setHighlight(1); +})(); - +// Snippets: +const elements = document.getElementsByClassName('example'); +for(let i = 0; i < elements.length; ++ i) { + const el = elements[i]; + const diagram = new SequenceDiagram(el.innerText); + diagram.dom().setAttribute('class', 'example-diagram'); + el.parentNode.insertBefore(diagram.dom(), el); +} - - - - - - - +}, {once: true}); - +
+ +
+

Sequence Diagram

+ +
+  define Complex System as sys
+  define User as usr
+  define Sequence Diagram as diagram
+  define Other Users as usr2
+  begin sys, usr, usr2
+  sys ~> usr
+  note over usr: "Take time to\nunderstand System"
+  usr -> *diagram: Create
+  usr -> usr2: Inform
+  usr2 <-> diagram: Learn & understand
+  usr2 -> sys: Use
+  terminators box
+
+
+ +

Introduction

+ +

+Want to draw a Sequence Diagram? +Go to the online editor. +

+

+This library renders sequence diagrams from code. It is +open-source +(LGPL-3.0), and including it in a website is as simple as adding the script:

+ +
+<script src="lib/sequence-diagram.min.js"></script>
+
+ +

+Any element with the class sequence-diagram will automatically be +converted when the page loads: +

+ +
+
+  A -> B: foo
+  B -> A: bar
+
+
+ +
+<pre class="sequence-diagram">
+  A -> B: foo
+  B -> A: bar
+</pre>
+
+ +

Language

+ +

Connection Types

+
+title Connection Types
+
+begin Foo, Bar, Baz
+
+Foo -> Bar: Simple arrow
+Bar --> Baz: Dashed arrow
+Foo <- Bar: Reversed arrow
+Bar <-- Baz: Reversed & dashed
+Foo <-> Bar: Double arrow
+Bar <--> Baz: Double dashed arrow
+
+# An arrow with no label:
+Foo -> Bar
+
+Bar ->> Baz: Different arrow
+Foo <<--> Bar: Mix of arrows
+
+Bar -> Bar: Bar talks to itself
+
+Foo -> +Bar: Foo asks Bar
+-Bar --> Foo: and Bar replies
+
+# Arrows leaving on the left and right of the diagram
+[ -> Foo: From the left
+[ <- Foo: To the left
+Foo -> ]: To the right
+Foo <- ]: From the right
+[ ~> ]: Wavy left to right!
+# (etc.)
+
+ +

Notes & State

+
+title Note Placements
+
+note over Foo: Foo says something
+note left of Foo: Stuff
+note right of Bar: More stuff
+note over Foo, Bar: "Foo and Bar
+on multiple lines"
+note between Foo, Bar: Link
+
+text right: 'Comments\nOver here!'
+
+state over Foo: Foo is ponderous
+
+ +

Logic

+
+title At the Bank
+
+begin Person, ATM, Bank
+Person -> ATM: Request money
+ATM -> Bank: Check funds
+if fraud detected
+  Bank -> Police: "Get 'em!"
+  Police -> Person: "You're nicked"
+  end Police
+else if sufficient funds
+  ATM -> Bank: Withdraw funds
+  repeat until "all requested money
+                has been handed over"
+    ATM -> Person: Dispense note
+  end
+else
+  ATM -> Person: Error
+end
+
+ +

Label Templates

+
+autolabel "[<inc>] <label>"
+
+begin "Underpants\nGnomes" as A
+A <- ]: Collect underpants
+A <-> ]: ???
+A <- ]: Profit!
+
+ +

Multiline Text

+
+title 'My Multiline
+Title'
+
+note over Foo: 'Also possible\nwith escapes'
+
+Foo -> Bar: 'Lines of text\non this arrow'
+
+if 'Even multiline\ninside conditions like this'
+  Foo -> 'Multiline\nagent'
+end
+
+state over Foo: 'Newlines here,
+too!'
+
+ +

Short-Lived Agents

+
+title "Baz doesn't live long"
+
+note over Foo, Bar: Using begin / end
+
+begin Baz
+Bar -> Baz
+Baz -> Foo
+end Baz
+
+note over Foo, Bar: Using * / !
+
+# * and ! cause agents to be created and destroyed inline
+Bar -> *Baz: make Baz
+Foo <- !Baz: end Baz
+
+# Foo and Bar end with black bars
+terminators bar
+# (options are: box, bar, cross, fade, none)
+
+ +

Agent Aliases

+
+define My complicated agent name as A
+define "Another agent name,
+and this one's multi-line!" as B
+
+A -> B: this is much easier
+A <- B: than writing the whole name
+
+ +

Alternative Agent Ordering

+
+define Baz, Foo
+
+Foo -> Bar
+Bar -> Baz
+
+ +

More

+ +

+More features are supported. See the +online editor's library and +autocomplete features to discover them. +

+ +

Browser Support

+ +

+This has been tested in the latest versions of Google Chrome, Mozilla Firefox, +and Apple Safari. Versions of Microsoft Internet Explorer / Edge have not been +tested and probably won't work. Any bugs found in a supported browser should be +reported in the +Issue Tracker. +

+ +

API

+ +

+For more advanced usage, an API is available: +

+ +
+ +
+var diagram = new SequenceDiagram();
+diagram.set('A -> B\nB -> A');
+document.body.appendChild(diagram.dom());
+diagram.setHighlight(1); // Highlight elements created in line 1 (0-based)
+
+ +

Constructor

+ +
+diagram = new SequenceDiagram(code, options);
+diagram = new SequenceDiagram(code);
+diagram = new SequenceDiagram(options);
+diagram = new SequenceDiagram();
+
+ +

+Creates a new SequenceDiagram object. Options is an object which can contain: +

+ + +

.clone

+ +
+newDiagram = diagram.clone(options);
+newDiagram = diagram.clone();
+
+ +

+Creates a copy of the diagram. If options are given, they will override the +current diagram's state (options is passed to the constructor of the new object, +so all the same options are available). +

+ +

.set

+ +
+diagram.set(code);
+
+ +

+Changes the code for the diagram and causes a re-render. +

+ +

.process

+ +
+processed = diagram.process(code);
+
+ +

+Processes the given code but does not render it. Causes no change to the +diagram object. This is mostly useful for debounced rendering with immediate +error notifications. The resulting object can be passed to +render at a later point. +

+ +

.render

+ +
+diagram.render();
+diagram.render(processed);
+
+ +

+Forces a re-render of the diagram. Typically this happens automatically. +Optionally, the result of an earlier call to +process can be provided. +

+ +

.setHighlight

+ +
+diagram.setHighlight(line);
+diagram.setHighlight();
+
+ +

+Marks elements generated by the specified line with a "focus" CSS class, which +can be used to style them. Only one line can be highlighted at a time. Calling +with no parameter (or null) will remove the highlighting. +

+ +

.addTheme

+ +
+diagram.addTheme(theme);
+
+ +

+Make a new theme available to the diagram. Any unrecognised themes are replaced +with the default theme. +

+

+The theme API has not been finalised yet, so this method is not typically +useful. +

+ +

.getThemeNames

+ +
+names = diagram.getThemeNames();
+
+ +

+Returns a list of names of themes which are available to this diagram. These +can be specified in a theme <name> line in the code. +

+ +

.getThemes

+ +
+themes = diagram.getThemes();
+
+ +

+Returns a list of themes which are available to this diagram. +

+ +

.getSVGSynchronous

+ +
+svgURL = diagram.getSVGSynchronous();
+
+ +

+Returns a blob URL which contains the SVG code for the current diagram. +

+ +

.getSVG

+ +
+diagram.getSVG().then(({url, latest}) => { ... });
+
+ +

+Asynchronous version of +getSVGSynchronous. This is +provided for compatibility with getPNG, +which has no synchronous equivalent. +

+

The callback recieves an object containing:

+ + +

.getPNG

+ +
+diagram.getPNG(options).then(({url, latest}) => { ... });
+
+ +

+Generates a PNG image and returns a blob URL. +

+

The options can include:

+ +

The callback recieves an object containing:

+ + +

.getSize

+ +
+size = diagram.getSize();
+
+ +

+Returns an object containing width and height +properties, corresponding to the size of the diagram in units. +

+ +

.setContainer

+ +
+diagram.setContainer(node);
+
+ +

+Same as calling node.appendChild(diagram.dom()). +

+ +

.dom

+ +
+node = diagram.dom();
+
+ +

+Returns the base SVG element which the diagram has been rendered into. +

+ +

Thanks

+ +

+Thanks to +websequencediagrams.com +and +js-sequence-diagrams +for inspiring the syntax of this project. +

+ +
+  begin User, SequenceDiagram as SD, Parser, Generator, Renderer
+
+  User -> +SD: code
+
+  SD -> +Parser: code
+  -Parser --> SD: parsed
+
+  SD -> +Generator: parsed
+  -Generator --> SD: generated
+
+  -SD -> +Renderer: generated
+  -Renderer -> *DOM: SVG
+  User <~> DOM: interaction
+
+  terminators box
+
+ + + +
diff --git a/lib/sequence-diagram.js b/lib/sequence-diagram.js new file mode 100644 index 0000000..7bed586 --- /dev/null +++ b/lib/sequence-diagram.js @@ -0,0 +1,6091 @@ +(function () { +/** + * @license almond 0.3.3 Copyright jQuery Foundation and other contributors. + * Released under MIT license, http://github.com/requirejs/almond/LICENSE + */ +//Going sloppy to avoid 'use strict' string cost, but strict practices should +//be followed. +/*global setTimeout: false */ + +var requirejs, require, define; +(function (undef) { + var main, req, makeMap, handlers, + defined = {}, + waiting = {}, + config = {}, + defining = {}, + hasOwn = Object.prototype.hasOwnProperty, + aps = [].slice, + jsSuffixRegExp = /\.js$/; + + function hasProp(obj, prop) { + return hasOwn.call(obj, prop); + } + + /** + * Given a relative module name, like ./something, normalize it to + * a real name that can be mapped to a path. + * @param {String} name the relative name + * @param {String} baseName a real name that the name arg is relative + * to. + * @returns {String} normalized name + */ + function normalize(name, baseName) { + var nameParts, nameSegment, mapValue, foundMap, lastIndex, + foundI, foundStarMap, starI, i, j, part, normalizedBaseParts, + baseParts = baseName && baseName.split("/"), + map = config.map, + starMap = (map && map['*']) || {}; + + //Adjust any relative paths. + if (name) { + name = name.split('/'); + lastIndex = name.length - 1; + + // If wanting node ID compatibility, strip .js from end + // of IDs. Have to do this here, and not in nameToUrl + // because node allows either .js or non .js to map + // to same file. + if (config.nodeIdCompat && jsSuffixRegExp.test(name[lastIndex])) { + name[lastIndex] = name[lastIndex].replace(jsSuffixRegExp, ''); + } + + // Starts with a '.' so need the baseName + if (name[0].charAt(0) === '.' && baseParts) { + //Convert baseName to array, and lop off the last part, + //so that . matches that 'directory' and not name of the baseName's + //module. For instance, baseName of 'one/two/three', maps to + //'one/two/three.js', but we want the directory, 'one/two' for + //this normalization. + normalizedBaseParts = baseParts.slice(0, baseParts.length - 1); + name = normalizedBaseParts.concat(name); + } + + //start trimDots + for (i = 0; i < name.length; i++) { + part = name[i]; + if (part === '.') { + name.splice(i, 1); + i -= 1; + } else if (part === '..') { + // If at the start, or previous value is still .., + // keep them so that when converted to a path it may + // still work when converted to a path, even though + // as an ID it is less than ideal. In larger point + // releases, may be better to just kick out an error. + if (i === 0 || (i === 1 && name[2] === '..') || name[i - 1] === '..') { + continue; + } else if (i > 0) { + name.splice(i - 1, 2); + i -= 2; + } + } + } + //end trimDots + + name = name.join('/'); + } + + //Apply map config if available. + if ((baseParts || starMap) && map) { + nameParts = name.split('/'); + + for (i = nameParts.length; i > 0; i -= 1) { + nameSegment = nameParts.slice(0, i).join("/"); + + if (baseParts) { + //Find the longest baseName segment match in the config. + //So, do joins on the biggest to smallest lengths of baseParts. + for (j = baseParts.length; j > 0; j -= 1) { + mapValue = map[baseParts.slice(0, j).join('/')]; + + //baseName segment has config, find if it has one for + //this name. + if (mapValue) { + mapValue = mapValue[nameSegment]; + if (mapValue) { + //Match, update name to the new value. + foundMap = mapValue; + foundI = i; + break; + } + } + } + } + + if (foundMap) { + break; + } + + //Check for a star map match, but just hold on to it, + //if there is a shorter segment match later in a matching + //config, then favor over this star map. + if (!foundStarMap && starMap && starMap[nameSegment]) { + foundStarMap = starMap[nameSegment]; + starI = i; + } + } + + if (!foundMap && foundStarMap) { + foundMap = foundStarMap; + foundI = starI; + } + + if (foundMap) { + nameParts.splice(0, foundI, foundMap); + name = nameParts.join('/'); + } + } + + return name; + } + + function makeRequire(relName, forceSync) { + return function () { + //A version of a require function that passes a moduleName + //value for items that may need to + //look up paths relative to the moduleName + var args = aps.call(arguments, 0); + + //If first arg is not require('string'), and there is only + //one arg, it is the array form without a callback. Insert + //a null so that the following concat is correct. + if (typeof args[0] !== 'string' && args.length === 1) { + args.push(null); + } + return req.apply(undef, args.concat([relName, forceSync])); + }; + } + + function makeNormalize(relName) { + return function (name) { + return normalize(name, relName); + }; + } + + function makeLoad(depName) { + return function (value) { + defined[depName] = value; + }; + } + + function callDep(name) { + if (hasProp(waiting, name)) { + var args = waiting[name]; + delete waiting[name]; + defining[name] = true; + main.apply(undef, args); + } + + if (!hasProp(defined, name) && !hasProp(defining, name)) { + throw new Error('No ' + name); + } + return defined[name]; + } + + //Turns a plugin!resource to [plugin, resource] + //with the plugin being undefined if the name + //did not have a plugin prefix. + function splitPrefix(name) { + var prefix, + index = name ? name.indexOf('!') : -1; + if (index > -1) { + prefix = name.substring(0, index); + name = name.substring(index + 1, name.length); + } + return [prefix, name]; + } + + //Creates a parts array for a relName where first part is plugin ID, + //second part is resource ID. Assumes relName has already been normalized. + function makeRelParts(relName) { + return relName ? splitPrefix(relName) : []; + } + + /** + * Makes a name map, normalizing the name, and using a plugin + * for normalization if necessary. Grabs a ref to plugin + * too, as an optimization. + */ + makeMap = function (name, relParts) { + var plugin, + parts = splitPrefix(name), + prefix = parts[0], + relResourceName = relParts[1]; + + name = parts[1]; + + if (prefix) { + prefix = normalize(prefix, relResourceName); + plugin = callDep(prefix); + } + + //Normalize according + if (prefix) { + if (plugin && plugin.normalize) { + name = plugin.normalize(name, makeNormalize(relResourceName)); + } else { + name = normalize(name, relResourceName); + } + } else { + name = normalize(name, relResourceName); + parts = splitPrefix(name); + prefix = parts[0]; + name = parts[1]; + if (prefix) { + plugin = callDep(prefix); + } + } + + //Using ridiculous property names for space reasons + return { + f: prefix ? prefix + '!' + name : name, //fullName + n: name, + pr: prefix, + p: plugin + }; + }; + + function makeConfig(name) { + return function () { + return (config && config.config && config.config[name]) || {}; + }; + } + + handlers = { + require: function (name) { + return makeRequire(name); + }, + exports: function (name) { + var e = defined[name]; + if (typeof e !== 'undefined') { + return e; + } else { + return (defined[name] = {}); + } + }, + module: function (name) { + return { + id: name, + uri: '', + exports: defined[name], + config: makeConfig(name) + }; + } + }; + + main = function (name, deps, callback, relName) { + var cjsModule, depName, ret, map, i, relParts, + args = [], + callbackType = typeof callback, + usingExports; + + //Use name if no relName + relName = relName || name; + relParts = makeRelParts(relName); + + //Call the callback to define the module, if necessary. + if (callbackType === 'undefined' || callbackType === 'function') { + //Pull out the defined dependencies and pass the ordered + //values to the callback. + //Default to [require, exports, module] if no deps + deps = !deps.length && callback.length ? ['require', 'exports', 'module'] : deps; + for (i = 0; i < deps.length; i += 1) { + map = makeMap(deps[i], relParts); + depName = map.f; + + //Fast path CommonJS standard dependencies. + if (depName === "require") { + args[i] = handlers.require(name); + } else if (depName === "exports") { + //CommonJS module spec 1.1 + args[i] = handlers.exports(name); + usingExports = true; + } else if (depName === "module") { + //CommonJS module spec 1.1 + cjsModule = args[i] = handlers.module(name); + } else if (hasProp(defined, depName) || + hasProp(waiting, depName) || + hasProp(defining, depName)) { + args[i] = callDep(depName); + } else if (map.p) { + map.p.load(map.n, makeRequire(relName, true), makeLoad(depName), {}); + args[i] = defined[depName]; + } else { + throw new Error(name + ' missing ' + depName); + } + } + + ret = callback ? callback.apply(defined[name], args) : undefined; + + if (name) { + //If setting exports via "module" is in play, + //favor that over return value and exports. After that, + //favor a non-undefined return value over exports use. + if (cjsModule && cjsModule.exports !== undef && + cjsModule.exports !== defined[name]) { + defined[name] = cjsModule.exports; + } else if (ret !== undef || !usingExports) { + //Use the return value from the function. + defined[name] = ret; + } + } + } else if (name) { + //May just be an object definition for the module. Only + //worry about defining if have a module name. + defined[name] = callback; + } + }; + + requirejs = require = req = function (deps, callback, relName, forceSync, alt) { + if (typeof deps === "string") { + if (handlers[deps]) { + //callback in this case is really relName + return handlers[deps](callback); + } + //Just return the module wanted. In this scenario, the + //deps arg is the module name, and second arg (if passed) + //is just the relName. + //Normalize module name, if it contains . or .. + return callDep(makeMap(deps, makeRelParts(callback)).f); + } else if (!deps.splice) { + //deps is a config object, not an array. + config = deps; + if (config.deps) { + req(config.deps, config.callback); + } + if (!callback) { + return; + } + + if (callback.splice) { + //callback is an array, which means it is a dependency list. + //Adjust args if there are dependencies + deps = callback; + callback = relName; + relName = null; + } else { + deps = undef; + } + } + + //Support require(['a']) + callback = callback || function () {}; + + //If relName is a function, it is an errback handler, + //so remove it. + if (typeof relName === 'function') { + relName = forceSync; + forceSync = alt; + } + + //Simulate async callback; + if (forceSync) { + main(undef, deps, callback, relName); + } else { + //Using a non-zero value because of concern for what old browsers + //do, and latest browsers "upgrade" to 4 if lower value is used: + //http://www.whatwg.org/specs/web-apps/current-work/multipage/timers.html#dom-windowtimers-settimeout: + //If want a value immediately, use require('id') instead -- something + //that works in almond on the global level, but not guaranteed and + //unlikely to work in other AMD implementations. + setTimeout(function () { + main(undef, deps, callback, relName); + }, 4); + } + + return req; + }; + + /** + * Just drops the config on the floor, but returns req in case + * the config return value is used. + */ + req.config = function (cfg) { + return req(cfg); + }; + + /** + * Expose module registry for debugging and tooling + */ + requirejs._defined = defined; + + define = function (name, deps, callback) { + if (typeof name !== 'string') { + throw new Error('See almond README: incorrect module build, no module name'); + } + + //This module may not have dependencies + if (!deps.splice) { + //deps is not an array, so probably means + //an object literal or factory function for + //the value. Adjust args. + callback = deps; + deps = []; + } + + if (!hasProp(defined, name) && !hasProp(waiting, name)) { + waiting[name] = [name, deps, callback]; + } + }; + + define.amd = { + jQuery: true + }; +}()); + +define("../node_modules/almond/almond", function(){}); + +define('core/EventObject',[],() => { + 'use strict'; + + return class EventObject { + constructor() { + this.listeners = new Map(); + this.forwards = new Set(); + } + + addEventListener(type, callback) { + const l = this.listeners.get(type); + if(l) { + l.push(callback); + } else { + this.listeners.set(type, [callback]); + } + } + + removeEventListener(type, fn) { + const l = this.listeners.get(type); + if(!l) { + return; + } + const i = l.indexOf(fn); + if(i !== -1) { + l.splice(i, 1); + } + } + + countEventListeners(type) { + return (this.listeners.get(type) || []).length; + } + + removeAllEventListeners(type) { + if(type) { + this.listeners.delete(type); + } else { + this.listeners.clear(); + } + } + + addEventForwarding(target) { + this.forwards.add(target); + } + + removeEventForwarding(target) { + this.forwards.delete(target); + } + + removeAllEventForwardings() { + this.forwards.clear(); + } + + trigger(type, params = []) { + (this.listeners.get(type) || []).forEach( + (listener) => listener.apply(null, params) + ); + this.forwards.forEach((fwd) => fwd.trigger(type, params)); + } + }; +}); + +define('core/ArrayUtilities',[],() => { + 'use strict'; + + function indexOf(list, element, equalityCheck = null) { + if(equalityCheck === null) { + return list.indexOf(element); + } + for(let i = 0; i < list.length; ++ i) { + if(equalityCheck(list[i], element)) { + return i; + } + } + return -1; + } + + function mergeSets(target, b = null, equalityCheck = null) { + if(!b) { + return; + } + for(let i = 0; i < b.length; ++ i) { + if(indexOf(target, b[i], equalityCheck) === -1) { + target.push(b[i]); + } + } + } + + function hasIntersection(a, b, equalityCheck = null) { + for(let i = 0; i < b.length; ++ i) { + if(indexOf(a, b[i], equalityCheck) !== -1) { + return true; + } + } + return false; + } + + function removeAll(target, b = null, equalityCheck = null) { + if(!b) { + return; + } + for(let i = 0; i < b.length; ++ i) { + const p = indexOf(target, b[i], equalityCheck); + if(p !== -1) { + target.splice(p, 1); + } + } + } + + function remove(list, item, equalityCheck = null) { + const p = indexOf(list, item, equalityCheck); + if(p !== -1) { + list.splice(p, 1); + } + } + + function last(list) { + return list[list.length - 1]; + } + + function combineRecur(parts, position, current, target) { + if(position >= parts.length) { + target.push(current.slice()); + return; + } + const choices = parts[position]; + if(!Array.isArray(choices)) { + current.push(choices); + combineRecur(parts, position + 1, current, target); + current.pop(); + return; + } + for(let i = 0; i < choices.length; ++ i) { + current.push(choices[i]); + combineRecur(parts, position + 1, current, target); + current.pop(); + } + } + + function combine(parts) { + const target = []; + combineRecur(parts, 0, [], target); + return target; + } + + function flatMap(list, fn) { + const result = []; + list.forEach((item) => { + result.push(...fn(item)); + }); + return result; + } + + return { + indexOf, + mergeSets, + hasIntersection, + removeAll, + remove, + last, + combine, + flatMap, + }; +}); + +define('sequence/CodeMirrorMode',['core/ArrayUtilities'], (array) => { + 'use strict'; + + const CM_ERROR = {type: 'error line-error', then: {'': 0}}; + + const makeCommands = ((() => { + // The order of commands inside "then" blocks directly influences the + // order they are displayed to the user in autocomplete menus. + // This relies on the fact that common JS engines maintain insertion + // order in objects, though this is not guaranteed. It could be switched + // to use Map objects instead for strict compliance, at the cost of + // extra syntax. + + const end = {type: '', suggest: '\n', then: {}}; + const hiddenEnd = {type: '', then: {}}; + + function textTo(exit) { + return {type: 'string', then: Object.assign({'': 0}, exit)}; + } + + const textToEnd = textTo({'\n': end}); + const aliasListToEnd = {type: 'variable', suggest: 'Agent', then: { + '': 0, + '\n': end, + ',': {type: 'operator', suggest: true, then: {'': 1}}, + 'as': {type: 'keyword', suggest: true, then: { + '': {type: 'variable', suggest: 'Agent', then: { + '': 0, + ',': {type: 'operator', suggest: true, then: {'': 3}}, + '\n': end, + }}, + }}, + }}; + + function agentListTo(exit) { + return {type: 'variable', suggest: 'Agent', then: Object.assign({}, + exit, + { + '': 0, + ',': {type: 'operator', suggest: true, then: {'': 1}}, + } + )}; + } + + const agentListToText = agentListTo({ + ':': {type: 'operator', suggest: true, then: {'': textToEnd}}, + }); + const agentList2ToText = {type: 'variable', suggest: 'Agent', then: { + '': 0, + ',': {type: 'operator', suggest: true, then: {'': agentListToText}}, + ':': CM_ERROR, + }}; + const singleAgentToText = {type: 'variable', suggest: 'Agent', then: { + '': 0, + ',': CM_ERROR, + ':': {type: 'operator', suggest: true, then: {'': textToEnd}}, + }}; + const agentToOptText = {type: 'variable', suggest: 'Agent', then: { + '': 0, + ':': {type: 'operator', suggest: true, then: { + '': textToEnd, + '\n': hiddenEnd, + }}, + '\n': end, + }}; + const referenceName = { + ':': {type: 'operator', suggest: true, then: { + '': textTo({ + 'as': {type: 'keyword', suggest: true, then: { + '': {type: 'variable', suggest: 'Agent', then: { + '': 0, + '\n': end, + }}, + }}, + }), + }}, + }; + const refDef = {type: 'keyword', suggest: true, then: Object.assign({ + 'over': {type: 'keyword', suggest: true, then: { + '': agentListTo(referenceName), + }}, + }, referenceName)}; + + function makeSideNote(side) { + return { + type: 'keyword', + suggest: [side + ' of ', side + ': '], + then: { + 'of': {type: 'keyword', suggest: true, then: { + '': agentListToText, + }}, + ':': {type: 'operator', suggest: true, then: { + '': textToEnd, + }}, + '': agentListToText, + }, + }; + } + + function makeOpBlock(exit) { + const op = {type: 'operator', suggest: true, then: { + '+': CM_ERROR, + '-': CM_ERROR, + '*': CM_ERROR, + '!': CM_ERROR, + '': exit, + }}; + return { + '+': {type: 'operator', suggest: true, then: { + '+': CM_ERROR, + '-': CM_ERROR, + '*': op, + '!': CM_ERROR, + '': exit, + }}, + '-': {type: 'operator', suggest: true, then: { + '+': CM_ERROR, + '-': CM_ERROR, + '*': op, + '!': {type: 'operator', then: { + '+': CM_ERROR, + '-': CM_ERROR, + '*': CM_ERROR, + '!': CM_ERROR, + '': exit, + }}, + '': exit, + }}, + '*': {type: 'operator', suggest: true, then: { + '+': op, + '-': op, + '*': CM_ERROR, + '!': CM_ERROR, + '': exit, + }}, + '!': op, + '': exit, + }; + } + + function makeCMConnect(arrows) { + const connect = { + type: 'keyword', + suggest: true, + then: makeOpBlock(agentToOptText), + }; + + const then = {'': 0}; + arrows.forEach((arrow) => (then[arrow] = connect)); + then[':'] = { + type: 'operator', + suggest: true, + override: 'Label', + then: {}, + }; + return makeOpBlock({type: 'variable', suggest: 'Agent', then}); + } + + const BASE_THEN = { + 'title': {type: 'keyword', suggest: true, then: { + '': textToEnd, + }}, + 'theme': {type: 'keyword', suggest: true, then: { + '': { + type: 'string', + suggest: { + global: 'themes', + suffix: '\n', + }, + then: { + '': 0, + '\n': end, + }, + }, + }}, + 'headers': {type: 'keyword', suggest: true, then: { + 'none': {type: 'keyword', suggest: true, then: {}}, + 'cross': {type: 'keyword', suggest: true, then: {}}, + 'box': {type: 'keyword', suggest: true, then: {}}, + 'fade': {type: 'keyword', suggest: true, then: {}}, + 'bar': {type: 'keyword', suggest: true, then: {}}, + }}, + 'terminators': {type: 'keyword', suggest: true, then: { + 'none': {type: 'keyword', suggest: true, then: {}}, + 'cross': {type: 'keyword', suggest: true, then: {}}, + 'box': {type: 'keyword', suggest: true, then: {}}, + 'fade': {type: 'keyword', suggest: true, then: {}}, + 'bar': {type: 'keyword', suggest: true, then: {}}, + }}, + 'define': {type: 'keyword', suggest: true, then: { + '': aliasListToEnd, + 'as': CM_ERROR, + }}, + 'begin': {type: 'keyword', suggest: true, then: { + '': aliasListToEnd, + 'reference': refDef, + 'as': CM_ERROR, + }}, + 'end': {type: 'keyword', suggest: true, then: { + '': aliasListToEnd, + 'as': CM_ERROR, + '\n': end, + }}, + 'if': {type: 'keyword', suggest: true, then: { + '': textToEnd, + ':': {type: 'operator', suggest: true, then: { + '': textToEnd, + }}, + '\n': end, + }}, + 'else': {type: 'keyword', suggest: ['else\n', 'else if: '], then: { + 'if': {type: 'keyword', suggest: 'if: ', then: { + '': textToEnd, + ':': {type: 'operator', suggest: true, then: { + '': textToEnd, + }}, + }}, + '\n': end, + }}, + 'repeat': {type: 'keyword', suggest: true, then: { + '': textToEnd, + ':': {type: 'operator', suggest: true, then: { + '': textToEnd, + }}, + '\n': end, + }}, + 'note': {type: 'keyword', suggest: true, then: { + 'over': {type: 'keyword', suggest: true, then: { + '': agentListToText, + }}, + 'left': makeSideNote('left'), + 'right': makeSideNote('right'), + 'between': {type: 'keyword', suggest: true, then: { + '': agentList2ToText, + }}, + }}, + 'state': {type: 'keyword', suggest: 'state over ', then: { + 'over': {type: 'keyword', suggest: true, then: { + '': singleAgentToText, + }}, + }}, + 'text': {type: 'keyword', suggest: true, then: { + 'left': makeSideNote('left'), + 'right': makeSideNote('right'), + }}, + 'autolabel': {type: 'keyword', suggest: true, then: { + 'off': {type: 'keyword', suggest: true, then: {}}, + '': textToEnd, + }}, + 'simultaneously': {type: 'keyword', suggest: true, then: { + ':': {type: 'operator', suggest: true, then: {}}, + 'with': {type: 'keyword', suggest: true, then: { + '': {type: 'variable', suggest: 'Label', then: { + '': 0, + ':': {type: 'operator', suggest: true, then: {}}, + }}, + }}, + }}, + }; + + return (arrows) => { + return { + type: 'error line-error', + then: Object.assign({}, BASE_THEN, makeCMConnect(arrows)), + }; + }; + })()); + + function cmCappedToken(token, current) { + if(Object.keys(current.then).length > 0) { + return token + ' '; + } else { + return token + '\n'; + } + } + + function cmGetVarSuggestions(state, previous, current) { + if(typeof current.suggest === 'object' && current.suggest.global) { + return [current.suggest]; + } + if( + typeof current.suggest !== 'string' || + previous.suggest === current.suggest + ) { + return null; + } + return state['known' + current.suggest]; + } + + function cmGetSuggestions(state, token, previous, current) { + if(token === '') { + return cmGetVarSuggestions(state, previous, current); + } else if(current.suggest === true) { + return [cmCappedToken(token, current)]; + } else if(Array.isArray(current.suggest)) { + return current.suggest; + } else if(current.suggest) { + return [current.suggest]; + } else { + return null; + } + } + + function cmMakeCompletions(state, path) { + const comp = []; + const current = array.last(path); + Object.keys(current.then).forEach((token) => { + let next = current.then[token]; + if(typeof next === 'number') { + next = path[path.length - next - 1]; + } + array.mergeSets( + comp, + cmGetSuggestions(state, token, current, next) + ); + }); + return comp; + } + + function updateSuggestion(state, locals, token, {suggest, override}) { + if(locals.type) { + if(suggest !== locals.type) { + if(override) { + locals.type = override; + } + array.mergeSets( + state['known' + locals.type], + [locals.value + ' '] + ); + locals.type = ''; + locals.value = ''; + } + } + if(typeof suggest === 'string' && state['known' + suggest]) { + locals.type = suggest; + if(locals.value) { + locals.value += token.s; + } + locals.value += token.v; + } + } + + function cmCheckToken(state, eol, commands) { + const suggestions = { + type: '', + value: '', + }; + let current = commands; + const path = [current]; + + state.line.forEach((token, i) => { + if(i === state.line.length - 1) { + state.completions = cmMakeCompletions(state, path); + } + const keywordToken = token.q ? '' : token.v; + const found = current.then[keywordToken] || current.then['']; + if(typeof found === 'number') { + path.length -= found; + } else { + path.push(found || CM_ERROR); + } + current = array.last(path); + updateSuggestion(state, suggestions, token, current); + }); + if(eol) { + updateSuggestion(state, suggestions, null, {}); + } + state.nextCompletions = cmMakeCompletions(state, path); + state.valid = ( + Boolean(current.then['\n']) || + Object.keys(current.then).length === 0 + ); + return current.type; + } + + function getInitialToken(block) { + const baseToken = (block.baseToken || {}); + return { + value: baseToken.v || '', + quoted: baseToken.q || false, + }; + } + + return class Mode { + constructor(tokenDefinitions, arrows) { + this.tokenDefinitions = tokenDefinitions; + this.commands = makeCommands(arrows); + this.lineComment = '#'; + } + + startState() { + return { + currentType: -1, + current: '', + currentSpace: '', + currentQuoted: false, + knownAgent: [], + knownLabel: [], + beginCompletions: cmMakeCompletions({}, [this.commands]), + completions: [], + nextCompletions: [], + valid: true, + line: [], + indent: 0, + }; + } + + _matchPattern(stream, pattern, consume) { + if(!pattern) { + return null; + } + pattern.lastIndex = 0; + return stream.match(pattern, consume); + } + + _tokenBegin(stream, state) { + state.currentSpace = ''; + let lastChar = ''; + while(true) { + if(stream.eol()) { + return false; + } + state.currentSpace += lastChar; + for(let i = 0; i < this.tokenDefinitions.length; ++ i) { + const block = this.tokenDefinitions[i]; + if(this._matchPattern(stream, block.start, true)) { + state.currentType = i; + const {value, quoted} = getInitialToken(block); + state.current = value; + state.currentQuoted = quoted; + return true; + } + } + lastChar = stream.next(); + } + } + + _tokenCheckEscape(stream, state, block) { + const match = this._matchPattern(stream, block.escape, true); + if(match) { + state.current += block.escapeWith(match); + } + } + + _addToken(state) { + state.line.push({ + v: state.current, + s: state.currentSpace, + q: state.currentQuoted, + }); + } + + _tokenEndFound(stream, state, block) { + state.currentType = -1; + if(block.omit) { + return 'comment'; + } + this._addToken(state); + return cmCheckToken(state, stream.eol(), this.commands); + } + + _tokenEOLFound(stream, state, block) { + state.current += '\n'; + if(block.omit) { + return 'comment'; + } + this._addToken(state); + const type = cmCheckToken(state, false, this.commands); + state.line.pop(); + return type; + } + + _tokenEnd(stream, state) { + while(true) { + const block = this.tokenDefinitions[state.currentType]; + this._tokenCheckEscape(stream, state, block); + if(!block.end || this._matchPattern(stream, block.end, true)) { + return this._tokenEndFound(stream, state, block); + } + if(stream.eol()) { + return this._tokenEOLFound(stream, state, block); + } + state.current += stream.next(); + } + } + + token(stream, state) { + state.completions = state.nextCompletions; + if(stream.sol() && state.currentType === -1) { + state.line.length = 0; + } + let type = ''; + if(state.currentType !== -1 || this._tokenBegin(stream, state)) { + type = this._tokenEnd(stream, state); + } + if(state.currentType === -1 && stream.eol() && !state.valid) { + return 'line-error ' + type; + } else { + return type; + } + } + + indent(state) { + return state.indent; + } + }; +}); + +define('sequence/Tokeniser',['./CodeMirrorMode'], (CMMode) => { + 'use strict'; + + function execAt(str, reg, i) { + reg.lastIndex = i; + return reg.exec(str); + } + + function unescape(match) { + const c = match[1]; + if(c === 'n') { + return '\n'; + } + return match[1]; + } + + const TOKENS = [ + {start: /#/y, end: /(?=\n)|$/y, omit: true}, + { + start: /"/y, + end: /"/y, + escape: /\\(.)/y, + escapeWith: unescape, + baseToken: {q: true}, + }, + { + start: /'/y, + end: /'/y, + escape: /\\(.)/y, + escapeWith: + unescape, + baseToken: {q: true}, + }, + {start: /(?=[^ \t\r\n:+\-~*!<>,])/y, end: /(?=[ \t\r\n:+\-~*!<>,])|$/y}, + {start: /(?=[\-~<>])/y, end: /(?=[^\-~<>])|$/y}, + {start: /,/y, baseToken: {v: ','}}, + {start: /:/y, baseToken: {v: ':'}}, + {start: /!/y, baseToken: {v: '!'}}, + {start: /\+/y, baseToken: {v: '+'}}, + {start: /\*/y, baseToken: {v: '*'}}, + {start: /\n/y, baseToken: {v: '\n'}}, + ]; + + function tokFindBegin(src, i) { + for(let j = 0; j < TOKENS.length; ++ j) { + const block = TOKENS[j]; + const match = execAt(src, block.start, i); + if(match) { + return { + newBlock: block, + end: !block.end, + appendSpace: '', + appendValue: '', + skip: match[0].length, + }; + } + } + return { + newBlock: null, + end: false, + appendSpace: src[i], + appendValue: '', + skip: 1, + }; + } + + function tokContinuePart(src, i, block) { + if(block.escape) { + const match = execAt(src, block.escape, i); + if(match) { + return { + newBlock: null, + end: false, + appendSpace: '', + appendValue: block.escapeWith(match), + skip: match[0].length, + }; + } + } + const match = execAt(src, block.end, i); + if(match) { + return { + newBlock: null, + end: true, + appendSpace: '', + appendValue: '', + skip: match[0].length, + }; + } + return { + newBlock: null, + end: false, + appendSpace: '', + appendValue: src[i], + skip: 1, + }; + } + + function tokAdvance(src, i, block) { + if(block) { + return tokContinuePart(src, i, block); + } else { + return tokFindBegin(src, i); + } + } + + function copyPos(pos) { + return {i: pos.i, ln: pos.ln, ch: pos.ch}; + } + + function advancePos(pos, src, steps) { + for(let i = 0; i < steps; ++ i) { + ++ pos.ch; + if(src[pos.i + i] === '\n') { + ++ pos.ln; + pos.ch = 0; + } + } + pos.i += steps; + } + + class TokenState { + constructor(src) { + this.src = src; + this.block = null; + this.token = null; + this.pos = {i: 0, ln: 0, ch: 0}; + this.reset(); + } + + isOver() { + return this.pos.i > this.src.length; + } + + reset() { + this.token = {s: '', v: '', q: false, b: null, e: null}; + this.block = null; + } + + beginToken(advance) { + this.block = advance.newBlock; + Object.assign(this.token, this.block.baseToken); + this.token.b = copyPos(this.pos); + } + + endToken() { + let token = null; + if(!this.block.omit) { + this.token.e = copyPos(this.pos); + token = this.token; + } + this.reset(); + return token; + } + + advance() { + const advance = tokAdvance(this.src, this.pos.i, this.block); + + if(advance.newBlock) { + this.beginToken(advance); + } + + this.token.s += advance.appendSpace; + this.token.v += advance.appendValue; + advancePos(this.pos, this.src, advance.skip); + + if(advance.end) { + return this.endToken(); + } else { + return null; + } + } + } + + function posStr(pos) { + return 'line ' + (pos.ln + 1) + ', character ' + pos.ch; + } + + return class Tokeniser { + tokenise(src) { + const tokens = []; + const state = new TokenState(src); + while(!state.isOver()) { + const token = state.advance(); + if(token) { + tokens.push(token); + } + } + if(state.block) { + throw new Error( + 'Unterminated literal (began at ' + + posStr(state.token.b) + ')' + ); + } + return tokens; + } + + getCodeMirrorMode(arrows) { + return new CMMode(TOKENS, arrows); + } + + splitLines(tokens) { + const lines = []; + let line = []; + tokens.forEach((token) => { + if(!token.q && token.v === '\n') { + if(line.length > 0) { + lines.push(line); + line = []; + } + } else { + line.push(token); + } + }); + if(line.length > 0) { + lines.push(line); + } + return lines; + } + }; +}); + +define('sequence/LabelPatternParser',[],() => { + 'use strict'; + + const LABEL_PATTERN = /(.*?)<([^<>]*)>/g; + const DP_PATTERN = /\.([0-9]*)/; + + function countDP(value) { + const match = DP_PATTERN.exec(value); + if(!match || !match[1]) { + return 0; + } + return match[1].length; + } + + function parseCounter(args) { + let start = 1; + let inc = 1; + let dp = 0; + if(args[0]) { + start = Number(args[0]); + dp = Math.max(dp, countDP(args[0])); + } + if(args[1]) { + inc = Number(args[1]); + dp = Math.max(dp, countDP(args[1])); + } + return {start, inc, dp}; + } + + function parseToken(token) { + if(token === 'label') { + return {token: 'label'}; + } + + const p = token.indexOf(' '); + let type = null; + let args = null; + if(p === -1) { + type = token; + args = []; + } else { + type = token.substr(0, p); + args = token.substr(p + 1).split(','); + } + + if(type === 'inc') { + return parseCounter(args); + } + + return '<' + token + '>'; + } + + function parsePattern(raw) { + const pattern = []; + let match = null; + let end = 0; + LABEL_PATTERN.lastIndex = 0; + while((match = LABEL_PATTERN.exec(raw))) { + if(match[1]) { + pattern.push(match[1]); + } + if(match[2]) { + pattern.push(parseToken(match[2])); + } + end = LABEL_PATTERN.lastIndex; + } + const remainder = raw.substr(end); + if(remainder) { + pattern.push(remainder); + } + return pattern; + } + + return parsePattern; +}); + +define('sequence/CodeMirrorHints',['core/ArrayUtilities'], (array) => { + 'use strict'; + + const TRIMMER = /^([ \t]*)(.*)$/; + const SQUASH_START = /^[ \t\r\n:,]/; + const SQUASH_END = /[ \t\r\n]$/; + + function makeRanges(cm, line, chFrom, chTo) { + const ln = cm.getLine(line); + const ranges = { + wordFrom: {line: line, ch: chFrom}, + squashFrom: {line: line, ch: chFrom}, + wordTo: {line: line, ch: chTo}, + squashTo: {line: line, ch: chTo}, + }; + if(chFrom > 0 && ln[chFrom - 1] === ' ') { + ranges.squashFrom.ch --; + } + if(ln[chTo] === ' ') { + ranges.squashTo.ch ++; + } + return ranges; + } + + function makeHintItem(text, ranges) { + return { + text: text, + displayText: (text === '\n') ? '' : text.trim(), + className: (text === '\n') ? 'pick-virtual' : null, + from: SQUASH_START.test(text) ? ranges.squashFrom : ranges.wordFrom, + to: SQUASH_END.test(text) ? ranges.squashTo : ranges.wordTo, + }; + } + + function getGlobals({global, prefix = '', suffix = ''}, globals) { + const identified = globals[global]; + if(!identified) { + return []; + } + return identified.map((item) => (prefix + item + suffix)); + } + + function populateGlobals(suggestions, globals = {}) { + for(let i = 0; i < suggestions.length;) { + if(typeof suggestions[i] === 'object') { + const identified = getGlobals(suggestions[i], globals); + array.mergeSets(suggestions, identified); + suggestions.splice(i, 1); + } else { + ++ i; + } + } + } + + function getHints(cm, options) { + const cur = cm.getCursor(); + const token = cm.getTokenAt(cur); + let partial = token.string; + if(token.end > cur.ch) { + partial = partial.substr(0, cur.ch - token.start); + } + const parts = TRIMMER.exec(partial); + partial = parts[2]; + const from = token.start + parts[1].length; + + const continuation = (cur.ch > 0 && token.state.line.length > 0); + let comp = (continuation ? + token.state.completions : + token.state.beginCompletions + ); + if(!continuation) { + comp = comp.concat(token.state.knownAgent); + } + + populateGlobals(comp, cm.options.globals); + + const ranges = makeRanges(cm, cur.line, from, token.end); + let selfValid = false; + const list = (comp + .filter((opt) => opt.startsWith(partial)) + .map((opt) => { + if(opt === partial + ' ' && !options.completeSingle) { + selfValid = true; + return null; + } + return makeHintItem(opt, ranges); + }) + .filter((opt) => (opt !== null)) + ); + if(selfValid && list.length > 0) { + list.unshift(makeHintItem(partial + ' ', ranges)); + } + + return { + list, + from: ranges.wordFrom, + to: ranges.wordTo, + }; + } + + return { + getHints, + }; +}); + +define('sequence/Parser',[ + 'core/ArrayUtilities', + './Tokeniser', + './LabelPatternParser', + './CodeMirrorHints', +], ( + array, + Tokeniser, + labelPatternParser, + CMHints +) => { + 'use strict'; + + const BLOCK_TYPES = { + 'if': {type: 'block begin', mode: 'if', skip: []}, + 'else': {type: 'block split', mode: 'else', skip: ['if']}, + 'repeat': {type: 'block begin', mode: 'repeat', skip: []}, + }; + + const CONNECT_TYPES = ((() => { + const lTypes = [ + {tok: '', type: 0}, + {tok: '<', type: 1}, + {tok: '<<', type: 2}, + ]; + const mTypes = [ + {tok: '-', type: 'solid'}, + {tok: '--', type: 'dash'}, + {tok: '~', type: 'wave'}, + ]; + const rTypes = [ + {tok: '', type: 0}, + {tok: '>', type: 1}, + {tok: '>>', type: 2}, + ]; + const arrows = (array.combine([lTypes, mTypes, rTypes]) + .filter((arrow) => (arrow[0].type !== 0 || arrow[2].type !== 0)) + ); + + const types = new Map(); + + arrows.forEach((arrow) => { + types.set(arrow.map((part) => part.tok).join(''), { + line: arrow[1].type, + left: arrow[0].type, + right: arrow[2].type, + }); + }); + + return types; + })()); + + const CONNECT_AGENT_FLAGS = { + '*': 'begin', + '+': 'start', + '-': 'stop', + '!': 'end', + }; + + const TERMINATOR_TYPES = [ + 'none', + 'box', + 'cross', + 'fade', + 'bar', + ]; + + const NOTE_TYPES = { + 'text': { + mode: 'text', + types: { + 'left': {type: 'note left', skip: ['of'], min: 0, max: null}, + 'right': {type: 'note right', skip: ['of'], min: 0, max: null}, + }, + }, + 'note': { + mode: 'note', + types: { + 'over': {type: 'note over', skip: [], min: 0, max: null}, + 'left': {type: 'note left', skip: ['of'], min: 0, max: null}, + 'right': {type: 'note right', skip: ['of'], min: 0, max: null}, + 'between': {type: 'note between', skip: [], min: 2, max: null}, + }, + }, + 'state': { + mode: 'state', + types: { + 'over': {type: 'note over', skip: [], min: 1, max: 1}, + }, + }, + }; + + const AGENT_MANIPULATION_TYPES = { + 'define': {type: 'agent define'}, + 'begin': {type: 'agent begin', mode: 'box'}, + 'end': {type: 'agent end', mode: 'cross'}, + }; + + function makeError(message, token = null) { + let suffix = ''; + if(token) { + suffix = ( + ' at line ' + (token.b.ln + 1) + + ', character ' + token.b.ch + ); + } + return new Error(message + suffix); + } + + function errToken(line, pos) { + if(pos < line.length) { + return line[pos]; + } + const last = array.last(line); + if(!last) { + return null; + } + return {b: last.e}; + } + + function joinLabel(line, begin = 0, end = null) { + if(end === null) { + end = line.length; + } + if(end <= begin) { + return ''; + } + let result = line[begin].v; + for(let i = begin + 1; i < end; ++ i) { + result += line[i].s + line[i].v; + } + return result; + } + + function tokenKeyword(token) { + if(!token || token.q) { + return null; + } + return token.v; + } + + function skipOver(line, start, skip, error = null) { + for(let i = 0; i < skip.length; ++ i) { + const expected = skip[i]; + const token = line[start + i]; + if(tokenKeyword(token) !== expected) { + if(error) { + throw makeError( + error + '; expected "' + expected + '"', + token + ); + } else { + return start; + } + } + } + return start + skip.length; + } + + function findToken(line, token, start = 0) { + for(let i = start; i < line.length; ++ i) { + if(tokenKeyword(line[i]) === token) { + return i; + } + } + return -1; + } + + function readAgentAlias(line, start, end, enableAlias) { + let aliasSep = -1; + if(enableAlias) { + aliasSep = findToken(line, 'as', start); + } + if(aliasSep === -1 || aliasSep >= end) { + aliasSep = end; + } + if(start >= aliasSep) { + throw makeError('Missing agent name', errToken(line, start)); + } + return { + name: joinLabel(line, start, aliasSep), + alias: joinLabel(line, aliasSep + 1, end), + }; + } + + function readAgent(line, start, end, { + flagTypes = {}, + aliases = false, + } = {}) { + const flags = []; + let p = start; + for(; p < end; ++ p) { + const token = line[p]; + const rawFlag = tokenKeyword(token); + const flag = flagTypes[rawFlag]; + if(flag) { + if(flags.includes(flag)) { + throw makeError('Duplicate agent flag: ' + rawFlag, token); + } + flags.push(flag); + } else { + break; + } + } + const {name, alias} = readAgentAlias(line, p, end, aliases); + return { + name, + alias, + flags, + }; + } + + function readAgentList(line, start, end, readAgentOpts) { + const list = []; + let currentStart = -1; + for(let i = start; i < end; ++ i) { + const token = line[i]; + if(tokenKeyword(token) === ',') { + if(currentStart !== -1) { + list.push(readAgent(line, currentStart, i, readAgentOpts)); + currentStart = -1; + } + } else if(currentStart === -1) { + currentStart = i; + } + } + if(currentStart !== -1) { + list.push(readAgent(line, currentStart, end, readAgentOpts)); + } + return list; + } + + const PARSERS = [ + (line, meta) => { // title + if(tokenKeyword(line[0]) !== 'title') { + return null; + } + + meta.title = joinLabel(line, 1); + return true; + }, + + (line, meta) => { // theme + if(tokenKeyword(line[0]) !== 'theme') { + return null; + } + + meta.theme = joinLabel(line, 1); + return true; + }, + + (line, meta) => { // terminators + if(tokenKeyword(line[0]) !== 'terminators') { + return null; + } + + const type = tokenKeyword(line[1]); + if(!type) { + throw makeError('Unspecified termination', line[0]); + } + if(TERMINATOR_TYPES.indexOf(type) === -1) { + throw makeError('Unknown termination "' + type + '"', line[1]); + } + meta.terminators = type; + return true; + }, + + (line, meta) => { // headers + if(tokenKeyword(line[0]) !== 'headers') { + return null; + } + + const type = tokenKeyword(line[1]); + if(!type) { + throw makeError('Unspecified header', line[0]); + } + if(TERMINATOR_TYPES.indexOf(type) === -1) { + throw makeError('Unknown header "' + type + '"', line[1]); + } + meta.headers = type; + return true; + }, + + (line) => { // autolabel + if(tokenKeyword(line[0]) !== 'autolabel') { + return null; + } + + let raw = null; + if(tokenKeyword(line[1]) === 'off') { + raw = '