From 841efaea2ce8866052a163d863dbf0b45ab09a5d Mon Sep 17 00:00:00 2001 From: Jason Fields Date: Tue, 26 Oct 2021 18:11:29 -0400 Subject: [PATCH] [WIP] Basic support for expressions, `:let`, and `:echo` There remains 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 | 37 + src/cmd_line/commands/let.ts | 139 ++++ src/error.ts | 68 ++ src/vimscript/exCommandParser.ts | 10 +- src/vimscript/expression/build.ts | 139 ++++ src/vimscript/expression/displayValue.ts | 37 + src/vimscript/expression/evaluate.ts | 860 +++++++++++++++++++++++ src/vimscript/expression/parser.ts | 390 ++++++++++ src/vimscript/expression/types.ts | 165 +++++ src/vimscript/parserUtils.ts | 4 +- test/vimscript/exCommandParse.test.ts | 25 + test/vimscript/expression.test.ts | 592 ++++++++++++++++ 13 files changed, 2467 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/displayValue.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 bdeb8aa9af71..da11db839240 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..50dbf74f5df1 --- /dev/null +++ b/src/cmd_line/commands/echo.ts @@ -0,0 +1,37 @@ +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 } from '../../vimscript/expression/types'; +import { displayValue } from '../../vimscript/expression/displayValue'; + +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..710629b2e4d2 --- /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 '../../vimscript/expression/displayValue'; + +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..8d7d291352d0 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,11 @@ export enum ErrorCode { NoPreviousCommand = 34, NoPreviousRegularExpression = 35, NoWriteSinceLastChange = 37, + MissingQuote = 114, + UnknownFunction_call = 117, + TooManyArgs = 118, + NotEnoughArgs = 119, + UndefinedVariable = 121, ErrorWritingToFile = 208, FileNoLongerAvailable = 211, RecursiveMapping = 223, @@ -34,11 +40,40 @@ export enum ErrorCode { AtStartOfChangeList = 662, AtEndOfChangeList = 663, ChangeListIsEmpty = 664, + ListIndexOutOfRange = 684, + ArgumentOfSortMustBeAList = 686, + CanOnlyCompareListWithList = 691, + InvalidOperationForList = 692, + CannotIndexAFuncref = 695, + UnknownFunction_funcref = 700, + UsingAFuncrefAsANumber = 703, + FuncrefVariableNameMustStartWithACapital = 704, + DictionaryRequired = 715, + KeyNotPresentInDictionary = 716, + CannotUseSliceWithADictionary = 719, + DuplicateKeyInDictionary = 721, + StrideIsZero = 726, + StartPastEnd = 727, + UsingADictionaryAsANumber = 728, + UsingListAsAString = 730, + UsingFuncrefAsAString = 729, + UsingDictionaryAsAString = 731, + CanOnlyCompareDictionaryWithDictionary = 735, + InvalidOperationForDictionary = 736, + UsingAListAsANumber = 745, NoPreviouslyUsedRegister = 748, + CannotUseModuloWithFloat = 804, + UsingAFloatAsANumber = 805, + UsingFloatAsAString = 806, + NumberOrFloatRequired = 808, + ArgumentOfMapMustBeAListDictionaryOrBlob = 896, + ExpectedADict = 922, + SecondArgumentOfFunction = 923, } export const ErrorMessage: IErrorMessage = { 14: 'Invalid address', + 15: 'Invalid expression', 16: 'Invalid range', 20: 'Mark not set', 23: 'No alternate file', @@ -48,6 +83,11 @@ export const ErrorMessage: IErrorMessage = { 34: 'No previous command', 35: 'No previous regular expression', 37: 'No write since last change (add ! to override)', + 114: 'Missing quote', + 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 +109,35 @@ export const ErrorMessage: IErrorMessage = { 662: 'At start of changelist', 663: 'At end of changelist', 664: 'changelist is empty', + 684: 'list index out of range', + 686: 'Argument of sort() must be a List', + 691: 'Can only compare List with List', + 692: 'Invalid operation for List', + 695: 'Cannot index a Funcref', + 700: 'Unknown function', + 703: 'Using a Funcref as a Number', + 704: 'Funcref variable name must start with a capital', + 715: 'Dictionary required', + 716: 'Key not present in Dictionary', + 719: 'Cannot use [:] with a Dictionary', + 721: 'Duplicate key in Dictionary', + 726: 'Stride is zero', + 727: 'Start past end', + 728: 'Using a Dictionary as a Number', + 729: 'using Funcref as a String', + 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', + 804: "Cannot use '%' with Float", + 805: 'Using a Float as a Number', + 806: 'Using Float as a String', + 808: 'Number or Float required', + 896: 'Argument of map() must be a List, Dictionary or Blob', + 922: 'expected a dict', + 923: 'Second argument of function() must be a list or a dict', }; 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..9cbf4cb418d4 --- /dev/null +++ b/src/vimscript/expression/build.ts @@ -0,0 +1,139 @@ +import { + NumberValue, + Expression, + ListExpression, + UnaryExpression, + BinaryOp, + BinaryExpression, + FunctionCallExpression, + StringValue, + LambdaExpression, + VariableExpression, + Namespace, + FloatValue, + FuncRefValue, + ListValue, + DictionaryValue, + Value, +} from './types'; + +export function int(value: number): NumberValue { + return { + type: 'number', + value, + }; +} + +export function float(value: number): FloatValue { + return { + type: 'float', + value, + }; +} + +export function bool(value: boolean): NumberValue { + return int(value ? 1 : 0); +} + +export function str(value: string): StringValue { + return { + type: 'string', + value, + }; +} + +export function list(items: Value[]): ListValue { + return { + type: 'list', + items, + }; +} + +export function funcref(name: string, arglist?: ListValue, dict?: DictionaryValue): FuncRefValue { + return { + type: 'funcref', + name, + arglist, + dict, + }; +} + +export function listExpr(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/displayValue.ts b/src/vimscript/expression/displayValue.ts new file mode 100644 index 000000000000..78cbdb0f7c50 --- /dev/null +++ b/src/vimscript/expression/displayValue.ts @@ -0,0 +1,37 @@ +import { Value } from './types'; + +export function displayValue(value: Value, topLevel = true): string { + switch (value.type) { + case 'number': + return value.value.toString(); + case 'float': + // TODO: this is incorrect for float with exponent + if (Number.isInteger(value.value)) { + return `${value.value}.0`; + } else { + 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(', ')}}`; + case 'funcref': + if (!value.arglist?.items.length) { + if (value.dict) { + return `function('${value.name}', ${displayValue(value.dict)})`; + } + return value.name; + } else { + if (value.dict) { + return `function('${value.name}', ${displayValue(value.arglist)}, ${displayValue( + value.dict + )})`; + } + return `function('${value.name}', ${displayValue(value.arglist)})`; + } + } +} diff --git a/src/vimscript/expression/evaluate.ts b/src/vimscript/expression/evaluate.ts new file mode 100644 index 000000000000..dcea290fffab --- /dev/null +++ b/src/vimscript/expression/evaluate.ts @@ -0,0 +1,860 @@ +import { all } from 'parsimmon'; +import { displayValue } from './displayValue'; +import { configuration } from '../../configuration/configuration'; +import { ErrorCode, VimError } from '../../error'; +import { globalState } from '../../state/globalState'; +import { bool, float, funcref, listExpr, int, str, variable, list } from './build'; +import { expressionParser, numberParser } from './parser'; +import { + BinaryOp, + ComparisonOp, + DictionaryValue, + Expression, + FloatValue, + ListValue, + NumberValue, + StringValue, + UnaryOp, + Value, + VariableExpression, +} from './types'; + +function toInt(value: Value): number { + switch (value.type) { + case 'number': + return value.value; + case 'float': + throw VimError.fromCode(ErrorCode.UsingAFloatAsANumber); + case 'string': + const parsed = numberParser.skip(all).parse(value.value); + if (parsed.status === false) { + return 0; + } + return parsed.value.value; + case 'list': + throw VimError.fromCode(ErrorCode.UsingAListAsANumber); + case 'dict_val': + throw VimError.fromCode(ErrorCode.UsingADictionaryAsANumber); + case 'funcref': + throw VimError.fromCode(ErrorCode.UsingAFuncrefAsANumber); + } +} + +function toFloat(value: Value): number { + switch (value.type) { + case 'number': + return value.value; + case 'float': + return value.value; + case 'string': + case 'list': + case 'dict_val': + case 'funcref': + throw VimError.fromCode(ErrorCode.NumberOrFloatRequired); + } +} + +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); + case 'funcref': + throw VimError.fromCode(ErrorCode.UsingFuncrefAsAString); + } +} + +function toDict(value: Value): DictionaryValue { + switch (value.type) { + case 'number': + case 'float': + case 'string': + case 'list': + case 'funcref': + throw VimError.fromCode(ErrorCode.DictionaryRequired); + case 'dict_val': + return value; + } +} + +export class Variable { + public value: Value; + public locked: boolean = false; + + constructor(value: Value) { + this.value = value; + } +} + +type VariableStore = Map; + +export class EvaluationContext { + private static globalVariables: VariableStore = new Map(); + + private localScopes: VariableStore[] = []; + + /** + * 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': + case 'funcref': + return expression; + case 'list': + return listExpr(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 (max === undefined) { + max = min; + } + if (expression.args.length < min) { + throw VimError.fromCode(ErrorCode.NotEnoughArgs, expression.func); + } + if (expression.args.length > max) { + throw VimError.fromCode(ErrorCode.TooManyArgs, expression.func); + } + const args: Array = expression.args.map((arg) => this.evaluate(arg)); + while (args.length < max) { + args.push(undefined); + } + return args; + }; + switch (expression.func) { + case 'abs': { + const [x] = getArgs(1); + return float(Math.abs(toFloat(x!))); + } + case 'acos': { + const [x] = getArgs(1); + return float(Math.acos(toFloat(x!))); + } + case 'asin': { + const [x] = getArgs(1); + return float(Math.asin(toFloat(x!))); + } + case 'atan2': { + const [x, y] = getArgs(2); + return float(Math.atan2(toFloat(x!), toFloat(y!))); + } + case 'and': { + const [x, y] = getArgs(2); + // tslint:disable-next-line: no-bitwise + return int(toInt(x!) & toInt(y!)); + } + case 'ceil': { + const [x] = getArgs(1); + return float(Math.ceil(toFloat(x!))); + } + case 'copy': { + // TODO: real copy once references are implemented + const [x] = getArgs(1); + return x!; + } + case 'cos': { + const [x] = getArgs(1); + return float(Math.cos(toFloat(x!))); + } + case 'cosh': { + const [x] = getArgs(1); + return float(Math.cosh(toFloat(x!))); + } + case 'deepcopy': { + // TODO: real deep copy once references are implemented + const [x] = getArgs(1); + return x!; + } + case 'empty': { + let [x] = getArgs(1); + x = x!; + switch (x.type) { + case 'number': + case 'float': + return bool(x.value === 0); + case 'string': + return bool(x.value.length === 0); + case 'list': + return bool(x.items.length === 0); + case 'dict_val': + return bool(x.items.size === 0); + // TODO: + // case 'blob': + default: + return bool(false); + } + } + case 'eval': { + const [expr] = getArgs(1); + return this.evaluate(expressionParser.tryParse(toString(expr!))); + } + case 'exp': { + const [x] = getArgs(1); + return float(Math.exp(toFloat(x!))); + } + case 'function': { + const [name, arglist, dict] = getArgs(1, 3); + if (arglist) { + if (arglist.type === 'list') { + if (dict && dict.type !== 'dict_val') { + throw VimError.fromCode(ErrorCode.ExpectedADict); + } + return funcref(toString(name!), arglist, dict); + } else if (arglist.type === 'dict_val') { + if (dict) { + // function('abs', {}, {}) + throw VimError.fromCode(ErrorCode.SecondArgumentOfFunction); + } + return funcref(toString(name!), undefined, arglist); + } else { + throw VimError.fromCode(ErrorCode.SecondArgumentOfFunction); + } + } + if (dict && dict.type !== 'dict_val') { + throw VimError.fromCode(ErrorCode.ExpectedADict); + } + // TODO: + // if (toString(name!) is invalid function) { + // throw VimError.fromCode(ErrorCode.UnknownFunction_funcref, toString(name!)); + // } + return { + type: 'funcref', + name: toString(name!), + arglist, + dict, + }; + } + case 'floor': { + const [x] = getArgs(1); + return float(Math.floor(toFloat(x!))); + } + case 'fmod': { + const [x, y] = getArgs(2); + return float(toFloat(x!) % toFloat(y!)); + } + case 'isinf': { + const [x] = getArgs(1); + const _x = toFloat(x!); + return bool(_x === Infinity || _x === -Infinity); + } + case 'isnan': { + const [x] = getArgs(1); + return bool(isNaN(toFloat(x!))); + } + case 'items': { + const [d] = getArgs(1); + return list([...toDict(d!).items.entries()].map(([k, v]) => list([str(k), v]))); + } + case 'keys': { + const [d] = getArgs(1); + return list([...toDict(d!).items.keys()].map(str)); + } + case 'log': { + const [x] = getArgs(1); + return float(Math.log(toFloat(x!))); + } + case 'log10': { + const [x] = getArgs(1); + return float(Math.log10(toFloat(x!))); + } + case 'map': { + const [seq, fn] = getArgs(2); + switch (seq?.type) { + case 'list': + return list( + seq.items.map((val, idx) => { + switch (fn?.type) { + case 'funcref': + return this.evaluate({ + type: 'funcrefCall', + expression: fn, + args: [int(idx), val], + }); + default: + this.localScopes.push( + new Map([ + ['v:key', new Variable(int(idx))], + ['v:val', new Variable(val)], + ]) + ); + const retval = this.evaluate(expressionParser.tryParse(toString(fn!))); + this.localScopes.pop(); + return retval; + } + }) + ); + case 'dict_val': + // TODO + // case 'blob': + // TODO + default: + throw VimError.fromCode(ErrorCode.ArgumentOfMapMustBeAListDictionaryOrBlob); + } + } + case 'or': { + const [x, y] = getArgs(2); + // tslint:disable-next-line: no-bitwise + return int(toInt(x!) | toInt(y!)); + } + case 'pow': { + const [x, y] = getArgs(2); + return float(Math.pow(toFloat(x!), toFloat(y!))); + } + case 'range': { + const [val, max, stride] = getArgs(1, 3); + const start = max !== undefined ? toInt(val!) : 0; + const end = max !== undefined ? toInt(max) : toInt(val!) - 1; + const step = stride !== undefined ? toInt(stride) : 1; + if (step === 0) { + throw VimError.fromCode(ErrorCode.StrideIsZero); + } + if (step > 0 !== start < end && Math.abs(start - end) > 1) { + throw VimError.fromCode(ErrorCode.StartPastEnd); + } + const items: Value[] = []; + for (let i = start; step > 0 ? i <= end : i >= end; i += step) { + items.push(int(i)); + } + return list(items); + } + case 'repeat': { + const [val, count] = getArgs(2); + if (val?.type === 'list') { + const items: Value[] = new Array(toInt(count!)).fill(val.items).flat(); + return list(items); + } else { + return str(toString(val!).repeat(toInt(count!))); + } + } + case 'reverse': { + // TODO: modify original + const [l] = getArgs(1); + if (l?.type === 'list') { + return list(l.items.reverse()); + } + // TODO: handle Blob + return int(0); + } + case 'round': { + const [x] = getArgs(1); + return float(Math.round(toFloat(x!))); + } + case 'sin': { + const [x] = getArgs(1); + return float(Math.sin(toFloat(x!))); + } + case 'sinh': { + const [x] = getArgs(1); + return float(Math.sinh(toFloat(x!))); + } + case 'sort': { + // TODO: use dict + const [l, func, dict] = getArgs(1, 3); + if (l?.type !== 'list') { + throw VimError.fromCode(ErrorCode.ArgumentOfSortMustBeAList); + } + let compare: (x: Value, y: Value) => number; + if (func !== undefined) { + if (func.type === 'string') { + if (func.value === '1' || func.value === 'i') { + // Ignore case + compare = (x, y) => + displayValue(x).toLowerCase().localeCompare(displayValue(y).toLowerCase()); + } else { + // TODO: handle other special cases ('l', 'n', 'N', 'f') + throw Error('compare() with function name is not yet implemented'); + } + } else if (func.type === 'funcref') { + // TODO + throw Error('compare() with Funcref is not yet implemented'); + } else { + throw VimError.fromCode(ErrorCode.InvalidArgument); + } + } else { + compare = (x, y) => displayValue(x).localeCompare(displayValue(y)); + } + // TODO: Numbers after Strings, Lists after Numbers + return list(l.items.sort(compare)); + } + case 'sqrt': { + const [x] = getArgs(1); + return float(Math.sqrt(toFloat(x!))); + } + case 'string': { + const [x] = getArgs(1); + return str(displayValue(x!)); + } + case 'tan': { + const [x] = getArgs(1); + return float(Math.tan(toFloat(x!))); + } + case 'tanh': { + const [x] = getArgs(1); + return float(Math.tanh(toFloat(x!))); + } + case 'tolower': { + const [s] = getArgs(1); + return str(toString(s!).toLowerCase()); + } + case 'toupper': { + const [s] = getArgs(1); + return str(toString(s!).toUpperCase()); + } + case 'trunc': { + const [x] = getArgs(1); + return float(Math.trunc(toFloat(x!))); + } + case 'type': { + let [x] = getArgs(1); + x = x!; + switch (x.type) { + case 'number': + return int(0); + case 'string': + return int(1); + case 'funcref': + return int(2); + case 'list': + return int(3); + case 'dict_val': + return int(4); + case 'float': + return int(5); + // case 'bool': + // return num(6); + // case 'null': + // return num(7); + // case 'blob': + // return num(8); + default: + const guard: never = x; + throw new Error('type() got unexpected type'); + } + } + case 'values': { + const [d] = getArgs(1); + return list([...toDict(d!).items.values()]); + } + case 'xor': { + const [x, y] = getArgs(2); + // tslint:disable-next-line: no-bitwise + return int(toInt(x!) ^ toInt(y!)); + } + // TODO: many, many more + default: { + throw VimError.fromCode(ErrorCode.UnknownFunction_call, 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) : int(0), + expression.end ? this.evaluate(expression.end) : int(-1) + ); + case 'entry': + return str(''); // TODO + case 'funcrefCall': { + const fref = this.evaluate(expression.expression); + if (fref.type !== 'funcref') { + // TODO + throw new Error(`Expected funcref, got ${fref.type}`); + } + // TODO: use `fref.dict` + return this.evaluate({ + type: 'function_call', + func: fref.name, + args: (fref.arglist?.items ?? []).concat(expression.args.map((e) => this.evaluate(e))), + }); + } + 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( + toInt(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(varExpr: VariableExpression, value: Value): void { + if (value.type === 'funcref' && varExpr.name[0] === varExpr.name[0].toLowerCase()) { + throw VimError.fromCode(ErrorCode.FuncrefVariableNameMustStartWithACapital, varExpr.name); + } + + let store: VariableStore | undefined; + if (this.localScopes && varExpr.namespace === undefined) { + store = this.localScopes[this.localScopes.length - 1]; + } else if (varExpr.namespace === 'g' || varExpr.namespace === undefined) { + store = EvaluationContext.globalVariables; + } else { + // TODO + } + + if (store) { + const _var = store.get(varExpr.name); + if (_var) { + _var.value = value; + } else { + store.set(varExpr.name, new Variable(value)); + } + } + } + + private evaluateVariable(varExpr: VariableExpression): Value { + if (varExpr.namespace === undefined) { + for (let i = this.localScopes.length - 1; i >= 0; i--) { + const _var = this.localScopes[i].get(varExpr.name); + if (_var !== undefined) { + return _var.value; + } + } + } + + if (varExpr.namespace === 'g' || varExpr.namespace === undefined) { + const _var = EvaluationContext.globalVariables.get(varExpr.name); + if (_var === undefined) { + throw VimError.fromCode( + ErrorCode.UndefinedVariable, + varExpr.namespace ? `${varExpr.namespace}:${varExpr.name}` : varExpr.name + ); + } else { + return _var.value; + } + } else if (varExpr.namespace === 'v') { + if (varExpr.name === 'true') { + return bool(true); + } else if (varExpr.name === 'false') { + return bool(false); + } else if (varExpr.name === 'hlsearch') { + return bool(globalState.hl); + } else if (varExpr.name === 't_number') { + return int(0); + } else if (varExpr.name === 't_string') { + return int(1); + } else if (varExpr.name === 't_func') { + return int(2); + } else if (varExpr.name === 't_list') { + return int(3); + } else if (varExpr.name === 't_dict') { + return int(4); + } else if (varExpr.name === 't_float') { + return int(5); + } else if (varExpr.name === 't_bool') { + return int(6); + } else if (varExpr.name === 'numbermax') { + return int(Number.MAX_VALUE); + } else if (varExpr.name === 'numbermin') { + return int(Number.MIN_VALUE); + } + + // HACK: for things like v:key & v:val + return this.evaluate({ + type: 'variable', + namespace: undefined, + name: `v:${varExpr.name}`, + }); + } + + throw VimError.fromCode( + ErrorCode.UndefinedVariable, + varExpr.namespace ? `${varExpr.namespace}:${varExpr.name}` : varExpr.name + ); + } + + private evaluateIndex(sequence: Value, index: Value): Value { + switch (sequence.type) { + case 'string': + case 'number': + case 'float': { + const idx = toInt(index); + return str(idx >= 0 ? toString(sequence)[idx] ?? '' : ''); + } + case 'list': { + let idx = toInt(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[toInt(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; + } + case 'funcref': { + throw VimError.fromCode(ErrorCode.CannotIndexAFuncref); + } + } + } + + private evaluateSlice(sequence: Value, start: Value, end: Value): Value { + let _start = toInt(start); + let _end = toInt(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 list([]); + } + return list(sequence.items.slice(_start, _end + 1)); + } + case 'dict_val': { + throw VimError.fromCode(ErrorCode.CannotUseSliceWithADictionary); + } + case 'funcref': { + throw VimError.fromCode(ErrorCode.CannotIndexAFuncref); + } + } + } + + private evaluateUnary(operator: UnaryOp, operand: Expression): NumberValue | FloatValue { + const value = this.evaluate(operand); + + // TODO: this duplication is lazy and dumb + if (value.type === 'float') { + const _value = toFloat(value); + switch (operator) { + case '+': + return float(_value); + case '-': + return float(-_value); + case '!': + return float(_value === 0 ? 1 : 0); + } + } else { + const _value = toInt(value); + switch (operator) { + case '+': + return int(_value); + case '-': + return int(-_value); + case '!': + return int(_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 listExpr(lhs.items.concat(rhs.items)) as ListValue; + } else { + return int(toInt(lhs) + toInt(rhs)); + } + case '-': + return int(toInt(lhs) - toInt(rhs)); + case '.': + case '..': + return str(toString(lhs) + toString(rhs)); + case '*': + return int(toInt(lhs) * toInt(rhs)); + case '/': + return int(Math.trunc(toInt(lhs) / toInt(rhs))); + case '%': { + if (lhs.type === 'float' || rhs.type === 'float') { + throw VimError.fromCode(ErrorCode.CannotUseModuloWithFloat); + } + const [_lhs, _rhs] = [toInt(lhs), toInt(rhs)]; + if (_rhs === 0) { + return int(0); + } + + return int(_lhs % _rhs); + } + case '&&': + return bool(toInt(lhs) !== 0 && toInt(rhs) !== 0); + case '||': + return bool(toInt(lhs) !== 0 || toInt(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] = [int(toInt(lhs)), int(toInt(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..85f2e8fb3baa --- /dev/null +++ b/src/vimscript/expression/parser.ts @@ -0,0 +1,390 @@ +import { + Parser, + regexp, + seq, + alt, + string, + lazy, + any, + optWhitespace, + takeWhile, + noneOf, + seqObj, +} from 'parsimmon'; +import { ErrorCode, VimError } from '../../error'; +import { binary, float, lambda, listExpr, int, str } from './build'; +import { + BinaryOp, + DictionaryExpression, + EntryExpression, + EnvVariableExpression, + Expression, + FloatValue, + FuncrefCallExpression, + 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 int(Number.parseInt(x, 2)); + }) +); + +const hexadecimalNumberParser: Parser = regexp(/0[x]/i) + .then(regexp(/[0-9a-f]+/i)) + .map((x) => { + return int(Number.parseInt(x, 16)); + }); + +const decimalOrOctalNumberParser: Parser = regexp(/\d+/).map((x) => { + const base = x.startsWith('0') && /^[0-7]+$/.test(x) ? 8 : 10; + return int(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 = seq( + alt(string('+'), string('-')).fallback(undefined), + alt(binaryNumberParser, hexadecimalNumberParser, decimalOrOctalNumberParser) +).map(([sign, num]) => { + if (sign === '-') { + num.value = -num.value; + } + return num; +}); + +const stringParser: Parser = alt( + string('\\') + .then(any.fallback(undefined)) + .map((escaped) => { + // TODO: handle other special chars (:help expr-quote) + if (escaped === undefined) { + throw VimError.fromCode(ErrorCode.MissingQuote); // TODO: parameter + } else if (escaped === '\\') { + return '\\'; + } else if (escaped === '"') { + return '"'; + } else if (escaped === 'n') { + return '\n'; + } else if (escaped === 't') { + return '\t'; + } else { + return `\\${escaped}`; + } + }), + noneOf('"') +) + .many() + .wrap(string('"'), string('"')) + .desc('a string') + .map((segments) => { + return { type: 'string', value: segments.join('') }; + }); + +const literalStringParser: Parser = regexp(/[^']*/) + .sepBy(string("''")) + .wrap(string("'"), string("'")) + .desc('a literal 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) => listExpr(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 functionArgsParser: Parser = lazy(() => + expressionParser + .sepBy(string(',').trim(optWhitespace)) + .trim(optWhitespace) + .wrap(string('('), string(')')) +); + +const functionCallParser: Parser = seq( + regexp(/[a-z]+/).skip(optWhitespace), + functionArgsParser +) + .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: Function call with funcref +// TODO: Method call +// TODO: Variable/function with curly braces +const expr9Parser: Parser = alt( + floatParser, + numberParser, + stringParser, + 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 funcrefCallParser: Parser<(expr: Expression) => FuncrefCallExpression> = functionArgsParser + .desc('a funcref call') + .map((args) => { + return (expression: Expression) => { + return { + type: 'funcrefCall', + expression, + args, + }; + }; + }); + +const expr8Parser: Parser = seq( + expr9Parser, + alt<(expr: Expression) => Expression>( + indexParser, + sliceParser, + entryParser, + funcrefCallParser + ).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('-'), 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..8b7b7bbd50d0 --- /dev/null +++ b/src/vimscript/expression/types.ts @@ -0,0 +1,165 @@ +// -------------------- 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 FuncRefValue = { + type: 'funcref'; + name: string; + arglist?: ListValue; + dict?: DictionaryValue; +}; + +// TODO: export type BlobValue + +export type Value = + | NumberValue + | FloatValue + | StringValue + | ListValue + | DictionaryValue + | FuncRefValue; + +// -------------------- 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 FuncrefCallExpression = { + type: 'funcrefCall'; + expression: Expression; + args: Expression[]; +}; + +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 + | FuncrefCallExpression + | 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..63f9f7d6737c 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 { int, 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: int(5) }) + ); + exParseTest( + ':let foo += 5', + new LetCommand({ operation: '+=', variable: variable('foo'), expression: int(5) }) + ); + exParseTest( + ':let foo -= 5', + new LetCommand({ operation: '-=', variable: variable('foo'), expression: int(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..f7fe54616a79 --- /dev/null +++ b/test/vimscript/expression.test.ts @@ -0,0 +1,592 @@ +import * as assert from 'assert'; +import { + int, + negative, + positive, + listExpr, + funcCall, + multiply, + add, + str, + lambda, + variable, + float, + bool, + list, +} 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'; +import { displayValue } from '../../src/vimscript/expression/displayValue'; +import { ErrorCode, VimError } from '../../src/error'; + +function exprTest( + input: string, + asserts: { expr?: Expression } & ({ value?: Value; display?: string } | { error: ErrorCode }) +) { + test(input, () => { + try { + const expression = expressionParser.tryParse(input); + if (asserts.expr) { + assert.deepStrictEqual(expression, asserts.expr); + } + if ('error' in asserts) { + const ctx = new EvaluationContext(); + ctx.evaluate(expression); + } else { + if (asserts.value !== undefined) { + const ctx = new EvaluationContext(); + assert.deepStrictEqual(ctx.evaluate(expression), asserts.value); + } + if (asserts.display !== undefined) { + const ctx = new EvaluationContext(); + assert.deepStrictEqual(displayValue(ctx.evaluate(expression)), asserts.display); + } + } + } catch (e: unknown) { + if (e instanceof VimError) { + if ('error' in asserts) { + assert.deepStrictEqual(e.code, asserts.error); + } else { + throw e; + } + } else { + throw e; + } + } + }); +} + +suite.only('Parse & evaluate expression', () => { + suite('Numbers', () => { + exprTest('0', { expr: int(0) }); + exprTest('123', { expr: int(123) }); + + // Hexadecimal + exprTest('0xff', { expr: int(255) }); + exprTest('0Xff', { expr: int(255) }); + + // Binary + exprTest('0b01111', { expr: int(15) }); + exprTest('0B01111', { expr: int(15) }); + + // Octal + exprTest('012345', { expr: int(5349) }); + + // Looks like octal, but is not (has 8 or 9 as digit) + exprTest('012345678', { expr: int(12345678) }); + + exprTest('-47', { expr: negative(int(47)), value: int(-47) }); + exprTest('--47', { expr: negative(negative(int(47))), value: int(47) }); + exprTest('+47', { expr: positive(int(47)), value: int(47) }); + }); + + suite('Floats', () => { + exprTest('1.2', { expr: float(1.2) }); + exprTest('0.583', { expr: float(0.583) }); + exprTest('-5.3', { expr: negative(float(5.3)), value: float(-5.3) }); + }); + + suite('Strings', () => { + exprTest('""', { expr: str('') }); + exprTest('"\\""', { expr: str('"') }); + exprTest('"one\\ntwo\\tthree"', { expr: str('one\ntwo\tthree') }); + }); + + suite('Literal strings', () => { + exprTest("''", { expr: str('') }); + exprTest("''''", { expr: str("'") }); + exprTest("'one two three'", { expr: str('one two three') }); + exprTest("'one ''two'' three'", { expr: str("one 'two' three") }); + exprTest("'one\\ntwo\\tthree'", { expr: str('one\\ntwo\\tthree') }); + }); + + suite('Option', () => { + exprTest('&wrapscan', { + expr: { + type: 'option', + scope: undefined, + name: 'wrapscan', + }, + }); + exprTest('&g:wrapscan', { + expr: { + type: 'option', + scope: 'g', + name: 'wrapscan', + }, + }); + exprTest('&l:wrapscan', { + expr: { + type: 'option', + scope: 'l', + name: 'wrapscan', + }, + }); + }); + + suite('List', () => { + exprTest('[1,2,3]', { expr: listExpr([int(1), int(2), int(3)]) }); + exprTest('[1,2,3,]', { expr: listExpr([int(1), int(2), int(3)]) }); + exprTest('[ 1 , 2 , 3 ]', { expr: listExpr([int(1), int(2), int(3)]) }); + exprTest('[-1,7*8,3]', { + expr: listExpr([negative(int(1)), multiply(int(7), int(8)), int(3)]), + }); + exprTest('[[1,2],[3,4]]', { + expr: listExpr([listExpr([int(1), int(2)]), listExpr([int(3), int(4)])]), + }); + }); + + suite('Index', () => { + exprTest("'xyz'[0]", { + expr: { + type: 'index', + expression: str('xyz'), + index: int(0), + }, + }); + + exprTest("'xyz'[0][1]", { + expr: { + type: 'index', + expression: { + type: 'index', + expression: str('xyz'), + index: int(0), + }, + index: int(1), + }, + }); + }); + + suite('Slice', () => { + suite('String', () => { + exprTest("'abcde'[2:3]", { + expr: { + type: 'slice', + expression: str('abcde'), + start: int(2), + end: int(3), + }, + value: str('cd'), + }); + + exprTest("'abcde'[2:]", { + expr: { + type: 'slice', + expression: str('abcde'), + start: int(2), + end: undefined, + }, + value: str('cde'), + }); + + exprTest("'abcde'[:3]", { + expr: { + type: 'slice', + expression: str('abcde'), + start: undefined, + end: int(3), + }, + value: str('abcd'), + }); + + exprTest("'abcde'[-4:-2]", { + expr: { + type: 'slice', + expression: str('abcde'), + start: negative(int(4)), + end: negative(int(2)), + }, + value: str('bcd'), + }); + + exprTest("'abcde'[-2:-4]", { + expr: { + type: 'slice', + expression: str('abcde'), + start: negative(int(2)), + end: negative(int(4)), + }, + value: str(''), + }); + + exprTest("'abcde'[:]", { + expr: { + type: 'slice', + expression: str('abcde'), + start: undefined, + end: undefined, + }, + value: str('abcde'), + }); + }); + + suite('List', () => { + exprTest('[1,2,3,4,5][2:3]', { + expr: { + type: 'slice', + expression: listExpr([int(1), int(2), int(3), int(4), int(5)]), + start: int(2), + end: int(3), + }, + value: list([int(3), int(4)]), + }); + + exprTest('[1,2,3,4,5][2:]', { + expr: { + type: 'slice', + expression: listExpr([int(1), int(2), int(3), int(4), int(5)]), + start: int(2), + end: undefined, + }, + value: list([int(3), int(4), int(5)]), + }); + + exprTest('[1,2,3,4,5][:3]', { + expr: { + type: 'slice', + expression: listExpr([int(1), int(2), int(3), int(4), int(5)]), + start: undefined, + end: int(3), + }, + value: list([int(1), int(2), int(3), int(4)]), + }); + + exprTest('[1,2,3,4,5][-4:-2]', { + expr: { + type: 'slice', + expression: listExpr([int(1), int(2), int(3), int(4), int(5)]), + start: negative(int(4)), + end: negative(int(2)), + }, + value: list([int(2), int(3), int(4)]), + }); + + exprTest('[1,2,3,4,5][-2:-4]', { + expr: { + type: 'slice', + expression: listExpr([int(1), int(2), int(3), int(4), int(5)]), + start: negative(int(2)), + end: negative(int(4)), + }, + value: list([]), + }); + + exprTest('[1,2,3,4,5][:]', { + expr: { + type: 'slice', + expression: listExpr([int(1), int(2), int(3), int(4), int(5)]), + start: undefined, + end: undefined, + }, + value: list([int(1), int(2), int(3), int(4), int(5)]), + }); + }); + }); + + suite('Entry', () => { + exprTest('dict.one', { + expr: { + type: 'entry', + expression: variable('dict'), + entryName: 'one', + }, + }); + + exprTest('dict.1', { + expr: { + type: 'entry', + expression: variable('dict'), + entryName: '1', + }, + }); + + exprTest('dict.1two', { + expr: { + type: 'entry', + expression: variable('dict'), + entryName: '1two', + }, + }); + }); + + suite('Arithmetic', () => { + exprTest('5*6', { expr: multiply(int(5), int(6)), value: int(30) }); + exprTest('5*-6', { expr: multiply(int(5), negative(int(6))), value: int(-30) }); + exprTest('12*34*56', { + expr: multiply(multiply(int(12), int(34)), int(56)), + value: int(22848), + }); + }); + + suite('Precedence', () => { + exprTest('23+3*9', { expr: add(int(23), multiply(int(3), int(9))), value: int(50) }); + exprTest('(23+3)*9', { expr: multiply(add(int(23), int(3)), int(9)), value: int(234) }); + }); + + suite('Function calls', () => { + exprTest('getcmdpos()', { expr: funcCall('getcmdpos', []) }); + exprTest('sqrt(9)', { expr: funcCall('sqrt', [int(9)]), value: float(3.0) }); + exprTest('fmod(21,2)', { expr: funcCall('fmod', [int(21), int(2)]) }); + exprTest('fmod(2*10,2)', { expr: funcCall('fmod', [multiply(int(2), int(10)), int(2)]) }); + exprTest('add([1,2,3],4)', { + expr: funcCall('add', [listExpr([int(1), int(2), int(3)]), int(4)]), + }); + exprTest('reverse([1,2,3])', { + expr: funcCall('reverse', [listExpr([int(1), int(2), int(3)])]), + value: list([int(3), int(2), int(1)]), + }); + }); + + suite('Lambda', () => { + exprTest('{x->x}', { expr: lambda(['x'], variable('x')) }); + exprTest('{x,y->x+y}', { expr: lambda(['x', 'y'], add(variable('x'), variable('y'))) }); + }); +}); + +suite.only('Comparisons', () => { + suite('String equality', () => { + exprTest("'abc' == 'Abc'", { value: bool(false) }); // TODO: this should depend on 'ignorecase' + exprTest("'abc' ==# 'Abc'", { value: bool(false) }); + exprTest("'abc' ==? 'Abc'", { value: bool(true) }); + }); + + suite('Misc', () => { + exprTest("4 == '4'", { value: bool(true) }); + exprTest("4 is '4'", { value: bool(false) }); + exprTest('0 is []', { value: bool(false) }); + exprTest('[4] == ["4"]', { value: bool(false) }); + }); +}); + +suite.only('Conversions', () => { + suite.only('A stringified number can have only 1 sign in front of it', () => { + exprTest("+'123'", { value: int(123) }); + + exprTest("+'-123'", { value: int(-123) }); + exprTest("+'+123'", { value: int(123) }); + + exprTest("+'--123'", { value: int(0) }); + exprTest("+'++123'", { value: int(0) }); + exprTest("+'-+123'", { value: int(0) }); + exprTest("+'+-123'", { value: int(0) }); + }); +}); + +suite.only('Operators', () => { + suite('Unary', () => { + suite('!', () => { + exprTest('!0', { value: int(1) }); + exprTest('!1', { value: int(0) }); + exprTest('!123', { value: int(0) }); + + exprTest('!0.0', { value: float(1.0) }); + exprTest('!1.0', { value: float(0.0) }); + exprTest('!123.0', { value: float(0.0) }); + + exprTest("!'0'", { value: int(1) }); + exprTest("!'1'", { value: int(0) }); + exprTest("!'xyz'", { value: int(1) }); + exprTest('![]', { error: ErrorCode.UsingAListAsANumber }); + exprTest('!{}', { error: ErrorCode.UsingADictionaryAsANumber }); + }); + + suite('+', () => { + exprTest('+5', { value: int(5) }); + exprTest('+-5', { value: int(-5) }); + + exprTest('+5.0', { value: float(5) }); + exprTest('+-5.0', { value: float(-5) }); + + exprTest("+'5'", { value: int(5) }); + exprTest("+'-5'", { value: int(-5) }); + exprTest("+'xyz'", { value: int(0) }); + exprTest('+[]', { error: ErrorCode.UsingAListAsANumber }); + exprTest('+{}', { error: ErrorCode.UsingADictionaryAsANumber }); + }); + + suite('-', () => { + exprTest('-5', { value: int(-5) }); + exprTest('--5', { value: int(5) }); + + exprTest('-5.0', { value: float(-5) }); + exprTest('--5.0', { value: float(5) }); + + exprTest("-'5'", { value: int(-5) }); + exprTest("-'-5'", { value: int(5) }); + exprTest("-'xyz'", { value: int(-0) }); + exprTest('-[]', { error: ErrorCode.UsingAListAsANumber }); + exprTest('-{}', { error: ErrorCode.UsingADictionaryAsANumber }); + }); + }); + + suite('Binary', () => { + exprTest("'123' + '456'", { value: int(579) }); + exprTest("'123' . '456'", { value: str('123456') }); + exprTest("'123' .. '456'", { value: str('123456') }); + exprTest('123 . 456', { value: str('123456') }); + exprTest('123 .. 456', { value: str('123456') }); + + suite('%', () => { + exprTest('75 % 22', { value: int(9) }); + exprTest('75 % -22', { value: int(9) }); + exprTest('-75 % 22', { value: int(-9) }); + exprTest('-75 % -22', { value: int(-9) }); + + exprTest('5 % 0', { value: int(0) }); + exprTest('-5 % 0', { value: int(0) }); + + exprTest('5.2 % 2.1', { error: ErrorCode.CannotUseModuloWithFloat }); + exprTest('5.2 % 2', { error: ErrorCode.CannotUseModuloWithFloat }); + exprTest('5 % 2.1', { error: ErrorCode.CannotUseModuloWithFloat }); + }); + }); +}); + +suite.only('Builtin functions', () => { + suite('empty', () => { + exprTest('empty(0)', { value: bool(true) }); + exprTest('empty(0.0)', { value: bool(true) }); + exprTest("empty('')", { value: bool(true) }); + exprTest('empty([])', { value: bool(true) }); + exprTest('empty({})', { value: bool(true) }); + + exprTest('empty(1)', { value: bool(false) }); + exprTest('empty(1.0)', { value: bool(false) }); + exprTest("empty('xyz')", { value: bool(false) }); + exprTest('empty([0])', { value: bool(false) }); + exprTest("empty({'k': 'v'})", { value: bool(false) }); + }); + + suite('function(name, [, arglist [, dict]])', () => { + exprTest("function('abs')", { display: 'abs' }); + exprTest("function('abs', [])", { display: 'abs' }); + exprTest("function('abs', [-5])", { display: "function('abs', [-5])" }); + exprTest("function('abs', -5)", { error: ErrorCode.SecondArgumentOfFunction }); + exprTest("function('abs', '-5')", { error: ErrorCode.SecondArgumentOfFunction }); + exprTest("function('abs', [], [])", { error: ErrorCode.ExpectedADict }); + exprTest("function('abs', {}, {})", { error: ErrorCode.SecondArgumentOfFunction }); + exprTest("function('abs', [], {})", { display: "function('abs', {})" }); + exprTest("function('abs', [], #{x:5})", { display: "function('abs', {'x': 5})" }); + + // Immediately invoke the funcref + exprTest("function('abs')(-5)", { value: float(5) }); + exprTest("function('abs', [-5])()", { value: float(5) }); + exprTest("function('or', [1])(64)", { value: int(65) }); + }); + + suite('fmod(float, float)', () => { + exprTest('fmod(11, 3)', { value: float(2.0) }); + // exprTest('fmod(4.2, 1.0)', { value: float(0.2) }); + // exprTest('fmod(4.2, -1.0)', { value: float(0.2) }); + // exprTest('fmod(-4.2, 1.0)', { value: float(-0.2) }); + // exprTest('fmod(-4.2, -1.0)', { value: float(-0.2) }); + }); + + suite('isnan/isinf', () => { + exprTest('isnan(0.0 / 0.0)', { value: bool(true) }); + + exprTest('isinf(1.0 / 0.0)', { value: int(1) }); + exprTest('isinf(-1.0 / 0.0)', { value: int(-1) }); + }); + + suite('map', () => { + exprTest("map([10, 20, 30], 'v:key')", { + value: list([int(0), int(1), int(2)]), + }); + exprTest("map([10, 20, 30], 'v:val')", { + value: list([int(10), int(20), int(30)]), + }); + exprTest("map([10, 20, 30], 'v:key + v:val')", { + value: list([int(10), int(21), int(32)]), + }); + + // TODO: map() with Funcref + }); + + suite('tolower(str)', () => { + exprTest("tolower('Hello, World!')", { display: 'hello, world!' }); + exprTest('tolower(123)', { display: '123' }); + exprTest('tolower(1.23)', { error: ErrorCode.UsingFloatAsAString }); + }); + suite('toupper(str)', () => { + exprTest("toupper('Hello, World!')", { display: 'HELLO, WORLD!' }); + exprTest('toupper(123)', { display: '123' }); + exprTest('toupper(1.23)', { error: ErrorCode.UsingFloatAsAString }); + }); + + suite('range(expr [, max [, stride]])', () => { + exprTest('range(4)', { display: '[0, 1, 2, 3]' }); + exprTest('range(2, 4)', { display: '[2, 3, 4]' }); + exprTest('range(2, 9, 3)', { display: '[2, 5, 8]' }); + exprTest('range(2, -2, -1)', { display: '[2, 1, 0, -1, -2]' }); + exprTest('range(0)', { display: '[]' }); + exprTest('range(1, 10, 0)', { error: ErrorCode.StrideIsZero }); + exprTest('range(2, 0)', { error: ErrorCode.StartPastEnd }); + }); + + suite('repeat(expr, count)', () => { + exprTest('repeat(3, 5)', { display: '33333' }); + exprTest('repeat("abc", 3)', { display: 'abcabcabc' }); + exprTest('repeat("", 8)', { display: '' }); + exprTest('repeat([], 3)', { display: '[]' }); + exprTest('repeat([1,2], 3)', { display: '[1, 2, 1, 2, 1, 2]' }); + exprTest('repeat(range(2,6,2), 3)', { display: '[2, 4, 6, 2, 4, 6, 2, 4, 6]' }); + exprTest('repeat(1.0, 3)', { error: ErrorCode.UsingFloatAsAString }); + }); + + suite('string(x)', () => { + exprTest('string("")', { value: str('') }); + exprTest('string(123)', { value: str('123') }); + exprTest('string(123.0)', { value: str('123.0') }); + exprTest('string([1,2,3])', { value: str('[1, 2, 3]') }); + exprTest('string(#{a:1,b:2})', { value: str("{'a': 1, 'b': 2}") }); + }); + + suite('floor/ceil/round/trunc', () => { + exprTest('floor(3.5)', { value: float(3) }); + exprTest('floor(-3.5)', { value: float(-4) }); + + exprTest('ceil(3.5)', { value: float(4) }); + exprTest('ceil(-3.5)', { value: float(-3) }); + + exprTest('round(3.5)', { value: float(4) }); + exprTest('round(-3.5)', { value: float(-4) }); + + exprTest('trunc(3.5)', { value: float(3) }); + exprTest('trunc(-3.5)', { value: float(-3) }); + }); + + suite('keys/values/items', () => { + exprTest('keys({})', { value: list([]) }); + exprTest('sort(keys({"a": 1, "b": 2}))', { + value: list([str('a'), str('b')]), + }); + + exprTest('values({})', { value: list([]) }); + exprTest('sort(values({"a": 1, "b": 2}))', { + value: list([int(1), int(2)]), + }); + + exprTest('items({})', { value: list([]) }); + exprTest('sort(items({"a": 1, "b": 2}))', { + value: list([list([str('a'), int(1)]), list([str('b'), int(2)])]), + }); + }); + + suite('sort', () => { + exprTest('sort([])', { value: list([]) }); + + exprTest("sort(['A', 'c', 'B', 'a', 'C', 'b'])", { display: "['A', 'B', 'C', 'a', 'b', 'c']" }); + + exprTest("sort(['A', 'c', 'B', 'a', 'C', 'b'], 'i')", { + display: "['A', 'a', 'B', 'b', 'C', 'c']", + }); + exprTest("sort(['A', 'c', 'B', 'a', 'C', 'b'], 1)", { + display: "['A', 'a', 'B', 'b', 'C', 'c']", + }); + + // TODO + }); +});