basic validation

pull/31/head
Rich-Harris 9 years ago
parent c1d230d625
commit 0698525f1b

@ -1,12 +1,25 @@
import parse from './parse/index.js';
import validate from './validate/index.js';
import generate from './generate/index.js';
export function compile ( template, options = {} ) {
const parsed = parse( template, options );
// TODO validate template
const generated = generate( parsed, template, options );
export function compile ( source, options = {} ) {
const parsed = parse( source, options );
return generated;
const { errors, warnings } = validate( parsed, source, options );
if ( errors.length ) {
// TODO optionally show all errors?
throw errors[0];
}
if ( warnings.length ) {
console.warn( `Svelte: ${warnings.length} ${warnings.length === 1 ? 'error' : 'errors'} in ${options.filename || 'template'}:` );
warnings.forEach( warning => {
console.warn( `(${warning.loc.line}:${warning.loc.column}) ${warning.message}` );
});
}
return generate( parsed, source, options );
}
export { parse };
export { parse, validate };

@ -2,37 +2,13 @@ import { locate } from 'locate-character';
import fragment from './state/fragment.js';
import { whitespace } from './patterns.js';
import { trimStart, trimEnd } from './utils/trim.js';
import spaces from '../utils/spaces.js';
import getCodeFrame from '../utils/getCodeFrame.js';
import hash from './utils/hash.js';
function tabsToSpaces ( str ) {
return str.replace( /^\t+/, match => match.split( '\t' ).join( ' ' ) );
}
function ParseError ( message, template, index ) {
const { line, column } = locate( template, index );
const lines = template.split( '\n' );
const frameStart = Math.max( 0, line - 2 );
const frameEnd = Math.min( line + 3, lines.length );
const digits = String( frameEnd + 1 ).length;
const frame = lines
.slice( frameStart, frameEnd )
.map( ( str, i ) => {
const isErrorLine = frameStart + i === line;
let lineNum = String( i + frameStart + 1 );
while ( lineNum.length < digits ) lineNum = ` ${lineNum}`;
if ( isErrorLine ) {
const indicator = spaces( digits + 2 + tabsToSpaces( str.slice( 0, column ) ).length ) + '^';
return `${lineNum}: ${tabsToSpaces( str )}\n${indicator}`;
}
return `${lineNum}: ${tabsToSpaces( str )}`;
})
.join( '\n' );
const frame = getCodeFrame( template, line, column );
this.name = 'ParseError';
this.message = `${message} (${line + 1}:${column})\n${frame}`;

@ -0,0 +1,31 @@
import spaces from './spaces.js';
function tabsToSpaces ( str ) {
return str.replace( /^\t+/, match => match.split( '\t' ).join( ' ' ) );
}
export default function getCodeFrame ( source, line, column ) {
const lines = source.split( '\n' );
const frameStart = Math.max( 0, line - 2 );
const frameEnd = Math.min( line + 3, lines.length );
const digits = String( frameEnd + 1 ).length;
return lines
.slice( frameStart, frameEnd )
.map( ( str, i ) => {
const isErrorLine = frameStart + i === line;
let lineNum = String( i + frameStart + 1 );
while ( lineNum.length < digits ) lineNum = ` ${lineNum}`;
if ( isErrorLine ) {
const indicator = spaces( digits + 2 + tabsToSpaces( str.slice( 0, column ) ).length ) + '^';
return `${lineNum}: ${tabsToSpaces( str )}\n${indicator}`;
}
return `${lineNum}: ${tabsToSpaces( str )}`;
})
.join( '\n' );
}

@ -0,0 +1,42 @@
import validateJs from './js/index.js';
import { getLocator } from 'locate-character';
export default function validate ( parsed, source ) {
const locator = getLocator( source );
const validator = {
error: ( message, pos ) => {
const { line, column } = locator( pos );
validator.errors.push({
message,
pos,
loc: { line: line + 1, column }
});
},
warn: ( message, pos ) => {
const { line, column } = locator( pos );
validator.warnings.push({
message,
pos,
loc: { line: line + 1, column }
});
},
templateProperties: {},
errors: [],
warnings: []
};
if ( parsed.js ) {
validateJs( validator, parsed.js, source );
}
return {
errors: validator.errors,
warnings: validator.warnings
};
}

@ -0,0 +1,52 @@
import propValidators from './propValidators/index.js';
import FuzzySet from './utils/FuzzySet.js';
import checkForDupes from './utils/checkForDupes.js';
import checkForComputedKeys from './utils/checkForComputedKeys.js';
const validPropList = Object.keys( propValidators );
const fuzzySet = new FuzzySet( validPropList );
export default function validateJs ( validator, js, source ) {
js.content.body.forEach( node => {
// check there are no named exports
if ( node.type === 'ExportNamedDeclaration' ) {
validator.error( `A component can only have a default export`, node.start );
}
if ( node.type === 'ExportDefaultDeclaration' ) {
if ( validator.defaultExport ) {
validator.error( `Duplicate default export`, node.start );
}
validator.defaultExport = node;
}
});
// ensure all exported props are valid
if ( validator.defaultExport ) {
checkForComputedKeys( validator, validator.defaultExport.declaration.properties );
checkForDupes( validator, validator.defaultExport.declaration.properties );
validator.defaultExport.declaration.properties.forEach( prop => {
validator.templateProperties[ prop.key.value ] = prop;
});
validator.defaultExport.declaration.properties.forEach( prop => {
const propValidator = propValidators[ prop.key.name ];
if ( propValidator ) {
propValidator( validator, prop );
} else {
const matches = fuzzySet.get( prop.key.name );
if ( matches && matches[0] && matches[0][0] > 0.7 ) {
validator.error( `Unexpected property '${prop.key.name}' (did you mean '${matches[0][1]}'?)`, prop.start );
} else if ( /FunctionExpression/.test( prop.value.type ) ) {
validator.error( `Unexpected property '${prop.key.name}' (did you mean to include it in 'methods'?)`, prop.start );
} else {
validator.error( `Unexpected property '${prop.key.name}'`, prop.start );
}
}
});
}
}

@ -0,0 +1,3 @@
export default function components ( validator, prop ) {
}

@ -0,0 +1,32 @@
import checkForDupes from '../utils/checkForDupes.js';
import checkForComputedKeys from '../utils/checkForComputedKeys.js';
const isFunctionExpression = {
FunctionExpression: true,
ArrowFunctionExpression: true
};
export default function computed ( validator, prop ) {
if ( prop.value.type !== 'ObjectExpression' ) {
validator.error( `The 'computed' property must be an object literal`, prop.start );
return;
}
checkForDupes( validator, prop.value.properties );
checkForComputedKeys( validator, prop.value.properties );
prop.value.properties.forEach( computation => {
if ( !isFunctionExpression[ computation.value.type ] ) {
validator.error( `Computed properties can be function expressions or arrow function expressions`, computation.value.start );
return;
}
computation.value.params.forEach( param => {
const valid = param.type === 'Identifier' || param.type === 'AssignmentPattern' && param.left.type === 'Identifier';
if ( !valid ) {
validator.error( `Computed properties cannot use destructuring in function parameters`, param.start );
}
});
});
}

@ -0,0 +1,3 @@
export default function data ( validator, prop ) {
}

@ -0,0 +1,3 @@
export default function events ( validator, prop ) {
}

@ -0,0 +1,3 @@
export default function helpers ( validator, prop ) {
}

@ -0,0 +1,19 @@
import data from './data.js';
import computed from './computed.js';
import onrender from './onrender.js';
import onteardown from './onteardown.js';
import helpers from './helpers.js';
import methods from './methods.js';
import components from './components.js';
import events from './events.js';
export default {
data,
computed,
onrender,
onteardown,
helpers,
methods,
components,
events
};

@ -0,0 +1,3 @@
export default function methods ( validator, prop ) {
}

@ -0,0 +1,3 @@
export default function onrender ( validator, prop ) {
}

@ -0,0 +1,3 @@
export default function onteardown ( validator, prop ) {
}

@ -0,0 +1,290 @@
// adapted from https://github.com/Glench/this.js/blob/master/lib/this.js
// BSD Licensed
export default function FuzzySet (arr, useLevenshtein, gramSizeLower, gramSizeUpper) {
// default options
arr = arr || [];
this.gramSizeLower = gramSizeLower || 2;
this.gramSizeUpper = gramSizeUpper || 3;
this.useLevenshtein = (typeof useLevenshtein !== 'boolean') ? true : useLevenshtein;
// define all the object functions and attributes
this.exactSet = {};
this.matchDict = {};
this.items = {};
// helper functions
function levenshtein ( str1, str2 ) {
const current = [];
let prev;
let value;
for (let i = 0; i <= str2.length; i++) {
for (let j = 0; j <= str1.length; j++) {
if (i && j) {
if (str1.charAt(j - 1) === str2.charAt(i - 1)) {
value = prev;
} else {
value = Math.min(current[j], current[j - 1], prev) + 1;
}
} else {
value = i + j;
}
prev = current[j];
current[j] = value;
}
}
return current.pop();
}
// return an edit distance from 0 to 1
function _distance (str1, str2) {
if (str1 === null && str2 === null) throw 'Trying to compare two null values';
if (str1 === null || str2 === null) return 0;
str1 = String(str1); str2 = String(str2);
const distance = levenshtein(str1, str2);
if (str1.length > str2.length) {
return 1 - distance / str1.length;
} else {
return 1 - distance / str2.length;
}
}
const _nonWordRe = /[^\w, ]+/;
function _iterateGrams (value, gramSize) {
gramSize = gramSize || 2;
const simplified = '-' + value.toLowerCase().replace(_nonWordRe, '') + '-';
const lenDiff = gramSize - simplified.length;
const results = [];
if (lenDiff > 0) {
for (let i = 0; i < lenDiff; ++i) {
value += '-';
}
}
for (let i = 0; i < simplified.length - gramSize + 1; ++i) {
results.push(simplified.slice(i, i + gramSize));
}
return results;
}
function _gramCounter (value, gramSize) {
// return an object where key=gram, value=number of occurrences
gramSize = gramSize || 2;
const result = {};
const grams = _iterateGrams(value, gramSize);
let i = 0;
for (i; i < grams.length; ++i) {
if (grams[i] in result) {
result[grams[i]] += 1;
} else {
result[grams[i]] = 1;
}
}
return result;
}
// the main functions
this.get = function (value, defaultValue) {
// check for value in set, returning defaultValue or null if none found
const result = this._get(value);
if (!result && typeof defaultValue !== 'undefined') {
return defaultValue;
}
return result;
};
this._get = function (value) {
const normalizedValue = this._normalizeStr(value);
const result = this.exactSet[normalizedValue];
if (result) {
return [[1, result]];
}
let results = [];
// start with high gram size and if there are no results, go to lower gram sizes
for (let gramSize = this.gramSizeUpper; gramSize >= this.gramSizeLower; --gramSize) {
results = this.__get(value, gramSize);
if (results) {
return results;
}
}
return null;
};
this.__get = function (value, gramSize) {
const normalizedValue = this._normalizeStr(value);
const matches = {};
const gramCounts = _gramCounter(normalizedValue, gramSize);
const items = this.items[gramSize];
let sumOfSquareGramCounts = 0;
let gram;
let gramCount;
let i;
let index;
let otherGramCount;
for (gram in gramCounts) {
gramCount = gramCounts[gram];
sumOfSquareGramCounts += Math.pow(gramCount, 2);
if (gram in this.matchDict) {
for (i = 0; i < this.matchDict[gram].length; ++i) {
index = this.matchDict[gram][i][0];
otherGramCount = this.matchDict[gram][i][1];
if (index in matches) {
matches[index] += gramCount * otherGramCount;
} else {
matches[index] = gramCount * otherGramCount;
}
}
}
}
function isEmptyObject ( obj ) {
for ( const prop in obj ) {
if ( obj.hasOwnProperty( prop ) )
return false;
}
return true;
}
if (isEmptyObject(matches)) {
return null;
}
const vectorNormal = Math.sqrt(sumOfSquareGramCounts);
let results = [];
let matchScore;
// build a results list of [score, str]
for (const matchIndex in matches) {
matchScore = matches[matchIndex];
results.push([matchScore / (vectorNormal * items[matchIndex][0]), items[matchIndex][1]]);
}
function sortDescending (a, b) {
if (a[0] < b[0]) {
return 1;
} else if (a[0] > b[0]) {
return -1;
} else {
return 0;
}
}
results.sort(sortDescending);
if (this.useLevenshtein) {
const newResults = [];
const endIndex = Math.min(50, results.length);
// truncate somewhat arbitrarily to 50
for (let i = 0; i < endIndex; ++i) {
newResults.push([_distance(results[i][1], normalizedValue), results[i][1]]);
}
results = newResults;
results.sort(sortDescending);
}
const newResults = [];
for (let i = 0; i < results.length; ++i) {
if (results[i][0] == results[0][0]) {
newResults.push([results[i][0], this.exactSet[results[i][1]]]);
}
}
return newResults;
};
this.add = function (value) {
const normalizedValue = this._normalizeStr(value);
if (normalizedValue in this.exactSet) {
return false;
}
let i = this.gramSizeLower;
for (i; i < this.gramSizeUpper + 1; ++i) {
this._add(value, i);
}
};
this._add = function (value, gramSize) {
const normalizedValue = this._normalizeStr(value);
const items = this.items[gramSize] || [];
const index = items.length;
items.push(0);
const gramCounts = _gramCounter(normalizedValue, gramSize);
let sumOfSquareGramCounts = 0;
let gram;
let gramCount;
for (gram in gramCounts) {
gramCount = gramCounts[gram];
sumOfSquareGramCounts += Math.pow(gramCount, 2);
if (gram in this.matchDict) {
this.matchDict[gram].push([index, gramCount]);
} else {
this.matchDict[gram] = [[index, gramCount]];
}
}
const vectorNormal = Math.sqrt(sumOfSquareGramCounts);
items[index] = [vectorNormal, normalizedValue];
this.items[gramSize] = items;
this.exactSet[normalizedValue] = value;
};
this._normalizeStr = function (str) {
if (Object.prototype.toString.call(str) !== '[object String]') throw 'Must use a string as argument to FuzzySet functions';
return str.toLowerCase();
};
// return length of items in set
this.length = function () {
let count = 0;
let prop;
for (prop in this.exactSet) {
if (this.exactSet.hasOwnProperty(prop)) {
count += 1;
}
}
return count;
};
// return is set is empty
this.isEmpty = function () {
for (const prop in this.exactSet) {
if (this.exactSet.hasOwnProperty(prop)) {
return false;
}
}
return true;
};
// return list of values loaded into set
this.values = function () {
const values = [];
for (const prop in this.exactSet) {
if (this.exactSet.hasOwnProperty(prop)) {
values.push(this.exactSet[prop]);
}
}
return values;
};
// initialization
let i = this.gramSizeLower;
for (i; i < this.gramSizeUpper + 1; ++i) {
this.items[i] = [];
}
// add all the items to the set
for (i = 0; i < arr.length; ++i) {
this.add(arr[i]);
}
return this;
}

@ -0,0 +1,7 @@
export default function checkForComputedKeys ( validator, properties ) {
properties.forEach( prop => {
if ( prop.key.computed ) {
validator.error( `Cannot use computed keys`, prop.start );
}
});
}

@ -0,0 +1,11 @@
export default function checkForDupes ( validator, properties ) {
const seen = Object.create( null );
properties.forEach( prop => {
if ( seen[ prop.key.name ] ) {
validator.error( `Duplicate property '${prop.key.name}'`, prop.start );
}
seen[ prop.key.name ] = true;
});
}

@ -42,6 +42,7 @@
"eslint": "^3.10.2",
"eslint-plugin-import": "^2.2.0",
"estree-walker": "^0.3.0",
"fuzzyset.js": "0.0.1",
"jsdom": "^9.8.3",
"locate-character": "^2.0.0",
"magic-string": "^0.16.0",
@ -51,7 +52,9 @@
"reify": "^0.4.0",
"rollup": "^0.36.3",
"rollup-plugin-buble": "^0.14.0",
"rollup-plugin-node-resolve": "^2.0.0"
"rollup-plugin-commonjs": "^5.0.5",
"rollup-plugin-node-resolve": "^2.0.0",
"source-map-support": "^0.4.6"
},
"nyc": {
"include": [

@ -1,4 +1,5 @@
import nodeResolve from 'rollup-plugin-node-resolve';
import commonjs from 'rollup-plugin-commonjs';
export default {
entry: 'compiler/index.js',
@ -7,6 +8,8 @@ export default {
{ dest: 'dist/svelte.js', format: 'umd' }
],
plugins: [
nodeResolve({ jsnext: true, module: true })
]
nodeResolve({ jsnext: true, module: true }),
commonjs()
],
sourceMap: true
};

@ -1,11 +1,14 @@
import { compile, parse } from '../dist/svelte.js';
import { compile, parse, validate } from '../dist/svelte.js';
import assert from 'assert';
import * as path from 'path';
import * as fs from 'fs';
import jsdom from 'jsdom';
import { install } from 'console-group';
install();
import * as consoleGroup from 'console-group';
consoleGroup.install();
import * as sourceMapSupport from 'source-map-support';
sourceMapSupport.install();
const cache = {};
@ -24,7 +27,7 @@ function exists ( path ) {
}
describe( 'svelte', () => {
describe( 'parser', () => {
describe( 'parse', () => {
fs.readdirSync( 'test/parser' ).forEach( dir => {
if ( dir[0] === '.' ) return;
@ -57,7 +60,51 @@ describe( 'svelte', () => {
});
});
describe( 'compiler', () => {
describe( 'validate', () => {
function tryToLoadJson ( file ) {
try {
return JSON.parse( fs.readFileSync( file ) );
} catch ( err ) {
if ( err.code !== 'ENOENT' ) throw err;
return null;
}
}
fs.readdirSync( 'test/validator' ).forEach( dir => {
if ( dir[0] === '.' ) return;
const solo = exists( `test/validator/${dir}/solo` );
( solo ? it.only : it )( dir, () => {
const input = fs.readFileSync( `test/validator/${dir}/input.html`, 'utf-8' ).replace( /\s+$/, '' );
try {
const parsed = parse( input );
const { errors, warnings } = validate( parsed, input );
const expectedErrors = tryToLoadJson( `test/validator/${dir}/errors.json` ) || [];
const expectedWarnings = tryToLoadJson( `test/validator/${dir}/warnings.json` ) || [];
assert.deepEqual( errors, expectedErrors );
assert.deepEqual( warnings, expectedWarnings );
} catch ( err ) {
if ( err.name !== 'ParseError' ) throw err;
try {
const expected = require( `./validator/${dir}/error.json` );
assert.equal( err.shortMessage, expected.message );
assert.deepEqual( err.loc, expected.loc );
assert.equal( err.pos, expected.pos );
} catch ( err2 ) {
throw err2.code === 'MODULE_NOT_FOUND' ? err : err2;
}
}
});
});
});
describe( 'generate', () => {
before( () => {
function cleanChildren ( node ) {
let previous = null;

@ -0,0 +1,8 @@
[{
"message": "A component can only have a default export",
"pos": 10,
"loc": {
"line": 2,
"column": 1
}
}]

@ -0,0 +1,3 @@
<script>
export var foo = 42;
</script>

@ -0,0 +1,8 @@
[{
"message": "The 'computed' property must be an object literal",
"loc": {
"line": 5,
"column": 2
},
"pos": 42
}]

@ -0,0 +1,7 @@
<div></div>
<script>
export default {
computed: notAnObject
};
</script>

@ -0,0 +1,8 @@
[{
"message": "Computed properties can be function expressions or arrow function expressions",
"loc": {
"line": 6,
"column": 8
},
"pos": 62
}]

@ -0,0 +1,9 @@
<div></div>
<script>
export default {
computed: {
foo: notAFunction
}
};
</script>

@ -0,0 +1,8 @@
[{
"message": "Computed properties cannot use destructuring in function parameters",
"loc": {
"line": 6,
"column": 8
},
"pos": 62
}]

@ -0,0 +1,9 @@
<div></div>
<script>
export default {
computed: {
foo ({ a, b }) {}
}
};
</script>

@ -0,0 +1,8 @@
[{
"message": "Duplicate property 'methods'",
"loc": {
"line": 9,
"column": 2
},
"pos": 74
}]

@ -0,0 +1,13 @@
<div></div>
<script>
export default {
methods: {
foo () {}
},
methods: {
bar () {}
}
};
</script>

@ -0,0 +1,8 @@
[{
"message": "Unexpected property 'doSomething' (did you mean to include it in 'methods'?)",
"loc": {
"line": 5,
"column": 2
},
"pos": 42
}]

@ -0,0 +1,9 @@
<div></div>
<script>
export default {
doSomething () {
alert( 'boo' );
}
};
</script>

@ -0,0 +1,8 @@
[{
"message": "Unexpected property 'dada' (did you mean 'data'?)",
"loc": {
"line": 5,
"column": 2
},
"pos": 42
}]

@ -0,0 +1,11 @@
<div></div>
<script>
export default {
dada () {
return {
foo: 42
}
}
};
</script>
Loading…
Cancel
Save