From e1a0e655c67c1335353425c242dbca5749760ece Mon Sep 17 00:00:00 2001 From: Jason Fields Date: Tue, 26 Oct 2021 18:11:29 -0400 Subject: [PATCH] [WIP] MVP support for expressions, `:let`, and `:echo` There remain a mountain of bugs and TODOs, but this a big step toward much more substantial vimscript support. Refs #463 Fixes #7136, fixes #7155 --- package.json | 6 + src/cmd_line/commands/echo.ts | 53 +++ src/cmd_line/commands/let.ts | 139 ++++++++ src/error.ts | 38 ++ src/vimscript/exCommandParser.ts | 10 +- src/vimscript/expression/build.ts | 119 +++++++ src/vimscript/expression/evaluate.ts | 490 ++++++++++++++++++++++++++ src/vimscript/expression/parser.ts | 320 +++++++++++++++++ src/vimscript/expression/types.ts | 143 ++++++++ src/vimscript/parserUtils.ts | 4 +- test/vimscript/exCommandParse.test.ts | 25 ++ test/vimscript/expression.test.ts | 309 ++++++++++++++++ 12 files changed, 1651 insertions(+), 5 deletions(-) create mode 100644 src/cmd_line/commands/echo.ts create mode 100644 src/cmd_line/commands/let.ts create mode 100644 src/vimscript/expression/build.ts create mode 100644 src/vimscript/expression/evaluate.ts create mode 100644 src/vimscript/expression/parser.ts create mode 100644 src/vimscript/expression/types.ts create mode 100644 test/vimscript/expression.test.ts diff --git a/package.json b/package.json index 57ff1a5a1a15..c4f1ddb0d6c7 100644 --- a/package.json +++ b/package.json @@ -1219,5 +1219,11 @@ "webpack-cli": "4.10.0", "webpack-merge": "5.8.0", "webpack-stream": "7.0.0" + }, + "__metadata": { + "id": "d96e79c6-8b25-4be3-8545-0e0ecefcae03", + "publisherDisplayName": "vscodevim", + "publisherId": "5d63889b-1b67-4b1f-8350-4f1dce041a26", + "isPreReleaseVersion": false } } diff --git a/src/cmd_line/commands/echo.ts b/src/cmd_line/commands/echo.ts new file mode 100644 index 000000000000..bb793594525a --- /dev/null +++ b/src/cmd_line/commands/echo.ts @@ -0,0 +1,53 @@ +import { optWhitespace, Parser, whitespace } from 'parsimmon'; +import { VimState } from '../../state/vimState'; +import { StatusBar } from '../../statusBar'; +import { ExCommand } from '../../vimscript/exCommand'; +import { EvaluationContext } from '../../vimscript/expression/evaluate'; +import { expressionParser } from '../../vimscript/expression/parser'; +import { Expression, Value } from '../../vimscript/expression/types'; + +export function displayValue(value: Value, topLevel = true): string { + switch (value.type) { + case 'number': + case 'float': + // TODO: this is incorrect for float with exponent + return value.value.toString(); + case 'string': + return topLevel ? value.value : `'${value.value.replace("'", "''")}'`; + case 'list': + return `[${value.items.map((v) => displayValue(v, false)).join(', ')}]`; + case 'dict_val': + return `{${[...value.items] + .map(([k, v]) => `'${k}': ${displayValue(v, false)}`) + .join(', ')}}`; + } +} + +export class EchoCommand extends ExCommand { + public static argParser(echoArgs: { sep: string; error: boolean }): Parser { + return optWhitespace + .then(expressionParser.sepBy(whitespace)) + .map((expressions) => new EchoCommand(echoArgs, expressions)); + } + + private sep: string; + private error: boolean; + private expressions: Expression[]; + private constructor(args: { sep: string; error: boolean }, expressions: Expression[]) { + super(); + this.sep = args.sep; + this.error = args.error; + this.expressions = expressions; + } + + public override neovimCapable(): boolean { + return true; + } + + public async execute(vimState: VimState): Promise { + const ctx = new EvaluationContext(); + const values = this.expressions.map((x) => ctx.evaluate(x)); + const message = values.map((v) => displayValue(v)).join(this.sep); + StatusBar.setText(vimState, message, this.error); + } +} diff --git a/src/cmd_line/commands/let.ts b/src/cmd_line/commands/let.ts new file mode 100644 index 000000000000..75c46424d76f --- /dev/null +++ b/src/cmd_line/commands/let.ts @@ -0,0 +1,139 @@ +import { alt, optWhitespace, Parser, seq, string, whitespace } from 'parsimmon'; +import { env } from 'process'; +import { VimState } from '../../state/vimState'; +import { StatusBar } from '../../statusBar'; +import { ExCommand } from '../../vimscript/exCommand'; +import { + add, + concat, + divide, + modulo, + multiply, + str, + subtract, +} from '../../vimscript/expression/build'; +import { EvaluationContext } from '../../vimscript/expression/evaluate'; +import { + envVariableParser, + expressionParser, + optionParser, + registerParser, + variableParser, +} from '../../vimscript/expression/parser'; +import { + EnvVariableExpression, + Expression, + OptionExpression, + RegisterExpression, + VariableExpression, +} from '../../vimscript/expression/types'; +import { displayValue } from './echo'; + +export type LetCommandOperation = '=' | '+=' | '-=' | '*=' | '/=' | '%=' | '.=' | '..='; +export type LetCommandVariable = + | VariableExpression + | OptionExpression + | RegisterExpression + | EnvVariableExpression; +export type LetCommandArgs = + | { + operation: LetCommandOperation; + variable: LetCommandVariable; + expression: Expression; + } + | { + operation: 'print'; + variables: LetCommandVariable[]; + }; + +const operationParser: Parser = alt( + string('='), + string('+='), + string('-='), + string('*='), + string('/='), + string('%='), + string('.='), + string('..=') +); + +const letVarParser: Parser = alt( + variableParser, + optionParser, + envVariableParser, + registerParser +); + +export class LetCommand extends ExCommand { + // TODO: Support unpacking + // TODO: Support indexing + // TODO: Support slicing + public static readonly argParser: Parser = alt( + // `:let {var} = {expr}` + // `:let {var} += {expr}` + // `:let {var} -= {expr}` + // `:let {var} .= {expr}` + whitespace.then( + seq(letVarParser, operationParser.wrap(optWhitespace, optWhitespace), expressionParser).map( + ([variable, operation, expression]) => + new LetCommand({ + operation, + variable, + expression, + }) + ) + ), + // `:let` + // `:let {var-name} ...` + optWhitespace + .then(letVarParser.sepBy(whitespace)) + .map((variables) => new LetCommand({ operation: 'print', variables })) + ); + + private args: LetCommandArgs; + constructor(args: LetCommandArgs) { + super(); + this.args = args; + } + + async execute(vimState: VimState): Promise { + const context = new EvaluationContext(); + if (this.args.operation === 'print') { + if (this.args.variables.length === 0) { + // TODO + } else { + const variable = this.args.variables[this.args.variables.length - 1]; + const value = context.evaluate(variable); + // TODO: If number, should include # sign + StatusBar.setText(vimState, `${variable.name} ${displayValue(value)}`); + } + } else { + const variable = this.args.variable; + let value = context.evaluate(this.args.expression); + if (variable.type === 'variable') { + if (this.args.operation === '+=') { + value = context.evaluate(add(variable, value)); + } else if (this.args.operation === '-=') { + value = context.evaluate(subtract(variable, value)); + } else if (this.args.operation === '*=') { + value = context.evaluate(multiply(variable, value)); + } else if (this.args.operation === '/=') { + value = context.evaluate(divide(variable, value)); + } else if (this.args.operation === '%=') { + value = context.evaluate(modulo(variable, value)); + } else if (this.args.operation === '.=') { + value = context.evaluate(concat(variable, value)); + } else if (this.args.operation === '..=') { + value = context.evaluate(concat(variable, value)); + } + context.setVariable(variable, value); + } else if (variable.type === 'register') { + // TODO + } else if (variable.type === 'option') { + // TODO + } else if (variable.type === 'env_variable') { + value = str(env[variable.name] ?? ''); + } + } + } +} diff --git a/src/error.ts b/src/error.ts index d7fb9a1db999..dd2c4dbbecc3 100644 --- a/src/error.ts +++ b/src/error.ts @@ -4,6 +4,7 @@ interface IErrorMessage { export enum ErrorCode { InvalidAddress = 14, + InvalidExpression = 15, InvalidRange = 16, MarkNotSet = 20, NoAlternateFile = 23, @@ -13,6 +14,10 @@ export enum ErrorCode { NoPreviousCommand = 34, NoPreviousRegularExpression = 35, NoWriteSinceLastChange = 37, + UnknownFunction = 117, + TooManyArgs = 118, + NotEnoughArgs = 119, + UndefinedVariable = 121, ErrorWritingToFile = 208, FileNoLongerAvailable = 211, RecursiveMapping = 223, @@ -34,11 +39,26 @@ export enum ErrorCode { AtStartOfChangeList = 662, AtEndOfChangeList = 663, ChangeListIsEmpty = 664, + ListIndexOutOfRange = 684, + CanOnlyCompareListWithList = 691, + InvalidOperationForList = 692, + KeyNotPresentInDictionary = 716, + CannotUseSliceWithADictionary = 719, + DuplicateKeyInDictionary = 721, + UsingADictionaryAsANumber = 728, + UsingListAsAString = 730, + UsingDictionaryAsAString = 731, + CanOnlyCompareDictionaryWithDictionary = 735, + InvalidOperationForDictionary = 736, + UsingAListAsANumber = 745, NoPreviouslyUsedRegister = 748, + UsingAFloatAsANumber = 805, + UsingFloatAsAString = 806, } export const ErrorMessage: IErrorMessage = { 14: 'Invalid address', + 15: 'Invalid expression', 16: 'Invalid range', 20: 'Mark not set', 23: 'No alternate file', @@ -48,6 +68,10 @@ export const ErrorMessage: IErrorMessage = { 34: 'No previous command', 35: 'No previous regular expression', 37: 'No write since last change (add ! to override)', + 117: 'Unknown function', + 118: 'Too many arguments for function', + 119: 'Not enough arguments for function', + 121: 'Undefined variable', 208: 'Error writing to file', 211: 'File no longer available', // TODO: Should be `File "[file_name]" no longer available` 223: 'Recursive mapping', @@ -69,7 +93,21 @@ export const ErrorMessage: IErrorMessage = { 662: 'At start of changelist', 663: 'At end of changelist', 664: 'changelist is empty', + 684: 'list index out of range', + 691: 'Can only compare List with List', + 692: 'Invalid operation for List', + 716: 'Key not present in Dictionary', + 719: 'Cannot use [:] with a Dictionary', + 721: 'Duplicate key in Dictionary', + 728: 'Using a Dictionary as a Number', + 730: 'Using List as a String', + 731: 'Using Dictionary as a String', + 735: 'Can only compare Dictionary with Dictionary', + 736: 'Invalid operation for Dictionary', + 745: 'Using a List as a Number', 748: 'No previously used register', + 805: 'Using a Float as a Number', + 806: 'Using Float as a String', }; export class VimError extends Error { diff --git a/src/vimscript/exCommandParser.ts b/src/vimscript/exCommandParser.ts index f4203250324b..07ad324502b6 100644 --- a/src/vimscript/exCommandParser.ts +++ b/src/vimscript/exCommandParser.ts @@ -9,6 +9,7 @@ import { DeleteCommand } from '../cmd_line/commands/delete'; import { DigraphsCommand } from '../cmd_line/commands/digraph'; import { FileCommand } from '../cmd_line/commands/file'; import { FileInfoCommand } from '../cmd_line/commands/fileInfo'; +import { EchoCommand } from '../cmd_line/commands/echo'; import { GotoCommand } from '../cmd_line/commands/goto'; import { GotoLineCommand } from '../cmd_line/commands/gotoLine'; import { HistoryCommand } from '../cmd_line/commands/history'; @@ -44,6 +45,7 @@ import { StatusBar } from '../statusBar'; import { ExCommand } from './exCommand'; import { LineRange } from './lineRange'; import { nameAbbrevParser } from './parserUtils'; +import { LetCommand } from '../cmd_line/commands/let'; type ArgParser = Parser; @@ -202,11 +204,11 @@ export const builtinExCommands: ReadonlyArray<[[string, string], ArgParser | und [['dsp', 'lit'], undefined], [['e', 'dit'], FileCommand.argParsers.edit], [['ea', 'rlier'], undefined], - [['ec', 'ho'], undefined], - [['echoe', 'rr'], undefined], + [['ec', 'ho'], EchoCommand.argParser({ sep: ' ', error: false })], + [['echoe', 'rr'], EchoCommand.argParser({ sep: ' ', error: true })], [['echoh', 'l'], undefined], [['echom', 'sg'], undefined], - [['echon', ''], undefined], + [['echon', ''], EchoCommand.argParser({ sep: '', error: false })], [['el', 'se'], undefined], [['elsei', 'f'], undefined], [['em', 'enu'], undefined], @@ -297,7 +299,7 @@ export const builtinExCommands: ReadonlyArray<[[string, string], ArgParser | und [['ld', 'o'], undefined], [['le', 'ft'], LeftCommand.argParser], [['lefta', 'bove'], undefined], - [['let', ''], undefined], + [['let', ''], LetCommand.argParser], [['lex', 'pr'], undefined], [['lf', 'ile'], undefined], [['lfd', 'o'], undefined], diff --git a/src/vimscript/expression/build.ts b/src/vimscript/expression/build.ts new file mode 100644 index 000000000000..38b5a601a38c --- /dev/null +++ b/src/vimscript/expression/build.ts @@ -0,0 +1,119 @@ +import { + NumberValue, + Expression, + ListExpression, + UnaryExpression, + BinaryOp, + BinaryExpression, + FunctionCallExpression, + StringValue, + LambdaExpression, + VariableExpression, + Namespace, + FloatValue, +} from './types'; + +export function num(value: number): NumberValue { + return { + type: 'number', + value, + }; +} + +export function float(value: number): FloatValue { + return { + type: 'float', + value, + }; +} + +export function bool(value: boolean): NumberValue { + return num(value ? 1 : 0); +} + +export function str(value: string): StringValue { + return { + type: 'string', + value, + }; +} + +export function list(items: Expression[]): ListExpression { + return { + type: 'list', + items, + }; +} + +export function variable(name: string, namespace?: Namespace): VariableExpression { + return { + type: 'variable', + name, + namespace, + }; +} + +export function lambda(args: string[], body: Expression): LambdaExpression { + return { + type: 'lambda', + args, + body, + }; +} + +export function negative(operand: Expression): UnaryExpression { + return { + type: 'unary', + operator: '-', + operand, + }; +} + +export function positive(operand: Expression): UnaryExpression { + return { + type: 'unary', + operator: '+', + operand, + }; +} + +export function binary(lhs: Expression, operator: BinaryOp, rhs: Expression): BinaryExpression { + return { + type: 'binary', + operator, + lhs, + rhs, + }; +} + +export function add(lhs: Expression, rhs: Expression): BinaryExpression { + return binary(lhs, '+', rhs); +} + +export function subtract(lhs: Expression, rhs: Expression): BinaryExpression { + return binary(lhs, '-', rhs); +} + +export function multiply(lhs: Expression, rhs: Expression): BinaryExpression { + return binary(lhs, '*', rhs); +} + +export function divide(lhs: Expression, rhs: Expression): BinaryExpression { + return binary(lhs, '/', rhs); +} + +export function modulo(lhs: Expression, rhs: Expression): BinaryExpression { + return binary(lhs, '%', rhs); +} + +export function concat(lhs: Expression, rhs: Expression): BinaryExpression { + return binary(lhs, '..', rhs); +} + +export function funcCall(func: string, args: Expression[]): FunctionCallExpression { + return { + type: 'function_call', + func, + args, + }; +} diff --git a/src/vimscript/expression/evaluate.ts b/src/vimscript/expression/evaluate.ts new file mode 100644 index 000000000000..da6d34ce1451 --- /dev/null +++ b/src/vimscript/expression/evaluate.ts @@ -0,0 +1,490 @@ +import { all } from 'parsimmon'; +import { configuration } from '../../configuration/configuration'; +import { ErrorCode, VimError } from '../../error'; +import { globalState } from '../../state/globalState'; +import { bool, list, num, str } from './build'; +import { numberParser } from './parser'; +import { + BinaryOp, + ComparisonOp, + DictionaryValue, + Expression, + ListValue, + NumberValue, + StringValue, + UnaryOp, + Value, + VariableExpression, +} from './types'; + +function toNumber(value: Value): number { + switch (value.type) { + case 'number': + return value.value; + case 'float': + throw VimError.fromCode(ErrorCode.UsingAFloatAsANumber); + case 'string': + return numberParser.skip(all).tryParse(value.value).value; + case 'list': + throw VimError.fromCode(ErrorCode.UsingAListAsANumber); + case 'dict_val': + throw VimError.fromCode(ErrorCode.UsingADictionaryAsANumber); + } +} + +function toString(value: Value): string { + switch (value.type) { + case 'number': + return value.value.toString(); + case 'float': + throw VimError.fromCode(ErrorCode.UsingFloatAsAString); + case 'string': + return value.value; + case 'list': + throw VimError.fromCode(ErrorCode.UsingListAsAString); + case 'dict_val': + throw VimError.fromCode(ErrorCode.UsingDictionaryAsAString); + } +} + +export class EvaluationContext { + private static globalVariables = new Map(); + + /** + * Fully evaluates the given expression and returns the resulting value. + * May throw a variety of VimErrors if the expression is semantically invalid. + */ + public evaluate(expression: Expression): Value { + switch (expression.type) { + case 'number': + case 'float': + case 'string': + case 'dict_val': + return expression; + case 'list': + return list(expression.items.map((x) => this.evaluate(x))) as ListValue; // TODO: get rid of this `as` + case 'dictionary': + const items = new Map(); + for (const [key, val] of expression.items) { + const keyStr = toString(this.evaluate(key)); + if (items.has(keyStr)) { + throw VimError.fromCode(ErrorCode.DuplicateKeyInDictionary, `"${keyStr}"`); + } else { + items.set(keyStr, this.evaluate(val)); + } + } + return { + type: 'dict_val', + items, + }; + case 'variable': + return this.evaluateVariable(expression); + case 'register': + return str(''); // TODO + case 'option': + return str(''); // TODO + case 'env_variable': + return str(process.env[expression.name] ?? ''); + case 'function_call': + const getArgs = (min: number, max?: number) => { + if (expression.args.length < min) { + throw VimError.fromCode(ErrorCode.NotEnoughArgs, expression.func); + } + if (expression.args.length > (max ?? min)) { + throw VimError.fromCode(ErrorCode.TooManyArgs, expression.func); + } + return expression.args.map((arg) => this.evaluate(arg)); + }; + switch (expression.func) { + case 'abs': { + const [x] = getArgs(1); + return num(Math.abs(toNumber(x))); + } + case 'acos': { + const [x] = getArgs(1); + return num(Math.acos(toNumber(x))); + } + case 'asin': { + const [x] = getArgs(1); + return num(Math.asin(toNumber(x))); + } + case 'atan2': { + const [x, y] = getArgs(2); + return num(Math.atan2(toNumber(x), toNumber(y))); + } + case 'and': { + const [x, y] = getArgs(2); + // tslint:disable-next-line: no-bitwise + return num(toNumber(x) & toNumber(y)); + } + case 'ceil': { + const [x] = getArgs(1); + return num(Math.ceil(toNumber(x))); + } + case 'cos': { + const [x] = getArgs(1); + return num(Math.cos(toNumber(x))); + } + case 'cosh': { + const [x] = getArgs(1); + return num(Math.cosh(toNumber(x))); + } + case 'exp': { + const [x] = getArgs(1); + return num(Math.exp(toNumber(x))); + } + case 'floor': { + const [x] = getArgs(1); + return num(Math.floor(toNumber(x))); + } + case 'or': { + const [x, y] = getArgs(2); + // tslint:disable-next-line: no-bitwise + return num(toNumber(x) | toNumber(y)); + } + case 'pow': { + const [x, y] = getArgs(2); + return num(Math.pow(toNumber(x), toNumber(y))); + } + case 'sin': { + const [x] = getArgs(1); + return num(Math.sin(toNumber(x))); + } + case 'sinh': { + const [x] = getArgs(1); + return num(Math.sinh(toNumber(x))); + } + case 'sqrt': { + const [x] = getArgs(1); + return num(Math.sqrt(toNumber(x))); + } + case 'tan': { + const [x] = getArgs(1); + return num(Math.tan(toNumber(x))); + } + case 'tanh': { + const [x] = getArgs(1); + return num(Math.tanh(toNumber(x))); + } + case 'xor': { + const [x, y] = getArgs(2); + // tslint:disable-next-line: no-bitwise + return num(toNumber(x) ^ toNumber(y)); + } + // TODO: many, many more + default: { + throw VimError.fromCode(ErrorCode.UnknownFunction, expression.func); + } + } + case 'index': + return this.evaluateIndex( + this.evaluate(expression.expression), + this.evaluate(expression.index) + ); + case 'slice': + return this.evaluateSlice( + this.evaluate(expression.expression), + expression.start ? this.evaluate(expression.start) : num(0), + expression.end ? this.evaluate(expression.end) : num(-1) + ); + case 'entry': + return str(''); // TODO + case 'lambda': + return str(''); // TODO + case 'unary': + return this.evaluateUnary(expression.operator, expression.operand); + case 'binary': + return this.evaluateBinary(expression.operator, expression.lhs, expression.rhs); + case 'ternary': + return this.evaluate( + toNumber(this.evaluate(expression.if)) !== 0 ? expression.then : expression.else + ); + case 'comparison': + return bool( + this.evaluateComparison( + expression.operator, + expression.matchCase ?? configuration.ignorecase, + expression.lhs, + expression.rhs + ) + ); + default: + const guard: never = expression; + throw new Error(`evaluate() got unexpected expression type`); + } + } + + public setVariable(variable: VariableExpression, value: Value): void { + if (variable.namespace === 'g' || variable.namespace === undefined) { + // TODO: undefined should be local within a function + EvaluationContext.globalVariables.set(variable.name, value); + } else { + // TODO + } + } + + private evaluateVariable(variable: VariableExpression): Value { + if (variable.namespace === 'g' || variable.namespace === undefined) { + // TODO: undefined should be local within a function + const value = EvaluationContext.globalVariables.get(variable.name); + if (value === undefined) { + throw VimError.fromCode( + ErrorCode.UndefinedVariable, + variable.namespace ? `${variable.namespace}:${variable.name}` : variable.name + ); + } else { + return value; + } + } else if (variable.namespace === 'v') { + if (variable.name === 'true') { + return bool(true); + } else if (variable.name === 'false') { + return bool(false); + } else if (variable.name === 'hlsearch') { + return bool(globalState.hl); + } else if (variable.name === 't_number') { + return num(0); + } else if (variable.name === 't_string') { + return num(1); + } else if (variable.name === 't_func') { + return num(2); + } else if (variable.name === 't_list') { + return num(3); + } else if (variable.name === 't_dict') { + return num(4); + } else if (variable.name === 't_float') { + return num(5); + } else if (variable.name === 't_bool') { + return num(6); + } else if (variable.name === 'numbermax') { + return num(Number.MAX_VALUE); + } else if (variable.name === 'numbermin') { + return num(Number.MIN_VALUE); + } + } + + throw VimError.fromCode( + ErrorCode.UndefinedVariable, + variable.namespace ? `${variable.namespace}:${variable.name}` : variable.name + ); + } + + private evaluateIndex(sequence: Value, index: Value): Value { + switch (sequence.type) { + case 'string': + case 'number': + case 'float': { + const idx = toNumber(index); + return str(idx >= 0 ? toString(sequence)[idx] ?? '' : ''); + } + case 'list': { + let idx = toNumber(index); + idx = idx < 0 ? sequence.items.length - idx : 0; + if (idx < 0 || idx >= sequence.items.length) { + throw VimError.fromCode(ErrorCode.ListIndexOutOfRange, idx.toString()); + } + return sequence.items[toNumber(index)]; + } + case 'dict_val': { + const key = toString(index); + const result = sequence.items.get(key); + if (result === undefined) { + throw VimError.fromCode(ErrorCode.KeyNotPresentInDictionary, key); + } + return result; + } + } + } + + private evaluateSlice(sequence: Value, start: Value, end: Value): Value { + let _start = toNumber(start); + let _end = toNumber(end); + switch (sequence.type) { + case 'string': + case 'number': + case 'float': { + const _sequence = toString(sequence); + while (_start < 0) { + _start += _sequence.length; + } + while (_end < 0) { + _end += _sequence.length; + } + if (_end < _start) { + return str(''); + } + return str(_sequence.substring(_start, _end + 1)); + } + case 'list': { + while (_start < 0) { + _start += sequence.items.length; + } + while (_end < 0) { + _end += sequence.items.length; + } + if (_end < _start) { + return { + type: 'list', + items: [], + }; + } + return { + type: 'list', + items: sequence.items.slice(_start, _end + 1), + }; + } + case 'dict_val': { + throw VimError.fromCode(ErrorCode.CannotUseSliceWithADictionary); + } + } + } + + private evaluateUnary(operator: UnaryOp, operand: Expression): NumberValue { + const value = toNumber(this.evaluate(operand)); + switch (operator) { + case '+': + return num(value); + case '-': + return num(-value); + case '!': + return num(value === 0 ? 1 : 0); + } + } + + private evaluateBinary(operator: BinaryOp, lhsExpr: Expression, rhsExpr: Expression): Value { + const [lhs, rhs] = [this.evaluate(lhsExpr), this.evaluate(rhsExpr)]; + switch (operator) { + case '+': + if (lhs.type === 'list' && rhs.type === 'list') { + return list(lhs.items.concat(rhs.items)) as ListValue; + } else { + return num(toNumber(lhs) + toNumber(rhs)); + } + case '-': + return num(toNumber(lhs) - toNumber(rhs)); + case '.': + case '..': + return str(toString(lhs) + toString(rhs)); + case '*': + return num(toNumber(lhs) * toNumber(rhs)); + case '/': + return num(Math.trunc(toNumber(lhs) / toNumber(rhs))); + case '%': + return num(toNumber(lhs) % toNumber(rhs)); + case '&&': + return bool(toNumber(lhs) !== 0 && toNumber(rhs) !== 0); + case '||': + return bool(toNumber(lhs) !== 0 || toNumber(rhs) !== 0); + } + } + + private evaluateComparison( + operator: ComparisonOp, + matchCase: boolean, + lhsExpr: Expression, + rhsExpr: Expression + ): boolean { + switch (operator) { + case '==': + return this.evaluateBasicComparison('==', matchCase, lhsExpr, rhsExpr); + case '!=': + return !this.evaluateBasicComparison('==', matchCase, lhsExpr, rhsExpr); + case '>': + return this.evaluateBasicComparison('>', matchCase, lhsExpr, rhsExpr); + case '>=': + return ( + this.evaluateBasicComparison('>', matchCase, lhsExpr, rhsExpr) || + this.evaluateBasicComparison('==', matchCase, lhsExpr, rhsExpr) + ); + case '<': + return this.evaluateBasicComparison('>', matchCase, rhsExpr, lhsExpr); + case '<=': + return !this.evaluateBasicComparison('>', matchCase, lhsExpr, rhsExpr); + case '=~': + return this.evaluateBasicComparison('=~', matchCase, lhsExpr, rhsExpr); + case '!~': + return !this.evaluateBasicComparison('=~', matchCase, lhsExpr, rhsExpr); + case 'is': + return this.evaluateBasicComparison('is', matchCase, lhsExpr, rhsExpr); + case 'isnot': + return !this.evaluateBasicComparison('is', matchCase, lhsExpr, rhsExpr); + } + } + + private evaluateBasicComparison( + operator: '==' | '>' | '=~' | 'is', + matchCase: boolean, + lhsExpr: Expression, + rhsExpr: Expression + ): boolean { + if (lhsExpr.type === 'list') { + if (rhsExpr.type === 'list') { + switch (operator) { + case '==': + return ( + lhsExpr.items.length === rhsExpr.items.length && + lhsExpr.items.every((left, idx) => + this.evaluateBasicComparison('==', matchCase, left, rhsExpr.items[idx]) + ) + ); + case 'is': + return false; + default: + throw VimError.fromCode(ErrorCode.InvalidOperationForList); + } + } else { + throw VimError.fromCode(ErrorCode.CanOnlyCompareListWithList); + } + } else if (rhsExpr.type === 'list') { + throw VimError.fromCode(ErrorCode.CanOnlyCompareListWithList); + } else if (lhsExpr.type === 'dictionary') { + if (rhsExpr.type === 'dictionary') { + const [lhs, rhs] = [this.evaluate(lhsExpr), this.evaluate(rhsExpr)] as [ + DictionaryValue, + DictionaryValue + ]; + switch (operator) { + case '==': + return ( + lhs.items.size === rhs.items.size && + [...lhs.items.entries()].every( + ([key, value]) => + rhs.items.has(key) && + this.evaluateBasicComparison('==', matchCase, value, rhs.items.get(key)!) + ) + ); + case 'is': + return false; + default: + throw VimError.fromCode(ErrorCode.InvalidOperationForDictionary); + } + } else { + throw VimError.fromCode(ErrorCode.CanOnlyCompareDictionaryWithDictionary); + } + } else if (rhsExpr.type === 'dictionary') { + throw VimError.fromCode(ErrorCode.CanOnlyCompareDictionaryWithDictionary); + } else { + let [lhs, rhs] = [this.evaluate(lhsExpr), this.evaluate(rhsExpr)] as [ + NumberValue | StringValue, + NumberValue | StringValue + ]; + if (lhs.type === 'number' || rhs.type === 'number') { + // TODO: this conversion should only be done at top level (not in list/dictionary) + [lhs, rhs] = [num(toNumber(lhs)), num(toNumber(rhs))]; + } else if (!matchCase) { + lhs.value = lhs.value.toLowerCase(); + rhs.value = rhs.value.toLowerCase(); + } + switch (operator) { + case '==': + return lhs.value === rhs.value; + case 'is': + return lhs.type === rhs.type && lhs.value === rhs.value; + case '>': + return lhs.value > rhs.value; + case '=~': + return false; // TODO + } + } + } +} diff --git a/src/vimscript/expression/parser.ts b/src/vimscript/expression/parser.ts new file mode 100644 index 000000000000..c4185a1d21ca --- /dev/null +++ b/src/vimscript/expression/parser.ts @@ -0,0 +1,320 @@ +import { Parser, regexp, seq, alt, string, lazy, any, optWhitespace, takeWhile } from 'parsimmon'; +import { binary, float, lambda, list, num, str } from './build'; +import { + BinaryOp, + DictionaryExpression, + EntryExpression, + EnvVariableExpression, + Expression, + FloatValue, + FunctionCallExpression, + IndexExpression, + LambdaExpression, + ListExpression, + NumberValue, + OptionExpression, + RegisterExpression, + SliceExpression, + StringValue, + VariableExpression, +} from './types'; + +const binaryNumberParser: Parser = regexp(/0[b]/i).then( + regexp(/[0-1]+/).map((x) => { + return num(Number.parseInt(x, 2)); + }) +); + +const hexadecimalNumberParser: Parser = regexp(/0[x]/i) + .then(regexp(/[0-9a-f]+/i)) + .map((x) => { + return num(Number.parseInt(x, 16)); + }); + +const decimalOrOctalNumberParser: Parser = regexp(/\d+/).map((x) => { + const base = x.startsWith('0') && /^[0-7]+$/.test(x) ? 8 : 10; + return num(Number.parseInt(x, base)); +}); + +// TODO: support exponent +const floatParser: Parser = regexp(/\d+\.\d+/) + .map((x) => float(Number.parseFloat(x))) + .desc('a float'); + +export const numberParser: Parser = alt( + binaryNumberParser, + hexadecimalNumberParser, + decimalOrOctalNumberParser +); + +const literalStringParser: Parser = regexp(/[^']*/) + .sepBy(string("''")) + .wrap(string("'"), string("'")) + .desc('a string') + .map((segments) => { + return { type: 'string', value: segments.join("'") }; + }); + +const listParser: Parser = lazy(() => expressionParser) + .sepBy(string(',').trim(optWhitespace)) + .skip(string(',').atMost(1)) + .trim(optWhitespace) + .wrap(string('['), string(']')) + .map((items) => list(items)) + .desc('a list'); + +const dictionaryParser: Parser = lazy(() => + alt( + string('#').then( + seq( + takeWhile((char) => char !== ':') + .map((x) => str(x)) + .skip(string(':')) + .trim(optWhitespace), + expressionParser + ) + .sepBy(string(',').trim(optWhitespace)) + .skip(string(',').atMost(1)) + .trim(optWhitespace) + .wrap(string('{'), string('}')) + ), + seq(expressionParser.skip(string(':').trim(optWhitespace)), expressionParser) + .sepBy(string(',').trim(optWhitespace)) + .skip(string(',').atMost(1)) + .trim(optWhitespace) + .wrap(string('{'), string('}')) + ).desc('a dictionary') +).map((items) => { + return { + type: 'dictionary', + items, + }; +}); + +export const optionParser: Parser = string('&') + .then( + seq(alt(string('g'), string('l')).skip(string(':')).atMost(1), regexp(/[a-z]+/).desc('&option')) + ) + .map(([scope, name]) => { + return { type: 'option', scope: scope ? scope[0] : undefined, name }; + }); + +const nestedExpressionParser: Parser = lazy(() => expressionParser) + .trim(optWhitespace) + .wrap(string('('), string(')')) + .desc('a nested expression'); + +export const variableParser: Parser = seq( + alt( + string('b'), + string('w'), + string('t'), + string('g'), + string('l'), + string('s'), + string('a'), + string('v') + ) + .skip(string(':')) + .fallback(undefined), + regexp(/[a-zA-Z][a-zA-Z0-9]*/).desc('a variable') +).map(([namespace, name]) => { + return { type: 'variable', namespace, name }; +}); + +export const envVariableParser: Parser = string('$') + .then(regexp(/[a-z]+/)) + .desc('$ENV') + .map((name) => { + return { type: 'env_variable', name }; + }); + +export const registerParser: Parser = string('@') + .then(any) + .desc('@register') + .map((name) => { + return { type: 'register', name }; + }); + +const functionCallParser: Parser = seq( + regexp(/[a-z]+/i).skip(optWhitespace), + lazy(() => + expressionParser + .sepBy(string(',').trim(optWhitespace)) + .trim(optWhitespace) + .wrap(string('('), string(')')) + ).desc('a function call') +).map(([func, args]) => { + return { + type: 'function_call', + func, + args, + }; +}); + +const lambdaParser: Parser = seq( + regexp(/[a-z]+/i) + .sepBy(string(',').trim(optWhitespace)) + .skip(string('->').trim(optWhitespace)), + lazy(() => expressionParser).desc('a lambda') +) + .trim(optWhitespace) + .wrap(string('{'), string('}')) + .map(([args, body]) => { + return lambda(args, body); + }); + +// TODO: Double quote strings +// TODO: Function call with funcref +// TODO: Method call +// TODO: Variable/function with curly braces +const expr9Parser: Parser = alt( + floatParser, + numberParser, + literalStringParser, + listParser, + dictionaryParser, + optionParser, + nestedExpressionParser, + functionCallParser, // NOTE: this is out of order with :help expr, but it seems necessary + variableParser, + envVariableParser, + registerParser, + lambdaParser +); + +const indexParser: Parser<(expr: Expression) => IndexExpression> = lazy(() => + expressionParser.trim(optWhitespace).wrap(string('['), string(']')) +).map((index) => { + return (expression: Expression) => { + return { type: 'index', expression, index }; + }; +}); + +const sliceParser: Parser<(expr: Expression) => SliceExpression> = lazy(() => + seq(expressionParser.atMost(1).skip(string(':').trim(optWhitespace)), expressionParser.atMost(1)) + .trim(optWhitespace) + .wrap(string('['), string(']')) +).map(([start, end]) => { + return (expression: Expression) => { + return { type: 'slice', expression, start: start[0], end: end[0] }; + }; +}); + +const entryParser: Parser<(expr: Expression) => EntryExpression> = string('.') + .then(regexp(/[a-z0-9]+/i)) + .map((entryName) => { + return (expression: Expression) => { + return { + type: 'entry', + expression, + entryName, + }; + }; + }); + +const expr8Parser: Parser = seq( + expr9Parser, + alt<(expr: Expression) => Expression>(indexParser, sliceParser, entryParser).many() +) + .desc('expr8') + .map(([expression, things]) => things.reduce((expr, thing) => thing(expr), expression)); + +// Logical NOT, unary plus/minus +const expr7Parser: Parser = alt( + seq( + alt(string('!'), string('-'), string('+')), + lazy(() => expr7Parser) + ).map(([operator, operand]) => { + return { type: 'unary', operator, operand }; + }), + expr8Parser +).desc('expr7'); + +// Number multiplication/division/modulo +const expr6Parser: Parser = seq( + expr7Parser, + seq(alt(string('*'), string('/'), string('%')).trim(optWhitespace), expr7Parser).many() +) + .map(leftAssociative) + .desc('expr6'); + +// Number addition/subtraction, string/list/blob concatenation +const expr5Parser: Parser = seq( + expr6Parser, + seq(alt(string('+'), string('-')).trim(optWhitespace), expr6Parser).many() +) + .map(leftAssociative) + .desc('expr5'); + +// Comparison +const expr4Parser: Parser = alt( + seq( + expr5Parser, + seq( + alt( + string('=='), + string('!='), + string('>'), + string('>='), + string('<'), + string('<='), + string('=~'), + string('!~'), + string('is'), + string('isnot') + ), + regexp(/[#\?]?/) + ).trim(optWhitespace), + expr5Parser + ).map(([lhs, [operator, matchCase], rhs]) => { + return { + type: 'comparison', + operator, + matchCase: matchCase === '#' ? true : matchCase === '?' ? false : undefined, + lhs, + rhs, + }; + }), + expr5Parser +).desc('expr4'); + +// Logical AND +const expr3Parser: Parser = seq( + expr4Parser, + seq(string('&&').trim(optWhitespace), expr4Parser).many() +) + .map(leftAssociative) + .desc('expr3'); + +// Logical OR +const expr2Parser: Parser = seq( + expr3Parser, + seq(string('||').trim(optWhitespace), expr3Parser).many() +) + .map(leftAssociative) + .desc('expr2'); + +// If-then-else +const expr1Parser: Parser = alt( + seq( + expr2Parser, + string('?').trim(optWhitespace), + expr2Parser, + string(':').trim(optWhitespace), + expr2Parser + ).map(([_if, x, _then, y, _else]) => { + return { type: 'ternary', if: _if, then: _then, else: _else }; + }), + expr2Parser +).desc('an expression'); + +function leftAssociative(args: [Expression, Array<[BinaryOp, Expression]>]) { + let lhs = args[0]; + for (const [operator, rhs] of args[1]) { + lhs = binary(lhs, operator, rhs); + } + return lhs; +} + +export const expressionParser = expr1Parser; diff --git a/src/vimscript/expression/types.ts b/src/vimscript/expression/types.ts new file mode 100644 index 000000000000..3be8cac665f6 --- /dev/null +++ b/src/vimscript/expression/types.ts @@ -0,0 +1,143 @@ +// -------------------- Values -------------------- + +export type NumberValue = { + type: 'number'; + value: number; +}; + +export type FloatValue = { + type: 'float'; + value: number; +}; + +export type StringValue = { + type: 'string'; + value: string; +}; + +export type ListValue = { + type: 'list'; + items: Value[]; +}; + +export type DictionaryValue = { + type: 'dict_val'; + items: Map; +}; + +export type Value = NumberValue | FloatValue | StringValue | ListValue | DictionaryValue; + +// -------------------- Expressions -------------------- + +export type ListExpression = { + type: 'list'; + items: Expression[]; +}; + +export type DictionaryExpression = { + type: 'dictionary'; + items: Array<[Expression, Expression]>; +}; + +export type OptionExpression = { + type: 'option'; + scope: 'l' | 'g' | undefined; + name: string; +}; + +export type Namespace = 'b' | 'w' | 't' | 'g' | 'l' | 's' | 'a' | 'v'; +export type VariableExpression = { + type: 'variable'; + namespace: Namespace | undefined; + name: string; +}; + +export type EnvVariableExpression = { + type: 'env_variable'; + name: string; +}; + +export type RegisterExpression = { + type: 'register'; + name: string; +}; + +export type FunctionCallExpression = { + type: 'function_call'; + func: string; + args: Expression[]; +}; + +export type LambdaExpression = { + type: 'lambda'; + args: string[]; + body: Expression; +}; + +export type IndexExpression = { + type: 'index'; + expression: Expression; + index: Expression; +}; + +export type SliceExpression = { + type: 'slice'; + expression: Expression; + start: Expression | undefined; + end: Expression | undefined; +}; + +export type EntryExpression = { + type: 'entry'; + expression: Expression; + entryName: string; +}; + +export type UnaryOp = '!' | '-' | '+'; +export type UnaryExpression = { + type: 'unary'; + operator: UnaryOp; + operand: Expression; +}; + +export type ComparisonOp = '==' | '!=' | '>' | '>=' | '<' | '<=' | '=~' | '!~' | 'is' | 'isnot'; +export type ComparisonExpression = { + type: 'comparison'; + operator: ComparisonOp; + matchCase: boolean | undefined; + lhs: Expression; + rhs: Expression; +}; + +export type BinaryOp = '*' | '/' | '%' | '.' | '..' | '-' | '+' | '&&' | '||'; +export type BinaryExpression = { + type: 'binary'; + operator: BinaryOp; + lhs: Expression; + rhs: Expression; +}; + +export type TernaryExpression = { + type: 'ternary'; + if: Expression; + then: Expression; + else: Expression; +}; + +export type Expression = + | Value + | ListExpression + | DictionaryExpression + | OptionExpression + | VariableExpression + | LambdaExpression + | IndexExpression + | SliceExpression + | EntryExpression + | EnvVariableExpression + | RegisterExpression + | FunctionCallExpression + | ComparisonExpression + | BinaryExpression + | UnaryExpression + | TernaryExpression; diff --git a/src/vimscript/parserUtils.ts b/src/vimscript/parserUtils.ts index b36412669f64..edb48c6dbd4d 100644 --- a/src/vimscript/parserUtils.ts +++ b/src/vimscript/parserUtils.ts @@ -1,6 +1,8 @@ import { alt, any, Parser, regexp, seq, string, succeed, whitespace } from 'parsimmon'; -export const numberParser: Parser = regexp(/\d+/).map((num) => Number.parseInt(num, 10)); +export const numberParser: Parser = regexp(/\d+/) + .map((num) => Number.parseInt(num, 10)) + .desc('a number'); export const bangParser: Parser = string('!') .fallback(undefined) diff --git a/test/vimscript/exCommandParse.test.ts b/test/vimscript/exCommandParse.test.ts index 4086a414daa1..338b3efa838a 100644 --- a/test/vimscript/exCommandParse.test.ts +++ b/test/vimscript/exCommandParse.test.ts @@ -9,6 +9,7 @@ import { GotoCommand } from '../../src/cmd_line/commands/goto'; import { GotoLineCommand } from '../../src/cmd_line/commands/gotoLine'; import { HistoryCommand, HistoryCommandType } from '../../src/cmd_line/commands/history'; import { LeftCommand, RightCommand } from '../../src/cmd_line/commands/leftRightCenter'; +import { LetCommand } from '../../src/cmd_line/commands/let'; import { DeleteMarksCommand, MarksCommand } from '../../src/cmd_line/commands/marks'; import { PutExCommand } from '../../src/cmd_line/commands/put'; import { QuitCommand } from '../../src/cmd_line/commands/quit'; @@ -23,6 +24,7 @@ import { WriteCommand } from '../../src/cmd_line/commands/write'; import { YankCommand } from '../../src/cmd_line/commands/yank'; import { ExCommand } from '../../src/vimscript/exCommand'; import { exCommandParser, NoOpCommand } from '../../src/vimscript/exCommandParser'; +import { num, str, variable } from '../../src/vimscript/expression/build'; import { Address } from '../../src/vimscript/lineRange'; import { Pattern, SearchDirection } from '../../src/vimscript/pattern'; import { ShiftCommand } from '../../src/cmd_line/commands/shift'; @@ -291,6 +293,29 @@ suite('Ex command parsing', () => { }); suite(':let', () => { + exParseTest(':let', new LetCommand({ operation: 'print', variables: [] })); + exParseTest( + ':let foo bar', + new LetCommand({ operation: 'print', variables: [variable('foo'), variable('bar')] }) + ); + + exParseTest( + ':let foo = 5', + new LetCommand({ operation: '=', variable: variable('foo'), expression: num(5) }) + ); + exParseTest( + ':let foo += 5', + new LetCommand({ operation: '+=', variable: variable('foo'), expression: num(5) }) + ); + exParseTest( + ':let foo -= 5', + new LetCommand({ operation: '-=', variable: variable('foo'), expression: num(5) }) + ); + exParseTest( + ":let foo .= 'bar'", + new LetCommand({ operation: '.=', variable: variable('foo'), expression: str('bar') }) + ); + // TODO }); diff --git a/test/vimscript/expression.test.ts b/test/vimscript/expression.test.ts new file mode 100644 index 000000000000..aa64b619d533 --- /dev/null +++ b/test/vimscript/expression.test.ts @@ -0,0 +1,309 @@ +import * as assert from 'assert'; +import { + num, + negative, + positive, + list, + funcCall, + multiply, + add, + str, + lambda, + variable, + float, +} from '../../src/vimscript/expression/build'; +import { EvaluationContext } from '../../src/vimscript/expression/evaluate'; +import { expressionParser } from '../../src/vimscript/expression/parser'; +import { Expression, Value } from '../../src/vimscript/expression/types'; + +function exprTest(input: string, parsed: Expression, evaluated?: Value) { + test(input, () => { + const expression = expressionParser.tryParse(input); + assert.deepStrictEqual(expression, parsed); + if (evaluated !== undefined) { + const ctx = new EvaluationContext(); + assert.deepStrictEqual(ctx.evaluate(expression), evaluated); + } + }); +} + +suite.only('Parse & evaluate expression', () => { + suite('Numbers', () => { + exprTest('0', num(0)); + exprTest('123', num(123)); + + // Hexadecimal + exprTest('0xff', num(255)); + exprTest('0Xff', num(255)); + + // Binary + exprTest('0b01111', num(15)); + exprTest('0B01111', num(15)); + + // Octal + exprTest('012345', num(5349)); + + // Looks like octal, but is not (has 8 or 9 as digit) + exprTest('012345678', num(12345678)); + + exprTest('-47', negative(num(47)), num(-47)); + exprTest('--47', negative(negative(num(47))), num(47)); + exprTest('+47', positive(num(47)), num(47)); + }); + + suite('Floats', () => { + exprTest('1.2', float(1.2)); + exprTest('0.583', float(0.583)); + }); + + suite('Literal strings', () => { + exprTest("''", str('')); + exprTest("''''", str("'")); + exprTest("'one two three'", str('one two three')); + exprTest("'one ''two'' three'", str("one 'two' three")); + }); + + suite('Option', () => { + exprTest('&wrapscan', { + type: 'option', + scope: undefined, + name: 'wrapscan', + }); + exprTest('&g:wrapscan', { + type: 'option', + scope: 'g', + name: 'wrapscan', + }); + exprTest('&l:wrapscan', { + type: 'option', + scope: 'l', + name: 'wrapscan', + }); + }); + + suite('List', () => { + exprTest('[1,2,3]', list([num(1), num(2), num(3)])); + exprTest('[1,2,3,]', list([num(1), num(2), num(3)])); + exprTest('[ 1 , 2 , 3 ]', list([num(1), num(2), num(3)])); + exprTest('[-1,7*8,3]', list([negative(num(1)), multiply(num(7), num(8)), num(3)])); + exprTest('[[1,2],[3,4]]', list([list([num(1), num(2)]), list([num(3), num(4)])])); + }); + + suite('Index', () => { + exprTest("'xyz'[0]", { + type: 'index', + expression: str('xyz'), + index: num(0), + }); + + exprTest("'xyz'[0][1]", { + type: 'index', + expression: { + type: 'index', + expression: str('xyz'), + index: num(0), + }, + index: num(1), + }); + }); + + suite('Slice', () => { + suite('String', () => { + exprTest( + "'abcde'[2:3]", + { + type: 'slice', + expression: str('abcde'), + start: num(2), + end: num(3), + }, + str('cd') + ); + + exprTest( + "'abcde'[2:]", + { + type: 'slice', + expression: str('abcde'), + start: num(2), + end: undefined, + }, + str('cde') + ); + + exprTest( + "'abcde'[:3]", + { + type: 'slice', + expression: str('abcde'), + start: undefined, + end: num(3), + }, + str('abcd') + ); + + exprTest( + "'abcde'[-4:-2]", + { + type: 'slice', + expression: str('abcde'), + start: negative(num(4)), + end: negative(num(2)), + }, + str('bcd') + ); + + exprTest( + "'abcde'[-2:-4]", + { + type: 'slice', + expression: str('abcde'), + start: negative(num(2)), + end: negative(num(4)), + }, + str('') + ); + + exprTest( + "'abcde'[:]", + { + type: 'slice', + expression: str('abcde'), + start: undefined, + end: undefined, + }, + str('abcde') + ); + }); + + suite('List', () => { + exprTest( + '[1,2,3,4,5][2:3]', + { + type: 'slice', + expression: list([num(1), num(2), num(3), num(4), num(5)]), + start: num(2), + end: num(3), + }, + { + type: 'list', + items: [num(3), num(4)], + } + ); + + exprTest( + '[1,2,3,4,5][2:]', + { + type: 'slice', + expression: list([num(1), num(2), num(3), num(4), num(5)]), + start: num(2), + end: undefined, + }, + { + type: 'list', + items: [num(3), num(4), num(5)], + } + ); + + exprTest( + '[1,2,3,4,5][:3]', + { + type: 'slice', + expression: list([num(1), num(2), num(3), num(4), num(5)]), + start: undefined, + end: num(3), + }, + { + type: 'list', + items: [num(1), num(2), num(3), num(4)], + } + ); + + exprTest( + '[1,2,3,4,5][-4:-2]', + { + type: 'slice', + expression: list([num(1), num(2), num(3), num(4), num(5)]), + start: negative(num(4)), + end: negative(num(2)), + }, + { + type: 'list', + items: [num(2), num(3), num(4)], + } + ); + + exprTest( + '[1,2,3,4,5][-2:-4]', + { + type: 'slice', + expression: list([num(1), num(2), num(3), num(4), num(5)]), + start: negative(num(2)), + end: negative(num(4)), + }, + { + type: 'list', + items: [], + } + ); + + exprTest( + '[1,2,3,4,5][:]', + { + type: 'slice', + expression: list([num(1), num(2), num(3), num(4), num(5)]), + start: undefined, + end: undefined, + }, + { + type: 'list', + items: [num(1), num(2), num(3), num(4), num(5)], + } + ); + }); + }); + + suite('Entry', () => { + exprTest('dict.one', { + type: 'entry', + expression: variable('dict'), + entryName: 'one', + }); + + exprTest('dict.1', { + type: 'entry', + expression: variable('dict'), + entryName: '1', + }); + + exprTest('dict.1two', { + type: 'entry', + expression: variable('dict'), + entryName: '1two', + }); + }); + + suite('Arithmetic', () => { + exprTest('5*6', multiply(num(5), num(6)), num(30)); + exprTest('5*-6', multiply(num(5), negative(num(6))), num(-30)); + exprTest('12*34*56', multiply(multiply(num(12), num(34)), num(56)), num(22848)); + }); + + suite('Precedence', () => { + exprTest('23+3*9', add(num(23), multiply(num(3), num(9))), num(50)); + exprTest('(23+3)*9', multiply(add(num(23), num(3)), num(9)), num(234)); + }); + + suite('Function calls', () => { + exprTest('getcmdpos()', funcCall('getcmdpos', [])); + exprTest('sqrt(9)', funcCall('sqrt', [num(9)])); + exprTest('fmod(21,2)', funcCall('fmod', [num(21), num(2)])); + exprTest('fmod(2*10,2)', funcCall('fmod', [multiply(num(2), num(10)), num(2)])); + exprTest('add([1,2,3],4)', funcCall('add', [list([num(1), num(2), num(3)]), num(4)])); + }); + + suite('Lambda', () => { + exprTest('{x->x}', lambda(['x'], variable('x'))); + exprTest('{x,y->x+y}', lambda(['x', 'y'], add(variable('x'), variable('y')))); + }); +});