When creating new function from JavaScript code using new Function(params,body) constructor, passing invalid string in body yelds SyntaxError. While this exception contains error message (ie: Unexpected token =
), but does not seem to contain context (ie. line/column or character where error was found).
Example fiddle: https://jsfiddle.net/gheh1m8p/
var testWithSyntaxError = "{\n\n\n=2;}";
try {
var f=new Function('',testWithSyntaxError);
} catch(e) {
console.log(e instanceof SyntaxError);
console.log(e.message);
console.log(e.name);
console.log(e.fileName);
console.log(e.lineNumber);
console.log(e.columnNumber);
console.log(e.stack);
}
Output:
true
(index):54 Unexpected token =
(index):55 SyntaxError
(index):56 undefined
(index):57 undefined
(index):58 undefined
(index):59 SyntaxError: Unexpected token =
at Function (native)
at window.onload (https://fiddle.jshell.net/_display/:51:8)
How can I, without using external dependencies, pinpoint SyntaxError location withinn passed string? I require solution both for browser and nodejs.
Please note: I do have a valid reason to use eval-equivalent code.
In Chromium-based browsers, as you've seen, putting try
/catch
around something that throws a SyntaxError while V8 is parsing the code (before actually running it) won't produce anything helpful; it will describe the line that caused the evaluation of the problematic script in the stack trace, but no details on where the problem was in said script.
But, there's a cross-browser workaround. Instead of using try
/catch
, you can add an error
listener to window
, and the first argument provided to the callback will be an ErrorEvent
which has useful lineno
and colno
properties:
window.addEventListener('error', (errorEvent) => {
const { lineno, colno } = errorEvent;
console.log(`Error thrown at: ${lineno}:${colno}`);
// Don't pollute the console with additional info:
errorEvent.preventDefault();
});
const checkSyntax = (str) => {
// Using setTimeout because when an error is thrown without a catch,
// even if the error listener calls preventDefault(),
// the current thread will stop
setTimeout(() => {
eval(str);
});
};
checkSyntax(`console.log('foo') bar baz`);
checkSyntax(`foo bar baz`);
Look in your browser console to see this in action, not in the snippet console
Check the results in your browser console:
Error thrown at: 1:20
Error thrown at: 1:5
Which is what we want! Character 20 corresponds to
console.log('foo') bar baz
^
and character 5 corresponds to
foo bar baz
^
There are a couple issues, though: it would be good to make sure in the error
listened for is an error thrown when running checkSyntax
. Also, try
/catch
can be used for runtime errors (including syntax errors) after the script text has been parsed into an AST by the interpreter. So, you might have checkSyntax
only check that the Javascript is initially parsable, and nothing else, and then use try/catch
(if you want to run the code for real) to catch runtime errors. You can do this by inserting throw new Error
to the top of the text that's eval
ed.
Here's a convenient Promise-based function which can accomplish that:
// Use an IIFE to keep from polluting the global scope
(async () => {
let stringToEval;
let checkSyntaxResolve;
const cleanup = () => {
stringToEval = null;
checkSyntaxResolve = null; // not necessary, but makes things clearer
};
window.addEventListener('error', (errorEvent) => {
if (!stringToEval) {
// The error was caused by something other than the checkSyntax function below; ignore it
return;
}
const stringToEvalToPrint = stringToEval.split('\n').slice(1).join('\n');
// Don't pollute the console with additional info:
errorEvent.preventDefault();
if (errorEvent.message === 'Uncaught Error: Parsing successful!') {
console.log(`Parsing successful for: ${stringToEvalToPrint}`);
checkSyntaxResolve();
cleanup();
return;
}
const { lineno, colno } = errorEvent;
console.log(`Syntax error thrown at: ${lineno - 1}:${colno}`);
console.log(describeError(stringToEval, lineno, colno));
// checkSyntaxResolve should *always* be defined at this point - checkSyntax's eval was just called (synchronously)
checkSyntaxResolve();
cleanup();
});
const checkSyntax = (str) => {
console.log('----------------------------------------');
return new Promise((resolve) => {
checkSyntaxResolve = resolve;
// Using setTimeout because when an error is thrown without a catch,
// even if the 'error' listener calls preventDefault(),
// the current thread will stop
setTimeout(() => {
// If we only want to check the syntax for initial parsing validity,
// but not run the code for real, throw an error at the top:
stringToEval = `throw new Error('Parsing successful!');\n${str}`;
eval(stringToEval);
});
});
};
const describeError = (stringToEval, lineno, colno) => {
const lines = stringToEval.split('\n');
const line = lines[lineno - 1];
return `${line}\n${' '.repeat(colno - 1) + '^'}`;
};
await checkSyntax(`console.log('I will throw') bar baz`);
await checkSyntax(`foo bar baz will throw too`);
await checkSyntax(`console.log('A snippet without compile errors'); const foo = bar;`);
await checkSyntax(`console.log('A multi line snippet');
With a syntax error on the second line`);
})();
Look in your browser console to see this in action, not in the snippet console
await checkSyntax(`console.log('I will throw') bar baz`);
await checkSyntax(`foo bar baz will throw too`);
await checkSyntax(`console.log('A snippet without compile errors'); const foo = bar;`);
await checkSyntax(`console.log('A multi line snippet');
With a syntax error on the second line`);
Result:
----------------------------------------
Syntax error thrown at: 1:29
console.log('I will throw') bar baz
^
----------------------------------------
Syntax error thrown at: 1:5
foo bar baz will throw too
^
----------------------------------------
Parsing successful for: console.log('A snippet without compile errors'); const foo = bar;
----------------------------------------
Syntax error thrown at: 2:6
With a syntax error on the second line
^
If the fact that an error is thrown at window
is a problem (for example, if something else is already listening for window errors, which you don't want to disturb, and you can't attach your listener first and call stopImmediatePropagation()
on the event), another option is to use a web worker instead, which has its own execution context completely separate from the original window
:
// Worker:
const getErrorEvent = (() => {
const workerFn = () => {
const doEvalAndReply = (jsText) => {
self.addEventListener(
'error',
(errorEvent) => {
// Don't pollute the browser console:
errorEvent.preventDefault();
// The properties we want are actually getters on the prototype;
// they won't be retrieved when just stringifying
// so, extract them manually, and put them into a new object:
const { lineno, colno, message } = errorEvent;
const plainErrorEventObj = { lineno, colno, message };
self.postMessage(JSON.stringify(plainErrorEventObj));
},
{ once: true }
);
eval(jsText);
};
self.addEventListener('message', (e) => {
doEvalAndReply(e.data);
});
};
const blob = new Blob(
[ `(${workerFn})();`],
{ type: "text/javascript" }
);
const worker = new Worker(window.URL.createObjectURL(blob));
// Use a queue to ensure processNext only calls the worker once the worker is idle
const processingQueue = [];
let processing = false;
const processNext = () => {
processing = true;
const { resolve, jsText } = processingQueue.shift();
worker.addEventListener(
'message',
({ data }) => {
resolve(JSON.parse(data));
if (processingQueue.length) {
processNext();
} else {
processing = false;
}
},
{ once: true }
);
worker.postMessage(jsText);
};
return (jsText) => new Promise((resolve) => {
processingQueue.push({ resolve, jsText });
if (!processing) {
processNext();
}
});
})();
// Calls worker:
(async () => {
const checkSyntax = async (str) => {
console.log('----------------------------------------');
const stringToEval = `throw new Error('Parsing successful!');\n${str}`;
const { lineno, colno, message } = await getErrorEvent(stringToEval);
if (message === 'Uncaught Error: Parsing successful!') {
console.log(`Parsing successful for: ${str}`);
return;
}
console.log(`Syntax error thrown at: ${lineno - 1}:${colno}`);
console.log(describeError(stringToEval, lineno, colno));
};
const describeError = (stringToEval, lineno, colno) => {
const lines = stringToEval.split('\n');
const line = lines[lineno - 1];
return `${line}\n${' '.repeat(colno - 1) + '^'}`;
};
await checkSyntax(`console.log('I will throw') bar baz`);
await checkSyntax(`foo bar baz will throw too`);
await checkSyntax(`console.log('A snippet without compile errors'); const foo = bar;`);
await checkSyntax(`console.log('A multi line snippet');
With a syntax error on the second line`);
})();
Look in your browser console to see this in action, not in the snippet console
Essentially, what checkSyntax
is doing is checking to see if the code provided can be parsed into an Abstract Syntax Tree by the current interpreter. You can also use packages like @babel/parser or acorn to attempt to parse the string, though you'll have to configure it for the syntax permitted in the current environment (which will change as new syntax gets added to the language).
const checkSyntax = (str) => {
try {
acorn.Parser.parse(str);
console.log('Parsing successful');
} catch(e){
console.error(e.message);
}
};
checkSyntax(`console.log('I will throw') bar baz`);
checkSyntax(`foo bar baz will throw too`);
checkSyntax(`console.log('A snippet without compile errors'); const foo = bar;`);
checkSyntax(`console.log('A multi line snippet');
With a syntax error on the second line`);
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/acorn.min.js"></script>
The above works for browsers. In Node, the situation is different: listening for an uncaughtException can't be used to intercept the details of syntax errors, AFAIK. However, you can use vm module to attempt to compile the code, and if it throws a SyntaxError before running, you'll see something like this. Running
console.log('I will throw') bar baz
results in a stack of
evalmachine.<anonymous>:1
console.log('I will throw') bar baz
^^^
SyntaxError: Unexpected identifier
at createScript (vm.js:80:10)
at Object.runInNewContext (vm.js:135:10)
<etc>
So, just look at the first item in the stack to get the line number, and at the number of spaces before the ^
to get the column number. Using a similar technique as earlier, throw an error on the first line if parsing is successful:
const vm = require('vm');
const checkSyntax = (code) => {
console.log('---------------------------');
try {
vm.runInNewContext(`throw new Error();\n${code}`);
}
catch (e) {
describeError(e.stack);
}
};
const describeError = (stack) => {
const match = stack
.match(/^\D+(\d+)\n(.+\n( *)\^+)\n\n(SyntaxError.+)/);
if (!match) {
console.log('Parse successful!');
return;
}
const [, linenoPlusOne, caretString, colSpaces, message] = match;
const lineno = linenoPlusOne - 1;
const colno = colSpaces.length + 1;
console.log(`${lineno}:${colno}: ${message}\n${caretString}`);
};
checkSyntax(`console.log('I will throw') bar baz`);
checkSyntax(`foo bar baz will throw too`);
checkSyntax(`console.log('A snippet without compile errors'); const foo = bar;`);
checkSyntax(`console.log('A multi line snippet');
With a syntax error on the second line`);
Result:
---------------------------
1:29: SyntaxError: Unexpected identifier
console.log('I will throw') bar baz
^^^
---------------------------
1:5: SyntaxError: Unexpected identifier
foo bar baz will throw too
^^^
---------------------------
Parse successful!
---------------------------
2:6: SyntaxError: Unexpected identifier
With a syntax error on the second line
^
That said:
How can I, without using external dependencies, pinpoint SyntaxError location withinn passed string? I require solution both for browser and nodejs.
Unless you have to achieve this without an external library, using a library really would be the easiest (and tried-and-tested) solution. Acorn, as shown earlier (and other parsers) work in Node as well.
I'm sumarizing comments and some additional research:
There is currently no cross-platform way to retrive syntax error position from new Function()
or eval()
call.
error.lineNumber
and error.e.columnNumber
. This can be used with feature detection if position of error is not critical.Solutions 1 and 2 are incomplete, rely on features that are not part of standard. They can be suitable if this information is a help, not an requirement.
Solution 3 depends on external codebase, which was explicitly required by original question. It is suitable if this information is required and larger codebase is acceptable compromise.
Solution 4 is impractical.
Credits: @user3896470, @ivan-kuckir, @aprillion
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With