interface ITokenConfig {
name?: string;
startsWith?: string;
endsWith?: string;
skip?: string;
filter?: boolean;
t?: string | string[];
e?: string;
b?: string;
startScope?: string;
endScope?: string;
}
interface ITokenMatcher extends ITokenConfig {
name: string;
scopeName: string;
regex?: RegExp;
blockRegex?: RegExp;
}
type IToken = {
text: string;
name: string;
scope?: string;
offset: number;
line: number;
column: number;
};
type ITokenArray = Array<ITokenArray | IToken> & {
parent?: ITokenArray;
scope?: string;
};
interface IGrammar {
name?: string;
scopes: Record<string, Record<string, ITokenConfig>>;
}
const getTokens = (grammar: IGrammar): ITokenMatcher[] => {
const tokens: ITokenMatcher[] = [];
for (const [scopeName, scope] of Object.entries(grammar.scopes)) {
for (const [name, token] of Object.entries(scope)) {
tokens.push({
...token,
name,
scopeName,
regex: token.e
? new RegExp(`^(?:${token.e})`)
: undefined,
blockRegex: token.b
? new RegExp(token.b, 'm')
: undefined
});
}
}
return tokens;
};
const findEnd = (
input: string,
start: number,
token: ITokenMatcher
): number => {
const sw = token.startsWith!;
const ew = token.endsWith!;
let end = input.indexOf(ew, start + sw.length);
if (token.skip) {
while (end >= 0) {
let p = end - 1;
while (p >= start + sw.length && input[p] === '\\') {
p--;
}
if ((end - p) % 2 !== 0) break;
end = input.indexOf(ew, end + ew.length);
}
}
return end < 0 ? input.length : end + ew.length;
};
const matchToken = (
input: string,
pos: number,
token: ITokenMatcher
): number => {
if (token.t) {
const values = Array.isArray(token.t) ? token.t : [token.t];
let length = 0;
for (const value of values) {
if (input.startsWith(value, pos)) {
length = Math.max(length, value.length);
}
}
return length;
}
if (token.startsWith && token.endsWith) {
if (!input.startsWith(token.startsWith, pos)) return 0;
return findEnd(input, pos, token) - pos;
}
if (token.regex) {
const match = input.slice(pos).match(token.regex);
if (!match) return 0;
if (token.blockRegex?.test(match[0])) return 0;
return match[0].length;
}
return 0;
};
const newScope = (grammar: IGrammar) => {
const allTokens = getTokens(grammar);
let scopeTokens: ITokenMatcher[] = [];
const root: ITokenArray = [];
let current = root;
const update = () => {
scopeTokens = allTokens.filter(
token => token.scopeName === current.scope
);
};
const push = (scope: string) => {
const child: ITokenArray = [];
child.parent = current;
child.scope = scope;
current.push(child);
current = child;
update();
};
const pop = () => {
if (current.parent) current = current.parent;
update();
};
push('default');
return {
getTree: () => root,
getScopeTokens: () => scopeTokens,
addMatch: (
token: ITokenMatcher | null,
text: string,
offset: number,
line: number,
column: number
) => {
if (!token) {
current.push({
text,
name: 'UNKNOWN',
offset,
line,
column
});
return;
}
if (!token.filter) {
current.push({
text,
name: token.name,
offset,
line,
column
});
}
if (token.startScope) {
push(token.startScope);
}
if (token.endScope && current.scope === token.endScope) {
pop();
}
}
};
};
const lex = (input: string, grammar: IGrammar): ITokenArray => {
const scope = newScope(grammar);
let pos = 0;
let line = 1;
let column = 1;
while (pos < input.length) {
let best: ITokenMatcher | null = null;
let bestLength = 0;
for (const token of scope.getScopeTokens()) {
const length = matchToken(input, pos, token);
if (length > bestLength) {
best = token;
bestLength = length;
}
}
if (!best || bestLength === 0) {
const text = input[pos]!;
scope.addMatch(
null,
text,
pos,
line,
column
);
if (text === '\n') {
line++;
column = 1;
} else {
column++;
}
pos++;
continue;
}
const text = input.slice(pos, pos + bestLength);
scope.addMatch(
best,
text,
pos,
line,
column
);
for (let i = 0; i < text.length; i++) {
if (text[i] === '\n') {
line++;
column = 1;
} else {
column++;
}
}
pos += bestLength;
}
return scope.getTree();
};
const grammar: IGrammar = {
name: 'custom1',
scopes: {
default: {
'comment-line': {
startsWith: '//',
endsWith: '\n',
filter: true
},
'comment-block': {
startsWith: '/*',
endsWith: '*/',
filter: true
},
'brace-start': {
t: '(',
startScope: 'default'
},
'brace-end': {
t: ')',
endScope: 'default'
},
assignment: {
t: '='
},
'bool-operator': {
t: ['==', '!=']
},
'incr-operator': {
t: ['+=', '-=']
},
'math-operator': {
t: ['+', '-', '*', '/']
},
number: {
e: '[0-9]+\\.?[0-9]*'
},
separator: {
t: ';'
},
string: {
startsWith: '"',
skip: '\\"',
endsWith: '"'
},
function: {
t: 'function',
startScope: 'function'
},
name: {
e: '[_a-zA-Z][_a-zA-Z0-9]*'
},
whitespace: {
e: '\\s+',
filter: true
}
},
function: {
'comment-line': {
startsWith: '//',
endsWith: '\n',
filter: true
},
'comment-block': {
startsWith: '/*',
endsWith: '*/',
filter: true
},
number: {
e: '[0-9]+\\.?[0-9]*'
},
whitespace: {
e: '\\s+',
filter: true
},
end: {
t: 'end',
endScope: 'function'
}
}
}
};
const expr = `
var a = (1+(2+3));
// single line comment
"Hello \\"{0}\\" this is nice";
/* multiline
comment */
work a function 0.5 4 3.22 end 5 6.2 8
`;
const tokens = lex(expr, grammar);
function printTokens(
tokens: ITokenArray,
depth = 0
): string[] {
const result: string[] = [];
for (const token of tokens) {
if (Array.isArray(token)) {
result.push(
...printTokens(token, depth + 1)
);
} else {
result.push(
`${' '.repeat(depth)}${token.text.trim()} ` +
`<-(${token.name}) ` +
`[${token.line}:${token.column}]`
);
}
}
return result;
}
console.log(printTokens(tokens).join('\n'));
var <-(name) [2:5]
a <-(name) [2:9]
= <-(assignment) [2:11]
( <-(brace-start) [2:13]
1 <-(number) [2:14]
+ <-(math-operator) [2:15]
( <-(brace-start) [2:16]
2 <-(number) [2:17]
+ <-(math-operator) [2:18]
3 <-(number) [2:19]
) <-(brace-end) [2:20]
) <-(brace-end) [2:21]
; <-(separator) [2:22]
"Hello \"{0}\" this is nice" <-(string) [4:5]
; <-(separator) [4:33]
work <-(name) [7:5]
a <-(name) [7:10]
function <-(function) [7:12]
0.5 <-(number) [7:21]
4 <-(number) [7:25]
3.22 <-(number) [7:27]
end <-(end) [7:32]
5 <-(number) [7:36]
6.2 <-(number) [7:38]
8 <-(number) [7:42]
251500cookie-checkTypescript simple parser