sugarvalidator
Version:
Tool to validate SugarCube games and automatically detect errors
512 lines (485 loc) • 24.8 kB
JavaScript
function validate(fileData) {
const validationErrors = {};
const validationWarnings = {}
const entityMap = {
'<': '<',
'>': '>',
'"': '"',
''': `'`,
'&': '&',
'\t': ' ',
};
function decodeHTML(encodedStr) {
const input = Object.keys(entityMap);
for(const k of input) {
const v = entityMap[k];
while(encodedStr.indexOf(k) !== -1) {
encodedStr = encodedStr.replace(k, v);
}
}
return encodedStr;
}
function recodeHTML(str) {
const recodeMap = Object.keys(entityMap).reduce((map, key) => {
const intermediate = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(36) + Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(36);
return [
...map,
{ replace: key, intermediate, find: entityMap[key] },
];
}, []);
// Find replace find's with intermediate's
for (const { replace, intermediate, find } of recodeMap) {
while(str.indexOf(find) !== -1) {
str = str.replace(find, intermediate);
}
}
// Then replace intermediate's with replace's
for (const { replace, intermediate, find } of recodeMap) {
while(str.indexOf(intermediate) !== -1) {
str = str.replace(intermediate, replace);
}
}
return str;
}
const exclusionMarkup = [
['"""', '"""', 'Markup escape'],
['<nowiki>', '</nowiki>', 'Markup escape'],
['{{{', '}}}', 'Markup escape'],
['/*', '*/', 'Markup escape'],
['/%', '%/', 'Markup escape'],
['<!--', '-->', 'Markup escape'],
['<<script>>', '<</script>>', 'Script']
];
function recodeExcludedHTML(html, header) {
let startIndex = 0;
while(true) {
let firstIndex = html.length;
let firstMarkup = null;
for(const markup of exclusionMarkup) {
const index = html.indexOf(markup[0], startIndex);
if (index !== -1 && index < firstIndex) {
firstIndex = index;
firstMarkup = markup;
}
}
// If none of the opening markup can be found, we're done
if (!firstMarkup) {
return html;
}
// If something is found, find the matching closing markup
const closingIndex = html.indexOf(firstMarkup[1], firstIndex + firstMarkup[0].length);
// If no closing markup is found, add a warning and stop trying to exclude further markup
if (closingIndex === -1) {
addWarning([
[`Found $$1 opening '$$2', but no subsequent closing '$$3'`, firstMarkup[2], firstMarkup[0], firstMarkup[1]],
], header);
return html;
}
// modify html to re-encode the excluded markup
html = html.substr(0, firstIndex + firstMarkup[0].length) + recodeHTML(html.substring(firstIndex + firstMarkup[0].length, closingIndex)) + html.substr(closingIndex);
startIndex = closingIndex + firstMarkup[1].length;
}
}
function getHTMLAttr(html, attr) {
const startStr = `${attr}="`;
const start = html.indexOf(startStr);
if (start === -1) return null;
const end = html.indexOf('"', start + startStr.length);
if (end === -1) return null;
return html.substring(start + startStr.length, end);
}
const passageStartTag = '<tw-passagedata';
const passageCloseTag = '</tw-passagedata>';
const passages = [];
const passageNameIndex = {};
const passageIdIndex = {};
let lastIndex = 0;
while(true) {
const startIndex = fileData.indexOf(passageStartTag, lastIndex);
if (startIndex === -1) break;
const endIndex = fileData.indexOf(passageCloseTag, startIndex);
if (endIndex === -1) throw 'Unclosed passage found: ' + fileData.substr(startIndex, 200);
const contentIndex = fileData.indexOf('>', startIndex) + 1;
const header = fileData.substring(startIndex, contentIndex);
const content = recodeExcludedHTML(decodeHTML(fileData.substring(contentIndex, endIndex)), header);
const id = getHTMLAttr(header, 'pid');
const name = getHTMLAttr(header, 'name');
const passage = { header, content, id, name };
// Add stuff
passages.push(passage);
if (name) passageNameIndex[name] = passage;
if (id) passageIdIndex[id] = passage;
lastIndex = endIndex + passageCloseTag.length;
}
function throwError(msg, passage) {
validationErrors[passage.header] = msg.replace(/\n/g, '\\n').replace(/\t/g, '\\t');
throw 'Error';
}
function addWarning(msg, header) {
if (!validationWarnings[header]) validationWarnings[header] = [];
validationWarnings[header].push(msg);
}
function matchQuotes(html, passage) {
const numDoubleQuotes = html.split('"').length - 1;
const numSingleQuotes = html.split("'").length - 1;
const unevenDoubleQuotes = (numDoubleQuotes % 2) === 1;
const unevenSingleQuotes = (numSingleQuotes % 2) === 1; // single quotes can reasonably be used in plain english
if (unevenDoubleQuotes) addWarning([['Uneven number of double-quotes in passage']], passage.header);
// if (unevenSingleQuotes) addWarning([['Uneven number of single-quotes in']], passage.header);
}
function matchGTLT(html, passage) {
let htmlClone = html;
while(true) {
const start = htmlClone.lastIndexOf('<<');
const end = htmlClone.indexOf('>>', start);
if (start === -1 && end === -1) return;
if (start === -1 || end === -1) {
if (start !== -1) {
const lineend = html.indexOf('\n', start);
const linestart = html.substr(0, lineend).lastIndexOf('\n');
const line = html.substring(linestart, lineend).trim().substr(0, 160);
return throwError(`Found an opening '<<' without matching '>>' Line: ${line}`, passage);
} else {
const lineend = html.indexOf('\n', end);
const linestart = html.substr(0, lineend).lastIndexOf('\n');
const line = html.substring(linestart, lineend).trim().substr(0, 160);
return throwError(`Found a closing '>>' without matching '<<' Line: ${line}`, passage);
}
}
htmlClone = htmlClone.substr(0, start) + '{{' + htmlClone.substring(start + 2, end) + '}}' + htmlClone.substr(end + 2);
}
}
function matchIfs(html, passage) {
while(true) {
const start = html.lastIndexOf('<<if ');
// If there is no <<if
if (start === -1) {
// But there's still an /if, elseif or else, something's fucked
if (html.indexOf('<</if>>') !== -1) return throwError('Unmatched <</if>> found in passage', passage);
if (html.indexOf('<<elseif') !== -1) return throwError('Unmatched <<elseif found in passage', passage);
if (html.indexOf('<<else>>') !== -1) return throwError('Unmatched <<else>> found in passage', passage);
// If none of those still exist, we're good
return;
}
// If there still is an <<if
// Make sure there is also an <</if>>
const end1 = html.indexOf('<</if>>', start);
const end2 = html.indexOf('<<endif>>', start);
const end = (end2 === -1) ? end1 : (end1 === -1) ? end2 : Math.min(end1, end2);
if (end === -1) return throwError('Unmatched <<if found in passage ' + html.substr(start, 40), passage);
// Only look between the <<if and <</if>>
const endOfIf = html.indexOf('>>', start);
// But make sure our <<if isn't completely broken
if (endOfIf > end) return throwError('Malformed <<if found in passage', passage);
// Get content
const content = html.substring(endOfIf + 2, end);
// Check if there is an <<else>>
const elseIndex = content.indexOf('<<else>>', start);
// If so, make sure there isn't a another <<else>> or an <<elseif after the <<else>>
if (elseIndex !== -1) {
if (content.indexOf('<<else>>', elseIndex + 1) !== -1) return throwError('Double <<else>> found inside if block in passage', passage);
if (content.indexOf('<<elseif', elseIndex + 1) !== -1) return throwError('<<elseif found after <<else>> found inside if block in passage', passage);
}
// Remove this if block from the HTML
html = html.substr(0, start) + html.substr(end + 7);
}
}
function findInvalidConditions(html, passage) {
const matches = html.match(/<<(else)?if [^>]* is(not)? (gt|gte|lt|lte) [^>]*>>/g);
if (matches) return throwError(`Invalid condition found: '${matches[0]}', is/isnot should not be used in combination with lt/lte/gt/gte`, passage);
}
function checkInvalidWidgets(html, passage) {
// Find our tags
const tags = getHTMLAttr(passage.header, 'tags');
// If one of this passage's tags is widget, we dont need to check if there are invalid widgets, cause they'd be valid in this passage
if (tags.split(' ').indexOf('widget') !== -1) return;
// Determine if there are any widget tags
if (html.indexOf('<<widget ') !== -1) return throwError('<<widget found in passage without a widget tag', passage);
}
function findDeprecatedInPassage(html, passage) {
const deprecationMap = {
'<<click': '<<link',
'<<endclick>>': '<</link>>',
'<</click>>': '<</link>>',
'<<endif>>': '<</if>>',
'<<endnobr>>': '<</nobr>>',
'<<endsilently>>': '<</silently>>',
'<<endfor>>': '<</for>>',
'<<endscript>>': '<</script>>',
'<<endbutton>>': '<</button>>',
'<<endappend>>': '<</append>>',
'<<endprepend>>': '<</prepend>>',
'<<endreplace>>': '<</replace>>',
'<<endwidget>>': '<</widget>>',
'<<setplaylist': '<<createplaylist',
'<<stopallaudio>>': '<<audio ":all" stop>>',
'<<display': '<<include',
'<<forget': 'forget()',
'<<remember': `memorize()' and 'recall()`,
'state.active.variables': 'State.variables',
'State.initPRNG(': 'State.prng.init(',
'.containsAll(': '.includesAll(',
'.containsAny(': '.includesAny(',
'.flatten(': '.flat(',
};
const deprecationKeys = Object.keys(deprecationMap);
for (const key of deprecationKeys) {
if (html.indexOf(key) !== -1) {
addWarning([[`Deprecated markup found in passage ('$$1' should be '$$2')`, key, deprecationMap[key]]], passage.header);
}
}
}
function findDeprecatedInScript() {
const deprecationMap = {
'state.active.variables': 'State.variables',
'State.initPRNG(': 'State.prng.init(',
'.containsAll(': '.includesAll(',
'.containsAny(': '.includesAny(',
'.flatten(': '.flat(',
'macros.': 'Macro.add(',
};
const initialIndex = fileData.indexOf('id="twine-user-script"');
const maxIndex = fileData.indexOf('</script>', initialIndex);
const deprecationKeys = Object.keys(deprecationMap);
for (const key of deprecationKeys) {
let curIndex = initialIndex;
while(true) {
const startIndex = fileData.indexOf(key, curIndex);
if (startIndex === -1) break;
if (startIndex >= maxIndex) break;
if (key === 'macros.') { // Special case, ignore if this is not stand-alone
const precedingChar = fileData.substr(startIndex - 1, 1);
if (precedingChar.match(/[\._a-z0-9]/i)) {
curIndex = startIndex + key.length;
continue;
}
}
const endOfLineIndex = fileData.indexOf('\n', startIndex);
const lines = fileData.substring(0, endOfLineIndex).split('\n');
const line = lines[lines.length - 1].trim();
if (!line.startsWith('//') && !line.startsWith('/*')) {
addWarning([
['Line $$1: $$2', lines.length, line],
["'$$1' should be '$$2'", key, deprecationMap[key]],
], 'Deprecated code found in Twine User-script');
}
curIndex = startIndex + key.length;
}
}
}
// ['script', 'widget', 'for', 'link', 'button']
const allMacros = {
// Native macros
switch: { closed: true, sub: [ 'case', 'default'] },
for: { closed: true, sub: [ 'break', 'continue'] },
repeat: { closed: true, sub: [ 'stop' ] },
cycle: { closed: true, sub: [ 'option', 'optionsfrom ']},
listbox: { closed: true, sub: [ 'option', 'optionsfrom ']},
timed: { closed: true, sub: [ 'next' ]},
createaudiogroup: { closed: true, sub: [ 'track' ]},
createplaylist: { closed: true, sub: [ 'track' ]},
};
function addSimpleMacro(name, closed) {
allMacros[name] = { closed, sub: closed ? [] : undefined };
}
// Closed
['capture', 'script', 'nobr', 'silently', 'button', 'link', 'linkappend', 'linkprepend', 'linkreplace', 'append', 'prepend', 'replace', 'widget'].forEach((name) => addSimpleMacro(name, true));
// Unclosed
['set', 'unset', 'run', '=', '-', 'include', 'print', 'checkbox', 'radiobutton', 'textarea', 'textbox', 'actions', 'back', 'choice', 'return', 'addclass'].forEach((name) => addSimpleMacro(name, false));
['copy', 'remove', 'removeclass', 'toggleclass', 'audio', 'cacheaudio', 'playlist', 'masteraudio', 'removeplaylist', 'waitforaudio', 'goto'].forEach((name) => addSimpleMacro(name, false));
let failedToParseAllMacros = false;
function tryEvalMacro(script) {
const Macro = {
add(name, { tags }) {
return { name , tags };
}
}
try {
return eval(script);
} catch (e) {
return false;
}
}
function findAllCustomMacros() {
const scriptStartIndex = fileData.indexOf('id="twine-user-script"');
if (scriptStartIndex === -1) return;
const scriptEndIndex = fileData.indexOf('</script>', scriptStartIndex);
if (scriptEndIndex === -1) return;
const userscript = fileData.substring(scriptStartIndex, scriptEndIndex);
const macroStartSyntax = 'Macro.add(';
let startIndex = 0;
while(true) {
const index = userscript.indexOf(macroStartSyntax, startIndex);
if (index === -1) return;
let innerStartIndex = startIndex = index + macroStartSyntax.length;
let attempt = 0;
while(true) {
const possibleEndIndex = userscript.indexOf(')', innerStartIndex);
const macroCode = userscript.substring(index, possibleEndIndex + 1);
const result = tryEvalMacro(macroCode);
if (result !== false) {
const { name , tags } = result;
allMacros[name] = { closed: (typeof tags !== 'undefined'), sub: tags };
startIndex = possibleEndIndex + 1;
break;
} else {
innerStartIndex = possibleEndIndex + 1;
if (attempt++ > 100) {
failedToParseAllMacros = true;
break;
}
}
}
}
}
function findAllCustomMacrosDeprecated() {
const scriptStartIndex = fileData.indexOf('id="twine-user-script"');
if (scriptStartIndex === -1) return;
const scriptEndIndex = fileData.indexOf('</script>', scriptStartIndex);
if (scriptEndIndex === -1) return;
let userscript = fileData.substring(scriptStartIndex, scriptEndIndex);
while(true) {
const match = userscript.match(/macros\s*\.([a-zA-Z][a-zA-Z0-9_]*)\s*=\s*{/);
if (!match) break;
const fullStr = match[0];
const macroName = match[1];
const innerStartIndex = match.index + fullStr.length - 1;
let innerEndIndexMinBound = innerStartIndex;
let attempt = 0;
while(true) {
const possibleEndIndex = userscript.indexOf('}', innerEndIndexMinBound);
const macroCodeObj = userscript.substring(innerStartIndex, possibleEndIndex + 1);
const macroCode = `Macro.add('${macroName}', ${macroCodeObj})`;
const result = tryEvalMacro(macroCode);
if (result !== false) {
const { name , tags } = result;
allMacros[name] = { closed: (typeof tags !== 'undefined'), sub: tags };
userscript = userscript.substr(innerStartIndex);
break;
} else {
innerEndIndexMinBound = possibleEndIndex + 1;
if (attempt++ > 100) {
userscript = userscript.substr(innerStartIndex);
failedToParseAllMacros = true;
console.error('Could not find custom macro', macroName, macroCode);
break;
}
}
}
}
userscript = fileData.substring(scriptStartIndex, scriptEndIndex);
while(true) {
const match = userscript.match(/macros\s*\[['"]([a-zA-Z][a-zA-Z0-9_]*)["']\]\s*=\s*{/);
if (!match) break;
const fullStr = match[0];
const macroName = match[1];
const innerStartIndex = match.index + fullStr.length - 1;
let innerEndIndexMinBound = innerStartIndex;
let attempt = 0;
while(true) {
const possibleEndIndex = userscript.indexOf('}', innerEndIndexMinBound);
const macroCodeObj = userscript.substring(innerStartIndex, possibleEndIndex + 1);
const macroCode = `Macro.add('${macroName}', ${macroCodeObj})`;
const result = tryEvalMacro(macroCode);
if (result !== false) {
const { name , tags } = result;
allMacros[name] = { closed: (typeof tags !== 'undefined'), sub: tags };
userscript = userscript.substr(innerStartIndex);
break;
} else {
innerEndIndexMinBound = possibleEndIndex + 1;
if (attempt++ > 100) {
userscript = userscript.substr(innerStartIndex);
failedToParseAllMacros = true;
console.error('Could not find custom macro', macroName, macroCode);
break;
}
}
}
}
}
const allWidgets = [];
let failedToParseAllWidgets = false;
function findAllWidgets(html, passage) {
// Find our tags
const tags = getHTMLAttr(passage.header, 'tags');
// If none of this passage's tags is widget, we dont need to check if there are any widgets
if (tags.split(' ').indexOf('widget') === -1) return;
// Find all widgets
let startIndex = 0;
while (true) {
const find = '<<widget ';
const index = html.indexOf(find, startIndex);
if (index === -1) return;
let widgetName = html.substr(index + find.length).trim().split(' ').shift().split('>').shift().split('\n').shift();
if (widgetName[0] === '"' && widgetName[widgetName.length - 1] === '"') widgetName = widgetName.substring(1, widgetName.length - 1);
else if (widgetName[0] === '$') failedToParseAllWidgets = true;
if (allWidgets.indexOf(widgetName) === -1) allWidgets.push(widgetName);
startIndex = index + find.length + widgetName.length;
}
}
const excludeTags = ['if', 'else', 'elseif', 'endif', 'click', 'endclick', 'endnobr', 'endsilently', 'endfor', 'endscript', 'endbutton', 'endappend', 'endprepend', 'endreplace', 'endwidget', 'setplaylist', 'stopallaudio', 'display', 'remember', 'forget'];
const excludePrefix = ['/', '-', '='];
function findAllTags(html, passage) {
let ignoreTagInRange = [];
let startIndex = 0;
while(true) {
const index = html.indexOf('<<', startIndex);
if (index === -1) return;
const tagName = html.substr(index + 2).split(' ').shift().split('>').shift().split('\n').shift();
if (excludePrefix.indexOf(tagName[0]) === -1 && excludeTags.indexOf(tagName) === -1) {
if (tagName[0] === '$' || tagName[0] === '_') {
return throwError(`Found '<<${tagName}'. Though this doesn't cause errors directly in the game, something else was almost certainly intended (ie: '<<=${tagname}')`, passage);
}
else if (!ignoreTagInRange.some((item) => item.tag === tagname && index > item.range[0] && index < item.range[1])) {
const closeTagIndex = html.substr(index).indexOf('<</' + tagName + '>>');
const isClosed = closeTagIndex !== -1;
const isWidget = allWidgets.indexOf(tagName) !== -1;
const isMacro = allMacros[tagName];
if (!isMacro && !isWidget) {
return throwError(`<<${tagName} found, but could not find macro or widget definition for tagname`, passage);
} else {
const shouldBeClosed = isWidget ? false : (isMacro && isMacro.closed);
if (isMacro && isMacro.sub && isMacro.sub.length) {
for (const subtag of isMacro.sub) {
ignoreTagInRange.push({ tag: subtag, range: [index, closeTagIndex] });
}
}
if (isClosed !== shouldBeClosed) {
if (isClosed) return throwError(`Macro '${tagName}' should not be closed, but was`, passage);
if (shouldBeClosed) return throwError(`Macro '${tagName}' should be closed, but was not`, passage);
}
}
}
}
startIndex = index + tagName.length + 1;
}
}
// First find all widgets and macros, so we can validate them later
findAllCustomMacros();
findAllCustomMacrosDeprecated();
for (const passage of passages) {
findAllWidgets(passage.content, passage);
}
for (const passage of passages) {
try {
matchQuotes(passage.content, passage);
matchGTLT(passage.content, passage);
matchIfs(passage.content, passage);
findDeprecatedInPassage(passage.content, passage);
findInvalidConditions(passage.content, passage);
checkInvalidWidgets(passage.content, passage);
findAllTags(passage.content, passage);
} catch(e) {
errorsFound = true;
}
}
findDeprecatedInScript();
return {
failedToParseAllMacros,
failedToParseAllWidgets,
errors: validationErrors,
warnings: validationWarnings,
};
}
if (typeof module !== 'undefined') module.exports = validate;