Fix autocomplete for multi-word names, and minor tweaks. Add testing for CodeMirror integration [#34]

This commit is contained in:
David Evans 2018-01-21 00:44:52 +00:00
parent ece615e2a0
commit 64e3f6aa03
7 changed files with 1029 additions and 253 deletions

View File

@ -626,32 +626,44 @@ define('sequence/CodeMirrorMode',['core/ArrayUtilities'], (array) => {
const end = {type: '', suggest: '\n', then: {}};
const hiddenEnd = {type: '', then: {}};
function textTo(exit) {
return {type: 'string', then: Object.assign({'': 0}, exit)};
function textTo(exit, suggest) {
return {
type: 'string',
suggest,
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,
const aliasListToEnd = {
type: 'variable',
suggest: {known: 'Agent'},
then: {
'': 0,
'\n': end,
',': {type: 'operator', suggest: true, then: {'': 1}},
'as': {type: 'keyword', suggest: true, then: {
'': {type: 'variable', suggest: {known: '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}},
}
)};
return {
type: 'variable',
suggest: {known: 'Agent'},
then: Object.assign({},
exit,
{
'': 0,
',': {type: 'operator', suggest: true, then: {'': 1}},
}
),
};
}
const colonTextToEnd = {
@ -662,32 +674,50 @@ define('sequence/CodeMirrorMode',['core/ArrayUtilities'], (array) => {
const agentListToText = agentListTo({
':': colonTextToEnd,
});
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,
':': colonTextToEnd,
}};
const agentToOptText = {type: 'variable', suggest: 'Agent', then: {
'': 0,
':': {type: 'operator', suggest: true, then: {
'': textToEnd,
'\n': hiddenEnd,
}},
'\n': end,
}};
const agentList2ToText = {
type: 'variable',
suggest: {known: 'Agent'},
then: {
'': 0,
',': {type: 'operator', suggest: true, then: {
'': agentListToText,
}},
':': CM_ERROR,
},
};
const singleAgentToText = {
type: 'variable',
suggest: {known: 'Agent'},
then: {
'': 0,
',': CM_ERROR,
':': colonTextToEnd,
},
};
const agentToOptText = {
type: 'variable',
suggest: {known: '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,
}},
'': {
type: 'variable',
suggest: {known: 'Agent'},
then: {
'': 0,
'\n': end,
},
},
}},
}),
}},
@ -774,7 +804,7 @@ define('sequence/CodeMirrorMode',['core/ArrayUtilities'], (array) => {
then: {},
};
return makeOpBlock(
{type: 'variable', suggest: 'Agent', then},
{type: 'variable', suggest: {known: 'Agent'}, then},
then
);
}
@ -868,12 +898,16 @@ define('sequence/CodeMirrorMode',['core/ArrayUtilities'], (array) => {
}},
'autolabel': {type: 'keyword', suggest: true, then: {
'off': {type: 'keyword', suggest: true, then: {}},
'': textToEnd,
'': textTo({'\n': end}, [
{v: '<label>', suffix: '\n', q: true},
{v: '[<inc>] <label>', suffix: '\n', q: true},
{v: '[<inc 1,0.01>] <label>', suffix: '\n', q: true},
]),
}},
'simultaneously': {type: 'keyword', suggest: true, then: {
':': {type: 'operator', suggest: true, then: {}},
'with': {type: 'keyword', suggest: true, then: {
'': {type: 'variable', suggest: 'Label', then: {
'': {type: 'variable', suggest: {known: 'Label'}, then: {
'': 0,
':': {type: 'operator', suggest: true, then: {}},
}},
@ -897,31 +931,27 @@ define('sequence/CodeMirrorMode',['core/ArrayUtilities'], (array) => {
}
}
function cmGetVarSuggestions(state, previous, current) {
if(typeof current.suggest === 'object' && current.suggest.global) {
return [current.suggest];
function cmGetSuggestions(state, token, current) {
let suggestions = current.suggest;
if(!Array.isArray(suggestions)) {
suggestions = [suggestions];
}
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.map((v) => ({v, q: false}));
} else if(current.suggest) {
return [{v: current.suggest, q: false}];
} else {
return null;
}
return array.flatMap(suggestions, (suggest) => {
if(suggest === true) {
return [cmCappedToken(token, current)];
} else if(typeof suggest === 'object') {
if(suggest.known) {
return state['known' + suggest.known] || [];
} else {
return [suggest];
}
} else if(typeof suggest === 'string' && suggest) {
return [{v: suggest, q: (token === '')}];
} else {
return [];
}
});
}
function cmMakeCompletions(state, path) {
@ -934,7 +964,7 @@ define('sequence/CodeMirrorMode',['core/ArrayUtilities'], (array) => {
}
array.mergeSets(
comp,
cmGetSuggestions(state, token, current, next),
cmGetSuggestions(state, token, next),
suggestionsEqual
);
});
@ -942,22 +972,24 @@ define('sequence/CodeMirrorMode',['core/ArrayUtilities'], (array) => {
}
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],
[{v: locals.value, suffix: ' ', q: true}],
suggestionsEqual
);
locals.type = '';
locals.value = '';
}
let known = null;
if(typeof suggest === 'object' && suggest.known) {
known = suggest.known;
}
if(typeof suggest === 'string' && state['known' + suggest]) {
locals.type = suggest;
if(locals.type && known !== locals.type) {
if(override) {
locals.type = override;
}
array.mergeSets(
state['known' + locals.type],
[{v: locals.value, suffix: ' ', q: true}],
suggestionsEqual
);
locals.type = '';
locals.value = '';
}
if(known) {
locals.type = known;
if(locals.value) {
locals.value += token.s;
}
@ -978,7 +1010,13 @@ define('sequence/CodeMirrorMode',['core/ArrayUtilities'], (array) => {
state.completions = cmMakeCompletions(state, path);
}
const keywordToken = token.q ? '' : token.v;
const found = current.then[keywordToken] || current.then[''];
let found = current.then[keywordToken];
if(found === undefined) {
found = current.then[''];
state.isVar = true;
} else {
state.isVar = token.q;
}
if(typeof found === 'number') {
path.length -= found;
} else {
@ -1025,6 +1063,7 @@ define('sequence/CodeMirrorMode',['core/ArrayUtilities'], (array) => {
completions: [],
nextCompletions: [],
valid: true,
isVar: true,
line: [],
indent: 0,
};
@ -1119,6 +1158,7 @@ define('sequence/CodeMirrorMode',['core/ArrayUtilities'], (array) => {
token(stream, state) {
state.completions = state.nextCompletions;
state.isVar = true;
if(stream.sol() && state.currentType === -1) {
state.line.length = 0;
}
@ -5646,7 +5686,8 @@ define('sequence/CodeMirrorHints',['core/ArrayUtilities'], (array) => {
const TRIMMER = /^([ \t]*)(.*)$/;
const SQUASH_START = /^[ \t\r\n:,]/;
const SQUASH_END = /[ \t\r\n]$/;
const REQUIRED_QUOTED = /[\r\n:,"]/;
const ONGOING_QUOTE = /^"(\\.|[^"])*$/;
const REQUIRED_QUOTED = /[\r\n:,"<>\-~]/;
const QUOTE_ESCAPE = /["\\]/g;
function suggestionsEqual(a, b) {
@ -5658,29 +5699,36 @@ define('sequence/CodeMirrorHints',['core/ArrayUtilities'], (array) => {
);
}
function makeRanges(cm, line, chFrom, chTo) {
function makeRangeFrom(cm, line, chFrom) {
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},
word: {line: line, ch: chFrom},
squash: {line: line, ch: chFrom},
};
if(chFrom > 0 && ln[chFrom - 1] === ' ') {
ranges.squashFrom.ch --;
ranges.squash.ch --;
}
return ranges;
}
function makeRangeTo(cm, line, chTo) {
const ln = cm.getLine(line);
const ranges = {
word: {line: line, ch: chTo},
squash: {line: line, ch: chTo},
};
if(ln[chTo] === ' ') {
ranges.squashTo.ch ++;
ranges.squash.ch ++;
}
return ranges;
}
function wrapQuote(entry, quote) {
if(!quote && entry.q && REQUIRED_QUOTED.test(entry.v)) {
if(!quote && REQUIRED_QUOTED.test(entry.v)) {
quote = '"';
}
let inner = entry.v;
if(quote) {
if(quote && entry.q) {
inner = quote + inner.replace(QUOTE_ESCAPE, '\\$&') + quote;
}
return (entry.prefix || '') + inner + (entry.suffix || '');
@ -5688,15 +5736,26 @@ define('sequence/CodeMirrorHints',['core/ArrayUtilities'], (array) => {
function makeHintItem(entry, ranges, quote) {
const quoted = wrapQuote(entry, quote);
return {
text: quoted,
displayText: (quoted === '\n') ? '<END>' : quoted.trim(),
className: (quoted === '\n') ? 'pick-virtual' : null,
from: SQUASH_START.test(quoted) ?
ranges.squashFrom : ranges.wordFrom,
to: SQUASH_END.test(quoted) ?
ranges.squashTo : ranges.wordTo,
};
const from = entry.q ? ranges.fromVar : ranges.fromKey;
if(quoted === '\n') {
return {
text: '\n',
displayText: '<END>',
className: 'pick-virtual',
from: from.squash,
to: ranges.to.squash,
displayFrom: null,
};
} else {
return {
text: quoted,
displayText: quoted.trim(),
className: null,
from: SQUASH_START.test(quoted) ? from.squash : from.word,
to: SQUASH_END.test(quoted) ? ranges.to.squash : ranges.to.word,
displayFrom: from.word,
};
}
}
function getGlobals({global, prefix = '', suffix = ''}, globals) {
@ -5719,29 +5778,89 @@ define('sequence/CodeMirrorHints',['core/ArrayUtilities'], (array) => {
}
}
function getPartial(cur, token) {
let partial = token.string;
if(token.end > cur.ch) {
partial = partial.substr(0, cur.ch - token.start);
function getTokensUpTo(cm, pos) {
const tokens = cm.getLineTokens(pos.line);
for(let p = 0; p < tokens.length; ++ p) {
if(tokens[p].end >= pos.ch) {
tokens.length = p + 1;
break;
}
}
return tokens;
}
function getVariablePartial(tokens, pos) {
let lastVariable = 0;
let partial = '';
let start = 0;
let end = 0;
tokens.forEach((token, p) => {
if(token.state.isVar) {
partial += token.string;
end = token.end;
} else {
lastVariable = p + 1;
partial = '';
start = token.end;
}
});
if(end > pos.ch) {
partial = partial.substr(0, pos.ch - start);
}
const parts = TRIMMER.exec(partial);
partial = parts[2];
let quote = '';
if(partial[0] === '"') {
if(ONGOING_QUOTE.test(partial)) {
quote = partial[0];
partial = partial.substr(1);
}
return {
partial,
quote,
from: token.start + parts[1].length,
from: start + parts[1].length,
valid: end >= start,
};
}
function getKeywordPartial(token, pos) {
let partial = token.string;
if(token.end > pos.ch) {
partial = partial.substr(0, pos.ch - token.start);
}
const parts = TRIMMER.exec(partial);
return {
partial: parts[2],
from: token.start + parts[1].length,
valid: true,
};
}
function suggestDropdownLocation(list, fromKey) {
let p = null;
list.forEach(({displayFrom}) => {
if(displayFrom) {
if(
!p ||
displayFrom.line > p.line ||
(displayFrom.line === p.line && displayFrom.ch > p.ch)
) {
p = displayFrom;
}
}
});
return p || fromKey.word;
}
function partialMatch(v, p) {
return p.valid && v.startsWith(p.partial);
}
function getHints(cm, options) {
const cur = cm.getCursor();
const token = cm.getTokenAt(cur);
const {partial, from, quote} = getPartial(cur, token);
const tokens = getTokensUpTo(cm, cur);
const token = array.last(tokens) || cm.getTokenAt(cur);
const pVar = getVariablePartial(tokens, cur);
const pKey = getKeywordPartial(token, cur);
const continuation = (cur.ch > 0 && token.state.line.length > 0);
let comp = (continuation ?
@ -5754,31 +5873,36 @@ define('sequence/CodeMirrorHints',['core/ArrayUtilities'], (array) => {
populateGlobals(comp, cm.options.globals);
const ranges = makeRanges(cm, cur.line, from, token.end);
let selfValid = false;
const ranges = {
fromVar: makeRangeFrom(cm, cur.line, pVar.from),
fromKey: makeRangeFrom(cm, cur.line, pKey.from),
to: makeRangeTo(cm, cur.line, token.end),
};
let selfValid = null;
const list = (comp
.filter(({v, q}) => (q || !quote) && v.startsWith(partial))
.filter((o) => (
(o.q || !pVar.quote) &&
partialMatch(o.v, o.q ? pVar : pKey)
))
.map((o) => {
if(o.v === partial + ' ' && !options.completeSingle) {
selfValid = true;
return null;
if(!options.completeSingle) {
if(o.v === (o.q ? pVar : pKey).partial) {
selfValid = o;
return null;
}
}
return makeHintItem(o, ranges, quote);
return makeHintItem(o, ranges, pVar.quote);
})
.filter((opt) => (opt !== null))
);
if(selfValid && list.length > 0) {
list.unshift(makeHintItem(
{v: partial, suffix: ' ', q: false},
ranges,
quote
));
list.unshift(makeHintItem(selfValid, ranges, pVar.quote));
}
return {
list,
from: ranges.wordFrom,
to: ranges.wordTo,
from: suggestDropdownLocation(list, ranges.fromKey),
to: ranges.to.word,
};
}

File diff suppressed because one or more lines are too long

View File

@ -4,7 +4,8 @@ define(['core/ArrayUtilities'], (array) => {
const TRIMMER = /^([ \t]*)(.*)$/;
const SQUASH_START = /^[ \t\r\n:,]/;
const SQUASH_END = /[ \t\r\n]$/;
const REQUIRED_QUOTED = /[\r\n:,"]/;
const ONGOING_QUOTE = /^"(\\.|[^"])*$/;
const REQUIRED_QUOTED = /[\r\n:,"<>\-~]/;
const QUOTE_ESCAPE = /["\\]/g;
function suggestionsEqual(a, b) {
@ -16,29 +17,36 @@ define(['core/ArrayUtilities'], (array) => {
);
}
function makeRanges(cm, line, chFrom, chTo) {
function makeRangeFrom(cm, line, chFrom) {
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},
word: {line: line, ch: chFrom},
squash: {line: line, ch: chFrom},
};
if(chFrom > 0 && ln[chFrom - 1] === ' ') {
ranges.squashFrom.ch --;
ranges.squash.ch --;
}
return ranges;
}
function makeRangeTo(cm, line, chTo) {
const ln = cm.getLine(line);
const ranges = {
word: {line: line, ch: chTo},
squash: {line: line, ch: chTo},
};
if(ln[chTo] === ' ') {
ranges.squashTo.ch ++;
ranges.squash.ch ++;
}
return ranges;
}
function wrapQuote(entry, quote) {
if(!quote && entry.q && REQUIRED_QUOTED.test(entry.v)) {
if(!quote && REQUIRED_QUOTED.test(entry.v)) {
quote = '"';
}
let inner = entry.v;
if(quote) {
if(quote && entry.q) {
inner = quote + inner.replace(QUOTE_ESCAPE, '\\$&') + quote;
}
return (entry.prefix || '') + inner + (entry.suffix || '');
@ -46,15 +54,26 @@ define(['core/ArrayUtilities'], (array) => {
function makeHintItem(entry, ranges, quote) {
const quoted = wrapQuote(entry, quote);
return {
text: quoted,
displayText: (quoted === '\n') ? '<END>' : quoted.trim(),
className: (quoted === '\n') ? 'pick-virtual' : null,
from: SQUASH_START.test(quoted) ?
ranges.squashFrom : ranges.wordFrom,
to: SQUASH_END.test(quoted) ?
ranges.squashTo : ranges.wordTo,
};
const from = entry.q ? ranges.fromVar : ranges.fromKey;
if(quoted === '\n') {
return {
text: '\n',
displayText: '<END>',
className: 'pick-virtual',
from: from.squash,
to: ranges.to.squash,
displayFrom: null,
};
} else {
return {
text: quoted,
displayText: quoted.trim(),
className: null,
from: SQUASH_START.test(quoted) ? from.squash : from.word,
to: SQUASH_END.test(quoted) ? ranges.to.squash : ranges.to.word,
displayFrom: from.word,
};
}
}
function getGlobals({global, prefix = '', suffix = ''}, globals) {
@ -77,29 +96,89 @@ define(['core/ArrayUtilities'], (array) => {
}
}
function getPartial(cur, token) {
let partial = token.string;
if(token.end > cur.ch) {
partial = partial.substr(0, cur.ch - token.start);
function getTokensUpTo(cm, pos) {
const tokens = cm.getLineTokens(pos.line);
for(let p = 0; p < tokens.length; ++ p) {
if(tokens[p].end >= pos.ch) {
tokens.length = p + 1;
break;
}
}
return tokens;
}
function getVariablePartial(tokens, pos) {
let lastVariable = 0;
let partial = '';
let start = 0;
let end = 0;
tokens.forEach((token, p) => {
if(token.state.isVar) {
partial += token.string;
end = token.end;
} else {
lastVariable = p + 1;
partial = '';
start = token.end;
}
});
if(end > pos.ch) {
partial = partial.substr(0, pos.ch - start);
}
const parts = TRIMMER.exec(partial);
partial = parts[2];
let quote = '';
if(partial[0] === '"') {
if(ONGOING_QUOTE.test(partial)) {
quote = partial[0];
partial = partial.substr(1);
}
return {
partial,
quote,
from: token.start + parts[1].length,
from: start + parts[1].length,
valid: end >= start,
};
}
function getKeywordPartial(token, pos) {
let partial = token.string;
if(token.end > pos.ch) {
partial = partial.substr(0, pos.ch - token.start);
}
const parts = TRIMMER.exec(partial);
return {
partial: parts[2],
from: token.start + parts[1].length,
valid: true,
};
}
function suggestDropdownLocation(list, fromKey) {
let p = null;
list.forEach(({displayFrom}) => {
if(displayFrom) {
if(
!p ||
displayFrom.line > p.line ||
(displayFrom.line === p.line && displayFrom.ch > p.ch)
) {
p = displayFrom;
}
}
});
return p || fromKey.word;
}
function partialMatch(v, p) {
return p.valid && v.startsWith(p.partial);
}
function getHints(cm, options) {
const cur = cm.getCursor();
const token = cm.getTokenAt(cur);
const {partial, from, quote} = getPartial(cur, token);
const tokens = getTokensUpTo(cm, cur);
const token = array.last(tokens) || cm.getTokenAt(cur);
const pVar = getVariablePartial(tokens, cur);
const pKey = getKeywordPartial(token, cur);
const continuation = (cur.ch > 0 && token.state.line.length > 0);
let comp = (continuation ?
@ -112,31 +191,36 @@ define(['core/ArrayUtilities'], (array) => {
populateGlobals(comp, cm.options.globals);
const ranges = makeRanges(cm, cur.line, from, token.end);
let selfValid = false;
const ranges = {
fromVar: makeRangeFrom(cm, cur.line, pVar.from),
fromKey: makeRangeFrom(cm, cur.line, pKey.from),
to: makeRangeTo(cm, cur.line, token.end),
};
let selfValid = null;
const list = (comp
.filter(({v, q}) => (q || !quote) && v.startsWith(partial))
.filter((o) => (
(o.q || !pVar.quote) &&
partialMatch(o.v, o.q ? pVar : pKey)
))
.map((o) => {
if(o.v === partial + ' ' && !options.completeSingle) {
selfValid = true;
return null;
if(!options.completeSingle) {
if(o.v === (o.q ? pVar : pKey).partial) {
selfValid = o;
return null;
}
}
return makeHintItem(o, ranges, quote);
return makeHintItem(o, ranges, pVar.quote);
})
.filter((opt) => (opt !== null))
);
if(selfValid && list.length > 0) {
list.unshift(makeHintItem(
{v: partial, suffix: ' ', q: false},
ranges,
quote
));
list.unshift(makeHintItem(selfValid, ranges, pVar.quote));
}
return {
list,
from: ranges.wordFrom,
to: ranges.wordTo,
from: suggestDropdownLocation(list, ranges.fromKey),
to: ranges.to.word,
};
}

View File

@ -23,32 +23,44 @@ define(['core/ArrayUtilities'], (array) => {
const end = {type: '', suggest: '\n', then: {}};
const hiddenEnd = {type: '', then: {}};
function textTo(exit) {
return {type: 'string', then: Object.assign({'': 0}, exit)};
function textTo(exit, suggest) {
return {
type: 'string',
suggest,
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,
const aliasListToEnd = {
type: 'variable',
suggest: {known: 'Agent'},
then: {
'': 0,
'\n': end,
',': {type: 'operator', suggest: true, then: {'': 1}},
'as': {type: 'keyword', suggest: true, then: {
'': {type: 'variable', suggest: {known: '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}},
}
)};
return {
type: 'variable',
suggest: {known: 'Agent'},
then: Object.assign({},
exit,
{
'': 0,
',': {type: 'operator', suggest: true, then: {'': 1}},
}
),
};
}
const colonTextToEnd = {
@ -59,32 +71,50 @@ define(['core/ArrayUtilities'], (array) => {
const agentListToText = agentListTo({
':': colonTextToEnd,
});
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,
':': colonTextToEnd,
}};
const agentToOptText = {type: 'variable', suggest: 'Agent', then: {
'': 0,
':': {type: 'operator', suggest: true, then: {
'': textToEnd,
'\n': hiddenEnd,
}},
'\n': end,
}};
const agentList2ToText = {
type: 'variable',
suggest: {known: 'Agent'},
then: {
'': 0,
',': {type: 'operator', suggest: true, then: {
'': agentListToText,
}},
':': CM_ERROR,
},
};
const singleAgentToText = {
type: 'variable',
suggest: {known: 'Agent'},
then: {
'': 0,
',': CM_ERROR,
':': colonTextToEnd,
},
};
const agentToOptText = {
type: 'variable',
suggest: {known: '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,
}},
'': {
type: 'variable',
suggest: {known: 'Agent'},
then: {
'': 0,
'\n': end,
},
},
}},
}),
}},
@ -171,7 +201,7 @@ define(['core/ArrayUtilities'], (array) => {
then: {},
};
return makeOpBlock(
{type: 'variable', suggest: 'Agent', then},
{type: 'variable', suggest: {known: 'Agent'}, then},
then
);
}
@ -265,12 +295,16 @@ define(['core/ArrayUtilities'], (array) => {
}},
'autolabel': {type: 'keyword', suggest: true, then: {
'off': {type: 'keyword', suggest: true, then: {}},
'': textToEnd,
'': textTo({'\n': end}, [
{v: '<label>', suffix: '\n', q: true},
{v: '[<inc>] <label>', suffix: '\n', q: true},
{v: '[<inc 1,0.01>] <label>', suffix: '\n', q: true},
]),
}},
'simultaneously': {type: 'keyword', suggest: true, then: {
':': {type: 'operator', suggest: true, then: {}},
'with': {type: 'keyword', suggest: true, then: {
'': {type: 'variable', suggest: 'Label', then: {
'': {type: 'variable', suggest: {known: 'Label'}, then: {
'': 0,
':': {type: 'operator', suggest: true, then: {}},
}},
@ -294,31 +328,27 @@ define(['core/ArrayUtilities'], (array) => {
}
}
function cmGetVarSuggestions(state, previous, current) {
if(typeof current.suggest === 'object' && current.suggest.global) {
return [current.suggest];
function cmGetSuggestions(state, token, current) {
let suggestions = current.suggest;
if(!Array.isArray(suggestions)) {
suggestions = [suggestions];
}
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.map((v) => ({v, q: false}));
} else if(current.suggest) {
return [{v: current.suggest, q: false}];
} else {
return null;
}
return array.flatMap(suggestions, (suggest) => {
if(suggest === true) {
return [cmCappedToken(token, current)];
} else if(typeof suggest === 'object') {
if(suggest.known) {
return state['known' + suggest.known] || [];
} else {
return [suggest];
}
} else if(typeof suggest === 'string' && suggest) {
return [{v: suggest, q: (token === '')}];
} else {
return [];
}
});
}
function cmMakeCompletions(state, path) {
@ -331,7 +361,7 @@ define(['core/ArrayUtilities'], (array) => {
}
array.mergeSets(
comp,
cmGetSuggestions(state, token, current, next),
cmGetSuggestions(state, token, next),
suggestionsEqual
);
});
@ -339,22 +369,24 @@ define(['core/ArrayUtilities'], (array) => {
}
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],
[{v: locals.value, suffix: ' ', q: true}],
suggestionsEqual
);
locals.type = '';
locals.value = '';
}
let known = null;
if(typeof suggest === 'object' && suggest.known) {
known = suggest.known;
}
if(typeof suggest === 'string' && state['known' + suggest]) {
locals.type = suggest;
if(locals.type && known !== locals.type) {
if(override) {
locals.type = override;
}
array.mergeSets(
state['known' + locals.type],
[{v: locals.value, suffix: ' ', q: true}],
suggestionsEqual
);
locals.type = '';
locals.value = '';
}
if(known) {
locals.type = known;
if(locals.value) {
locals.value += token.s;
}
@ -375,7 +407,13 @@ define(['core/ArrayUtilities'], (array) => {
state.completions = cmMakeCompletions(state, path);
}
const keywordToken = token.q ? '' : token.v;
const found = current.then[keywordToken] || current.then[''];
let found = current.then[keywordToken];
if(found === undefined) {
found = current.then[''];
state.isVar = true;
} else {
state.isVar = token.q;
}
if(typeof found === 'number') {
path.length -= found;
} else {
@ -422,6 +460,7 @@ define(['core/ArrayUtilities'], (array) => {
completions: [],
nextCompletions: [],
valid: true,
isVar: true,
line: [],
indent: 0,
};
@ -516,6 +555,7 @@ define(['core/ArrayUtilities'], (array) => {
token(stream, state) {
state.completions = state.nextCompletions;
state.isVar = true;
if(stream.sol() && state.currentType === -1) {
state.line.length = 0;
}

View File

@ -0,0 +1,521 @@
defineDescribe('Code Mirror Mode', [
'./SequenceDiagram',
'cm/lib/codemirror-real',
], (
SequenceDiagram,
CodeMirror
) => {
'use strict';
SequenceDiagram.registerCodeMirrorMode(CodeMirror);
const cm = new CodeMirror(null, {
value: '',
mode: 'sequence',
globals: {
themes: ['Theme', 'Other Theme'],
},
});
function getTokens(line) {
return cm.getLineTokens(line).map((token) => ({
v: token.string,
type: token.type,
}));
}
describe('colouring', () => {
it('highlights comments', () => {
cm.getDoc().setValue('# foo');
expect(getTokens(0)).toEqual([
{v: '# foo', type: 'comment'},
]);
});
it('highlights valid keywords', () => {
cm.getDoc().setValue('terminators cross');
expect(getTokens(0)).toEqual([
{v: 'terminators', type: 'keyword'},
{v: ' cross', type: 'keyword'},
]);
});
it('highlights comments after content', () => {
cm.getDoc().setValue('terminators cross # foo');
expect(getTokens(0)).toEqual([
{v: 'terminators', type: 'keyword'},
{v: ' cross', type: 'keyword'},
{v: ' # foo', type: 'comment'},
]);
});
it('highlights invalid lines', () => {
cm.getDoc().setValue('terminators huh');
expect(getTokens(0)[1].type).toContain('line-error');
});
it('highlights incomplete lines', () => {
cm.getDoc().setValue('terminators');
expect(getTokens(0)[0].type).toContain('line-error');
});
it('highlights free text', () => {
cm.getDoc().setValue('title my free text');
expect(getTokens(0)).toEqual([
{v: 'title', type: 'keyword'},
{v: ' my', type: 'string'},
{v: ' free', type: 'string'},
{v: ' text', type: 'string'},
]);
});
it('highlights quoted text', () => {
cm.getDoc().setValue('title "my free text"');
expect(getTokens(0)).toEqual([
{v: 'title', type: 'keyword'},
{v: ' "my free text"', type: 'string'},
]);
});
it('highlights agent names', () => {
cm.getDoc().setValue('A -> B');
expect(getTokens(0)).toEqual([
{v: 'A', type: 'variable'},
{v: ' ->', type: 'keyword'},
{v: ' B', type: 'variable'},
]);
});
it('does not consider quoted tokens as keywords', () => {
cm.getDoc().setValue('A "->" -> B');
expect(getTokens(0)).toEqual([
{v: 'A', type: 'variable'},
{v: ' "->"', type: 'variable'},
{v: ' ->', type: 'keyword'},
{v: ' B', type: 'variable'},
]);
});
it('highlights agent aliasing syntax', () => {
cm.getDoc().setValue('define A as B, C as D');
expect(getTokens(0)).toEqual([
{v: 'define', type: 'keyword'},
{v: ' A', type: 'variable'},
{v: ' as', type: 'keyword'},
{v: ' B', type: 'variable'},
{v: ',', type: 'operator'},
{v: ' C', type: 'variable'},
{v: ' as', type: 'keyword'},
{v: ' D', type: 'variable'},
]);
});
it('highlights multi-word agent names', () => {
cm.getDoc().setValue('Foo Bar -> Zig Zag');
expect(getTokens(0)).toEqual([
{v: 'Foo', type: 'variable'},
{v: ' Bar', type: 'variable'},
{v: ' ->', type: 'keyword'},
{v: ' Zig', type: 'variable'},
{v: ' Zag', type: 'variable'},
]);
});
it('highlights connection operators without spaces', () => {
cm.getDoc().setValue('abc->xyz');
expect(getTokens(0)).toEqual([
{v: 'abc', type: 'variable'},
{v: '->', type: 'keyword'},
{v: 'xyz', type: 'variable'},
]);
});
it('highlights the lost message operator without spaces', () => {
cm.getDoc().setValue('abc-xxyz');
expect(getTokens(0)).toEqual([
{v: 'abc', type: 'variable'},
{v: '-x', type: 'keyword'},
{v: 'xyz', type: 'variable'},
]);
});
it('recognises agent flags', () => {
cm.getDoc().setValue('Foo -> *Bar');
expect(getTokens(0)).toEqual([
{v: 'Foo', type: 'variable'},
{v: ' ->', type: 'keyword'},
{v: ' *', type: 'operator'},
{v: 'Bar', type: 'variable'},
]);
});
it('rejects missing agent names', () => {
cm.getDoc().setValue('+ -> Bar');
expect(getTokens(0)[2].type).toContain('line-error');
cm.getDoc().setValue('Bar -> +');
expect(getTokens(0)[2].type).toContain('line-error');
});
it('recognises found messages', () => {
cm.getDoc().setValue('* -> Bar');
expect(getTokens(0)[2].type).not.toContain('line-error');
cm.getDoc().setValue('Bar <- *');
expect(getTokens(0)[2].type).not.toContain('line-error');
});
it('recognises combined agent flags', () => {
cm.getDoc().setValue('Foo -> +*Bar');
expect(getTokens(0)).toEqual([
{v: 'Foo', type: 'variable'},
{v: ' ->', type: 'keyword'},
{v: ' +', type: 'operator'},
{v: '*', type: 'operator'},
{v: 'Bar', type: 'variable'},
]);
});
it('allows messages after connections', () => {
cm.getDoc().setValue('Foo -> Bar: hello');
expect(getTokens(0)).toEqual([
{v: 'Foo', type: 'variable'},
{v: ' ->', type: 'keyword'},
{v: ' Bar', type: 'variable'},
{v: ':', type: 'operator'},
{v: ' hello', type: 'string'},
]);
});
it('recognises invalid agent flag combinations', () => {
cm.getDoc().setValue('Foo -> *!Bar');
expect(getTokens(0)[3].type).toContain('line-error');
cm.getDoc().setValue('Foo -> +-Bar');
expect(getTokens(0)[3].type).toContain('line-error');
cm.getDoc().setValue('Foo -> +*-Bar');
expect(getTokens(0)[4].type).toContain('line-error');
});
it('highlights block statements', () => {
cm.getDoc().setValue(
'if\n' +
'if something\n' +
'else if another thing\n' +
'else\n' +
'end\n' +
'repeat a few times'
);
expect(getTokens(0)).toEqual([
{v: 'if', type: 'keyword'},
]);
expect(getTokens(1)).toEqual([
{v: 'if', type: 'keyword'},
{v: ' something', type: 'string'},
]);
expect(getTokens(2)).toEqual([
{v: 'else', type: 'keyword'},
{v: ' if', type: 'keyword'},
{v: ' another', type: 'string'},
{v: ' thing', type: 'string'},
]);
expect(getTokens(3)).toEqual([
{v: 'else', type: 'keyword'},
]);
expect(getTokens(4)).toEqual([
{v: 'end', type: 'keyword'},
]);
expect(getTokens(5)).toEqual([
{v: 'repeat', type: 'keyword'},
{v: ' a', type: 'string'},
{v: ' few', type: 'string'},
{v: ' times', type: 'string'},
]);
});
it('allows colons in block statements', () => {
cm.getDoc().setValue(
'if: something\n' +
'else if: another thing\n' +
'repeat: a few times'
);
expect(getTokens(0)).toEqual([
{v: 'if', type: 'keyword'},
{v: ':', type: 'operator'},
{v: ' something', type: 'string'},
]);
expect(getTokens(1)).toEqual([
{v: 'else', type: 'keyword'},
{v: ' if', type: 'keyword'},
{v: ':', type: 'operator'},
{v: ' another', type: 'string'},
{v: ' thing', type: 'string'},
]);
expect(getTokens(2)).toEqual([
{v: 'repeat', type: 'keyword'},
{v: ':', type: 'operator'},
{v: ' a', type: 'string'},
{v: ' few', type: 'string'},
{v: ' times', type: 'string'},
]);
});
it('highlights note statements', () => {
cm.getDoc().setValue(
'note over A: hi\n' +
'note over A, B: hi\n' +
'note left of A, B: hi\n' +
'note right of A, B: hi\n' +
'note between A, B: hi'
);
expect(getTokens(0)).toEqual([
{v: 'note', type: 'keyword'},
{v: ' over', type: 'keyword'},
{v: ' A', type: 'variable'},
{v: ':', type: 'operator'},
{v: ' hi', type: 'string'},
]);
expect(getTokens(1)).toEqual([
{v: 'note', type: 'keyword'},
{v: ' over', type: 'keyword'},
{v: ' A', type: 'variable'},
{v: ',', type: 'operator'},
{v: ' B', type: 'variable'},
{v: ':', type: 'operator'},
{v: ' hi', type: 'string'},
]);
expect(getTokens(2)).toEqual([
{v: 'note', type: 'keyword'},
{v: ' left', type: 'keyword'},
{v: ' of', type: 'keyword'},
{v: ' A', type: 'variable'},
{v: ',', type: 'operator'},
{v: ' B', type: 'variable'},
{v: ':', type: 'operator'},
{v: ' hi', type: 'string'},
]);
expect(getTokens(3)).toEqual([
{v: 'note', type: 'keyword'},
{v: ' right', type: 'keyword'},
{v: ' of', type: 'keyword'},
{v: ' A', type: 'variable'},
{v: ',', type: 'operator'},
{v: ' B', type: 'variable'},
{v: ':', type: 'operator'},
{v: ' hi', type: 'string'},
]);
expect(getTokens(4)).toEqual([
{v: 'note', type: 'keyword'},
{v: ' between', type: 'keyword'},
{v: ' A', type: 'variable'},
{v: ',', type: 'operator'},
{v: ' B', type: 'variable'},
{v: ':', type: 'operator'},
{v: ' hi', type: 'string'},
]);
});
it('rejects notes between a single agent', () => {
cm.getDoc().setValue('note between A: hi');
expect(getTokens(0)[3].type).toContain('line-error');
});
it('highlights state statements', () => {
cm.getDoc().setValue('state over A: hi');
expect(getTokens(0)).toEqual([
{v: 'state', type: 'keyword'},
{v: ' over', type: 'keyword'},
{v: ' A', type: 'variable'},
{v: ':', type: 'operator'},
{v: ' hi', type: 'string'},
]);
});
it('rejects state over multiple agents', () => {
cm.getDoc().setValue('state over A, B: hi');
expect(getTokens(0)[3].type).toContain('line-error');
});
});
describe('autocomplete', () => {
function getHints(pos, {completeSingle = true} = {}) {
const hintFn = cm.getHelpers(pos, 'hint')[0];
cm.setCursor(pos);
return hintFn(cm, Object.assign({completeSingle}, cm.options));
}
function getHintTexts(pos, options) {
const hints = getHints(pos, options);
return hints.list.map((hint) => hint.text);
}
it('suggests commands when used at the start of a line', () => {
cm.getDoc().setValue('');
const hints = getHintTexts({line: 0, ch: 0});
expect(hints).toContain('theme ');
expect(hints).toContain('title ');
expect(hints).toContain('headers ');
expect(hints).toContain('terminators ');
expect(hints).toContain('define ');
expect(hints).toContain('begin ');
expect(hints).toContain('end ');
expect(hints).toContain('if ');
expect(hints).toContain('else\n');
expect(hints).toContain('else if: ');
expect(hints).toContain('repeat ');
expect(hints).toContain('note ');
expect(hints).toContain('state over ');
expect(hints).toContain('text ');
expect(hints).toContain('autolabel ');
expect(hints).toContain('simultaneously ');
});
it('suggests known header types', () => {
cm.getDoc().setValue('headers ');
const hints = getHintTexts({line: 0, ch: 8});
expect(hints).toEqual([
'none\n',
'cross\n',
'box\n',
'fade\n',
'bar\n',
]);
});
it('suggests known terminator types', () => {
cm.getDoc().setValue('terminators ');
const hints = getHintTexts({line: 0, ch: 12});
expect(hints).toEqual([
'none\n',
'cross\n',
'box\n',
'fade\n',
'bar\n',
]);
});
it('suggests useful autolabel values', () => {
cm.getDoc().setValue('autolabel ');
const hints = getHintTexts({line: 0, ch: 10});
expect(hints).toContain('off\n');
expect(hints).toContain('"<label>"\n');
expect(hints).toContain('"[<inc>] <label>"\n');
});
it('suggests note positioning', () => {
cm.getDoc().setValue('note ');
const hints = getHintTexts({line: 0, ch: 5});
expect(hints).toEqual([
'over ',
'left of ',
'left: ',
'right of ',
'right: ',
'between ',
]);
});
it('filters suggestions', () => {
cm.getDoc().setValue('term');
const hints = getHintTexts({line: 0, ch: 4});
expect(hints).toEqual(['terminators ']);
});
it('suggests known agent names and flags', () => {
cm.getDoc().setValue('Foo -> ');
const hints = getHintTexts({line: 0, ch: 7});
expect(hints).toEqual([
'+ ',
'- ',
'* ',
'! ',
'Foo ',
]);
});
it('only suggests valid flag combinations', () => {
cm.getDoc().setValue('Foo -> + ');
const hints = getHintTexts({line: 0, ch: 10});
expect(hints).toContain('* ');
expect(hints).not.toContain('! ');
expect(hints).not.toContain('+ ');
expect(hints).not.toContain('- ');
expect(hints).toContain('Foo ');
});
it('suggests known agent names at the start of lines', () => {
cm.getDoc().setValue('Foo -> Bar\n');
const hints = getHintTexts({line: 1, ch: 0});
expect(hints).toContain('Foo ');
expect(hints).toContain('Bar ');
});
it('suggests known labels', () => {
cm.getDoc().setValue('Abc:\nsimultaneously with ');
const hints = getHintTexts({line: 1, ch: 20});
expect(hints).toEqual(['Abc ']);
});
it('suggests known themes', () => {
cm.getDoc().setValue('theme ');
const hints = getHintTexts({line: 0, ch: 6});
expect(hints).toEqual(['Theme\n', 'Other Theme\n']);
});
it('suggests filtered multi-word themes', () => {
cm.getDoc().setValue('theme Other ');
const hints = getHintTexts({line: 0, ch: 12});
expect(hints).toContain('Other Theme\n');
expect(hints).not.toContain('Theme\n');
});
it('suggests multi-word agents', () => {
cm.getDoc().setValue('Zig Zag -> Meh\nFoo Bar -> ');
const hints = getHintTexts({line: 1, ch: 11});
expect(hints).toContain('Zig Zag ');
expect(hints).toContain('Meh ');
expect(hints).toContain('Foo Bar ');
});
it('suggests filtered multi-word agents', () => {
cm.getDoc().setValue('Zig Zag -> Meh\nFoo Bar -> Foo ');
const hints = getHintTexts({line: 1, ch: 15});
expect(hints).toContain('Foo Bar ');
expect(hints).not.toContain('Zig Zag ');
expect(hints).not.toContain('Meh ');
});
it('suggests quoted names where required', () => {
cm.getDoc().setValue('"Zig -> Zag" -> ');
const hints = getHintTexts({line: 0, ch: 16});
expect(hints).toContain('"Zig -> Zag" ');
});
it('filters quoted names ignoring quotes', () => {
cm.getDoc().setValue('"Zig -> Zag" -> Zig');
let hints = getHintTexts({line: 0, ch: 19});
expect(hints).toContain('"Zig -> Zag" ');
cm.getDoc().setValue('"Zig -> Zag" -> Zag');
hints = getHintTexts({line: 0, ch: 19});
expect(hints).not.toContain('"Zig -> Zag" ');
});
});
});

View File

@ -13,6 +13,7 @@ define([
'sequence/LabelPatternParser_spec',
'sequence/Generator_spec',
'sequence/Renderer_spec',
'sequence/CodeMirrorMode_spec',
'sequence/themes/Basic_spec',
'sequence/themes/Monospace_spec',
'sequence/themes/Chunky_spec',

View File

@ -60,6 +60,12 @@
<meta name="cdn-cm/addon/edit/trailingspace" content="stubs/codemirror-trailingspace">
<meta name="cdn-cm/addon/comment/comment" content="stubs/codemirror-comment">
<meta
name="cdn-cm/lib/codemirror-real"
content="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.31.0/codemirror.min.js"
data-integrity="sha256-eue5ceZRwKVQ1OXOZSyU7MXCTZMlqsPi/TOIqh1Vlzo="
>
<!-- test files defined in scripts/specs.js -->
</head>