diff --git a/src/cmd_line/commands/echo.ts b/src/cmd_line/commands/echo.ts new file mode 100644 index 00000000000..50dbf74f5df --- /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/eval.ts b/src/cmd_line/commands/eval.ts new file mode 100644 index 00000000000..360bb1881bf --- /dev/null +++ b/src/cmd_line/commands/eval.ts @@ -0,0 +1,40 @@ +import { optWhitespace, Parser } from 'parsimmon'; +import { VimState } from '../../state/vimState'; +import { ExCommand } from '../../vimscript/exCommand'; +import { expressionParser, functionCallParser } from '../../vimscript/expression/parser'; +import { Expression } from '../../vimscript/expression/types'; +import { EvaluationContext } from '../../vimscript/expression/evaluate'; + +export class EvalCommand extends ExCommand { + public static argParser: Parser = optWhitespace + .then(expressionParser) + .map((expression) => new EvalCommand(expression)); + + private expression: Expression; + private constructor(expression: Expression) { + super(); + this.expression = expression; + } + + public async execute(vimState: VimState): Promise { + const ctx = new EvaluationContext(); + ctx.evaluate(this.expression); + } +} + +export class CallCommand extends ExCommand { + public static argParser: Parser = optWhitespace + .then(functionCallParser) + .map((call) => new CallCommand(call)); + + private expression: Expression; + private constructor(funcCall: Expression) { + super(); + this.expression = funcCall; + } + + public async execute(vimState: VimState): Promise { + const ctx = new EvaluationContext(); + ctx.evaluate(this.expression); + } +} diff --git a/src/cmd_line/commands/let.ts b/src/cmd_line/commands/let.ts new file mode 100644 index 00000000000..623199a0f76 --- /dev/null +++ b/src/cmd_line/commands/let.ts @@ -0,0 +1,154 @@ +// eslint-disable-next-line id-denylist +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'; +import { ErrorCode, VimError } from '../../error'; + +export type LetCommandOperation = '=' | '+=' | '-=' | '*=' | '/=' | '%=' | '.=' | '..='; +export type LetCommandVariable = + | VariableExpression + | OptionExpression + | RegisterExpression + | EnvVariableExpression; +export type LetCommandArgs = + | { + operation: LetCommandOperation; + variable: LetCommandVariable; + expression: Expression; + lock: boolean; + } + | { + operation: 'print'; + variables: LetCommandVariable[]; + }; + +const operationParser: Parser = alt( + string('='), + string('+='), + string('-='), + string('*='), + string('/='), + string('%='), + string('.='), + string('..='), +); + +const letVarParser = alt( + variableParser, + optionParser, + envVariableParser, + registerParser, +); + +export class LetCommand extends ExCommand { + // TODO: Support unpacking + // TODO: Support indexing + // TODO: Support slicing + public static readonly argParser = (lock: boolean) => + 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, + lock, + }), + ), + ), + // `: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); + const prefix = value.type === 'number' ? '#' : value.type === 'funcref' ? '*' : ''; + StatusBar.setText(vimState, `${variable.name} ${prefix}${displayValue(value)}`); + } + } else { + const variable = this.args.variable; + + if (this.args.lock) { + if (this.args.operation !== '=') { + throw VimError.fromCode(ErrorCode.CannotModifyExistingVariable); + } else if (this.args.variable.type !== 'variable') { + // TODO: this error message should vary by type + throw VimError.fromCode(ErrorCode.CannotLockARegister); + } + } + + 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, this.args.lock); + } 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/cmd_line/commands/marks.ts b/src/cmd_line/commands/marks.ts index 37351630caf..f01a72c3b4b 100644 --- a/src/cmd_line/commands/marks.ts +++ b/src/cmd_line/commands/marks.ts @@ -96,7 +96,7 @@ export class DeleteMarksCommand extends ExCommand { private static resolveMarkList(vimState: VimState, args: DeleteMarksArgs) { const asciiRange = (start: string, end: string) => { if (start > end) { - throw VimError.fromCode(ErrorCode.InvalidArgument); + throw VimError.fromCode(ErrorCode.InvalidArgument474); } const [asciiStart, asciiEnd] = [start.charCodeAt(0), end.charCodeAt(0)]; @@ -120,7 +120,7 @@ export class DeleteMarksCommand extends ExCommand { } else { const range = asciiRange(x.start, x.end); if (range === undefined) { - throw VimError.fromCode(ErrorCode.InvalidArgument); + throw VimError.fromCode(ErrorCode.InvalidArgument474); } marks.push(...range.concat()); } diff --git a/src/cmd_line/commands/put.ts b/src/cmd_line/commands/put.ts index 3e694eae881..c52e29f24a7 100644 --- a/src/cmd_line/commands/put.ts +++ b/src/cmd_line/commands/put.ts @@ -2,7 +2,7 @@ import { configuration } from '../../configuration/configuration'; import { VimState } from '../../state/vimState'; // eslint-disable-next-line id-denylist -import { Parser, alt, any, optWhitespace, seq } from 'parsimmon'; +import { Parser, alt, any, optWhitespace, seq, string } from 'parsimmon'; import { Position } from 'vscode'; import { PutBeforeFromCmdLine, PutFromCmdLine } from '../../actions/commands/put'; import { ErrorCode, VimError } from '../../error'; @@ -11,12 +11,14 @@ import { StatusBar } from '../../statusBar'; import { ExCommand } from '../../vimscript/exCommand'; import { LineRange } from '../../vimscript/lineRange'; import { bangParser } from '../../vimscript/parserUtils'; -import { expressionParser } from '../expression'; +import { Expression } from '../../vimscript/expression/types'; +import { expressionParser } from '../../vimscript/expression/parser'; +import { EvaluationContext, toString } from '../../vimscript/expression/evaluate'; export interface IPutCommandArguments { bang: boolean; register?: string; - fromExpression?: string; + fromExpression?: Expression; } // @@ -27,15 +29,20 @@ export interface IPutCommandArguments { export class PutExCommand extends ExCommand { public static readonly argParser: Parser = seq( bangParser, - alt( - expressionParser, - optWhitespace - .then(any) - .map((x) => ({ register: x })) - .fallback({ register: undefined }), + optWhitespace.then( + alt>( + string('=') + .then(optWhitespace) + .then(expressionParser) + .map((expression) => ({ fromExpression: expression })), + // eslint-disable-next-line id-denylist + any.map((register) => ({ register })).fallback({ register: undefined }), + ), ), ).map(([bang, register]) => new PutExCommand({ bang, ...register })); + private static lastExpression: Expression | undefined; + public readonly arguments: IPutCommandArguments; constructor(args: IPutCommandArguments) { @@ -48,14 +55,22 @@ export class PutExCommand extends ExCommand { } async doPut(vimState: VimState, position: Position): Promise { - if (this.arguments.fromExpression && this.arguments.register) { - // set the register to the value of the expression - Register.overwriteRegister( - vimState, - this.arguments.register, - this.arguments.fromExpression, - 0, - ); + if (this.arguments.register === '=' && this.arguments.fromExpression === undefined) { + if (PutExCommand.lastExpression === undefined) { + return; + } + this.arguments.fromExpression = PutExCommand.lastExpression; + } + + if (this.arguments.fromExpression) { + PutExCommand.lastExpression = this.arguments.fromExpression; + + this.arguments.register = '='; + + const value = new EvaluationContext().evaluate(this.arguments.fromExpression); + const stringified = + value.type === 'list' ? value.items.map(toString).join('\n') : toString(value); + Register.overwriteRegister(vimState, this.arguments.register, stringified, 0); } const registerName = this.arguments.register || (configuration.useSystemClipboard ? '*' : '"'); diff --git a/src/cmd_line/commands/set.ts b/src/cmd_line/commands/set.ts index 78045132deb..88b24fa366b 100644 --- a/src/cmd_line/commands/set.ts +++ b/src/cmd_line/commands/set.ts @@ -200,7 +200,7 @@ export class SetCommand extends ExCommand { if (type === 'boolean') { configuration[option] = false; } else { - throw VimError.fromCode(ErrorCode.InvalidArgument, `no${option}`); + throw VimError.fromCode(ErrorCode.InvalidArgument474, `no${option}`); } break; } @@ -209,7 +209,7 @@ export class SetCommand extends ExCommand { configuration[option] = !currentValue; } else { // TODO: Could also be {option}! - throw VimError.fromCode(ErrorCode.InvalidArgument, `inv${option}`); + throw VimError.fromCode(ErrorCode.InvalidArgument474, `inv${option}`); } break; } @@ -224,7 +224,10 @@ export class SetCommand extends ExCommand { case 'equal': { if (type === 'boolean') { // TODO: Could also be {option}:{value} - throw VimError.fromCode(ErrorCode.InvalidArgument, `${option}=${this.operation.value}`); + throw VimError.fromCode( + ErrorCode.InvalidArgument474, + `${option}=${this.operation.value}`, + ); } else if (type === 'string') { configuration[option] = this.operation.value; } else { @@ -242,7 +245,10 @@ export class SetCommand extends ExCommand { } case 'add': { if (type === 'boolean') { - throw VimError.fromCode(ErrorCode.InvalidArgument, `${option}+=${this.operation.value}`); + throw VimError.fromCode( + ErrorCode.InvalidArgument474, + `${option}+=${this.operation.value}`, + ); } else if (type === 'string') { configuration[option] = currentValue + this.operation.value; } else { @@ -259,7 +265,10 @@ export class SetCommand extends ExCommand { } case 'multiply': { if (type === 'boolean') { - throw VimError.fromCode(ErrorCode.InvalidArgument, `${option}^=${this.operation.value}`); + throw VimError.fromCode( + ErrorCode.InvalidArgument474, + `${option}^=${this.operation.value}`, + ); } else if (type === 'string') { configuration[option] = this.operation.value + currentValue; } else { @@ -276,7 +285,10 @@ export class SetCommand extends ExCommand { } case 'subtract': { if (type === 'boolean') { - throw VimError.fromCode(ErrorCode.InvalidArgument, `${option}-=${this.operation.value}`); + throw VimError.fromCode( + ErrorCode.InvalidArgument474, + `${option}-=${this.operation.value}`, + ); } else if (type === 'string') { configuration[option] = (currentValue as string).split(this.operation.value).join(''); } else { diff --git a/src/cmd_line/expression.ts b/src/cmd_line/expression.ts deleted file mode 100644 index 5f45dd2c132..00000000000 --- a/src/cmd_line/expression.ts +++ /dev/null @@ -1,47 +0,0 @@ -// eslint-disable-next-line id-denylist -import { Parser, alt, optWhitespace, seqObj, string } from 'parsimmon'; -import { ErrorCode, VimError } from '../error'; -import { integerParser } from '../vimscript/parserUtils'; - -interface Expression { - parser: Parser; -} - -function range(start: number, stop: number, step: number): number[] { - return Array.from({ length: (stop - start) / step + 1 }, (_, i) => start + i * step); -} - -const RangeExpression: Expression = { - parser: seqObj<{ start: number; end: number; step: number }>( - string('range'), - string('('), - optWhitespace, - ['start', integerParser], - optWhitespace, - string(','), - optWhitespace, - ['end', integerParser], - optWhitespace, - ['step', string(',').then(optWhitespace).then(integerParser).fallback(1)], - optWhitespace, - string(')'), - ).map(({ start, end, step }): string => { - const numbers = range(start, end, step); - if (numbers.length === 0) { - throw VimError.fromCode(ErrorCode.StartPastEnd); - } else { - return numbers.join('\n'); - } - }), -}; - -const EXPRESSION_REGISTER = string('='); - -const altExpressions: Parser = alt(RangeExpression.parser); - -export const expressionParser = seqObj<{ register: string; fromExpression: string }>( - optWhitespace, - ['register', EXPRESSION_REGISTER], - optWhitespace, - ['fromExpression', altExpressions], -); diff --git a/src/error.ts b/src/error.ts index ac627592a4e..45649f8525c 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, @@ -15,6 +16,11 @@ export enum ErrorCode { NoWriteSinceLastChange = 37, MultipleMatches = 93, NoMatchingBuffer = 94, + MissingQuote = 114, + UnknownFunction_call = 117, + TooManyArgs = 118, + NotEnoughArgs = 119, + UndefinedVariable = 121, ErrorWritingToFile = 208, FileNoLongerAvailable = 211, RecursiveMapping = 223, @@ -25,7 +31,8 @@ export enum ErrorCode { SearchHitBottom = 385, CannotCloseLastWindow = 444, ArgumentRequired = 471, - InvalidArgument = 474, + InvalidArgument474 = 474, + InvalidArgument475 = 475, NoRangeAllowed = 481, PatternNotFound = 486, TrailingCharacters = 488, @@ -36,12 +43,49 @@ export enum ErrorCode { AtStartOfChangeList = 662, AtEndOfChangeList = 663, ChangeListIsEmpty = 664, + ListIndexOutOfRange = 684, + ArgumentOfSortMustBeAList = 686, + CanOnlyCompareListWithList = 691, + InvalidOperationForList = 692, + CannotIndexAFuncref = 695, + UnknownFunction_funcref = 700, + InvalidTypeForLen = 701, + UsingAFuncrefAsANumber = 703, + FuncrefVariableNameMustStartWithACapital = 704, + ArgumentOfMaxMustBeAListOrDictionary = 712, // TODO: This should be different for min(), count() + ListRequired = 714, + DictionaryRequired = 715, + KeyNotPresentInDictionary = 716, + CannotUseSliceWithADictionary = 719, + DuplicateKeyInDictionary = 721, + StrideIsZero = 726, StartPastEnd = 727, + UsingADictionaryAsANumber = 728, + UsingListAsAString = 730, + UsingFuncrefAsAString = 729, + UsingDictionaryAsAString = 731, + CanOnlyCompareDictionaryWithDictionary = 735, + InvalidOperationForDictionary = 736, + ValueIsLocked = 741, + UsingAListAsANumber = 745, NoPreviouslyUsedRegister = 748, + CannotUseModuloWithFloat = 804, + UsingAFloatAsANumber = 805, + UsingFloatAsAString = 806, + NumberOrFloatRequired = 808, + ArgumentOfMapMustBeAListDictionaryOrBlob = 896, + ListOrBlobRequired = 897, + ExpectedADict = 922, + SecondArgumentOfFunction = 923, + BlobLiteralShouldHaveAnEvenNumberOfHexCharacters = 973, + UsingABlobAsANumber = 974, + CannotModifyExistingVariable = 995, + CannotLockARegister = 996, } export const ErrorMessage: IErrorMessage = { 14: 'Invalid address', + 15: 'Invalid expression', 16: 'Invalid range', 20: 'Mark not set', 23: 'No alternate file', @@ -53,6 +97,11 @@ export const ErrorMessage: IErrorMessage = { 37: 'No write since last change (add ! to override)', 93: 'More than one match', 94: 'No matching buffer', + 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', @@ -64,6 +113,7 @@ export const ErrorMessage: IErrorMessage = { 444: 'Cannot close last window', 471: 'Argument required', 474: 'Invalid argument', + 475: 'Invalid argument', 481: 'No range allowed', 486: 'Pattern not found', 488: 'Trailing characters', @@ -74,8 +124,44 @@ 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', + 701: 'Invalid type for len()', + 703: 'Using a Funcref as a Number', + 704: 'Funcref variable name must start with a capital', + 712: 'Argument of max() must be a List or Dictionary', + 714: 'List required', + 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', + 741: 'Value is locked', + 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', + 897: 'List or Blob required', + 922: 'expected a dict', + 923: 'Second argument of function() must be a list or a dict', + 973: 'Blob literal should have an even number of hex characters', + 974: 'Using a Blob as a Number', + 995: 'Cannot modify existing variable', + 996: 'Cannot lock a register', }; export class VimError extends Error { diff --git a/src/transformations/execute.ts b/src/transformations/execute.ts index e3907927380..e22587fdf8e 100644 --- a/src/transformations/execute.ts +++ b/src/transformations/execute.ts @@ -13,7 +13,7 @@ import { Logger } from '../util/logger'; import { keystrokesExpressionForMacroParser, keystrokesExpressionParser, -} from '../vimscript/expression'; +} from '../vimscript/parserUtils'; import { Dot, ExecuteNormalTransformation, diff --git a/src/vimscript/exCommandParser.ts b/src/vimscript/exCommandParser.ts index a29262ddc45..66334625307 100644 --- a/src/vimscript/exCommandParser.ts +++ b/src/vimscript/exCommandParser.ts @@ -10,6 +10,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'; @@ -49,6 +50,8 @@ import { StatusBar } from '../statusBar'; import { ExCommand } from './exCommand'; import { LineRange } from './lineRange'; import { nameAbbrevParser } from './parserUtils'; +import { LetCommand } from '../cmd_line/commands/let'; +import { CallCommand, EvalCommand } from '../cmd_line/commands/eval'; type ArgParser = Parser; @@ -125,7 +128,7 @@ export const builtinExCommands: ReadonlyArray<[[string, string], ArgParser | und [['cadde', 'xpr'], undefined], [['caddf', 'ile'], undefined], [['caf', 'ter'], undefined], - [['cal', 'l'], undefined], + [['cal', 'l'], CallCommand.argParser], [['cat', 'ch'], undefined], [['cb', 'uffer'], undefined], [['cbef', 'ore'], undefined], @@ -170,7 +173,7 @@ export const builtinExCommands: ReadonlyArray<[[string, string], ArgParser | und [['comp', 'iler'], undefined], [['con', 'tinue'], undefined], [['conf', 'irm'], undefined], - [['cons', 't'], undefined], + [['cons', 't'], LetCommand.argParser(true)], [['cope', 'n'], succeed(new VsCodeCommand('workbench.panel.markers.view.focus'))], [['cp', 'revious'], succeed(new VsCodeCommand('editor.action.marker.prevInFiles'))], [['cpf', 'ile'], succeed(new VsCodeCommand('editor.action.marker.prevInFiles'))], @@ -207,11 +210,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], @@ -221,7 +224,7 @@ export const builtinExCommands: ReadonlyArray<[[string, string], ArgParser | und [['endt', 'ry'], undefined], [['endw', 'hile'], undefined], [['ene', 'w'], FileCommand.argParsers.enew], - [['ev', 'al'], undefined], + [['ev', 'al'], EvalCommand.argParser], [['ex', ''], FileCommand.argParsers.edit], [['exe', 'cute'], undefined], [['exi', 't'], WriteQuitCommand.argParser], @@ -303,7 +306,7 @@ export const builtinExCommands: ReadonlyArray<[[string, string], ArgParser | und [['ld', 'o'], undefined], [['le', 'ft'], LeftCommand.argParser], [['lefta', 'bove'], undefined], - [['let', ''], undefined], + [['let', ''], LetCommand.argParser(false)], [['lex', 'pr'], undefined], [['lf', 'ile'], undefined], [['lfd', 'o'], undefined], diff --git a/src/vimscript/expression.ts b/src/vimscript/expression.ts deleted file mode 100644 index bc254896ee7..00000000000 --- a/src/vimscript/expression.ts +++ /dev/null @@ -1,34 +0,0 @@ -// eslint-disable-next-line id-denylist -import { Parser, alt, any, noneOf, regexp, string } from 'parsimmon'; -import { configuration } from '../configuration/configuration'; - -const leaderParser = regexp(//).map(() => configuration.leader); // lazy evaluation of configuration.leader -const specialCharacters = regexp(/<(?:Esc|C-\w|A-\w|C-A-\w)>/); - -const specialCharacterParser = alt(specialCharacters, leaderParser); - -// TODO: Move to a more general location -// TODO: Add more special characters -const escapedParser = string('\\') - // eslint-disable-next-line id-denylist - .then(any.fallback(undefined)) - .map((escaped) => { - if (escaped === undefined) { - return '\\\\'; - } else if (escaped === 'n') { - return '\n'; - } - return '\\' + escaped; - }); - -export const keystrokesExpressionParser: Parser = alt( - escapedParser, - specialCharacterParser, - any, -).many(); - -export const keystrokesExpressionForMacroParser: Parser = alt( - escapedParser, - specialCharacterParser, - noneOf('"'), -).many(); diff --git a/src/vimscript/expression/build.ts b/src/vimscript/expression/build.ts new file mode 100644 index 00000000000..7f3cd84c1f5 --- /dev/null +++ b/src/vimscript/expression/build.ts @@ -0,0 +1,147 @@ +import { + NumberValue, + Expression, + ListExpression, + UnaryExpression, + BinaryOp, + BinaryExpression, + FunctionCallExpression, + StringValue, + LambdaExpression, + VariableExpression, + Namespace, + FloatValue, + FuncRefValue, + ListValue, + DictionaryValue, + Value, + BlobValue, +} from './types'; + +export function int(value: number): NumberValue { + return { + type: 'number', + value: Math.trunc(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 blob(data: ArrayBuffer): BlobValue { + return { + type: 'blob', + data, + }; +} + +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 00000000000..3df94435645 --- /dev/null +++ b/src/vimscript/expression/displayValue.ts @@ -0,0 +1,46 @@ +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 + const result = value.value.toFixed(6).replace(/0*$/, ''); + if (result.endsWith('.')) { + return result + '0'; + } + return result; + } + 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)})`; + } + case 'blob': + return ( + '0z' + + [...new Uint8Array(value.data)] + .map((byte) => byte.toString(16).padStart(2, '0')) + .join('') + .toUpperCase() + ); + } +} diff --git a/src/vimscript/expression/evaluate.ts b/src/vimscript/expression/evaluate.ts new file mode 100644 index 00000000000..f9cc407c5cb --- /dev/null +++ b/src/vimscript/expression/evaluate.ts @@ -0,0 +1,1313 @@ +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, list, funcCall, blob } from './build'; +import { expressionParser, numberParser } from './parser'; +import { + BinaryOp, + ComparisonOp, + DictionaryValue, + Expression, + FloatValue, + FunctionCallExpression, + ListValue, + NumberValue, + StringValue, + UnaryOp, + Value, + VariableExpression, +} from './types'; + +// ID of next lambda; incremented each time one is created +let lambdaNumber = 1; + +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); + case 'blob': + throw VimError.fromCode(ErrorCode.UsingABlobAsANumber); + } +} + +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': + case 'blob': + throw VimError.fromCode(ErrorCode.NumberOrFloatRequired); + } +} + +export 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); + case 'blob': + return displayValue(value); + } +} + +function toList(value: Value): ListValue { + switch (value.type) { + case 'number': + case 'float': + case 'string': + case 'funcref': + case 'dict_val': + case 'blob': + throw VimError.fromCode(ErrorCode.ListRequired); + case 'list': + return value; + } +} + +function toDict(value: Value): DictionaryValue { + switch (value.type) { + case 'number': + case 'float': + case 'string': + case 'list': + case 'funcref': + case 'blob': + throw VimError.fromCode(ErrorCode.DictionaryRequired); + case 'dict_val': + return value; + } +} + +function mapNumber(value: Value, f: (x: number) => number): NumberValue | FloatValue { + switch (value.type) { + case 'float': + return float(f(value.value)); + default: + return int(f(toInt(value))); + } +} + +export class Variable { + public value: Value; + public locked: boolean = false; + + constructor(value: Value, locked: boolean = false) { + this.value = value; + this.locked = locked; + } +} + +type VariableStore = Map; + +export class EvaluationContext { + private static globalVariables: VariableStore = new Map(); + + private localScopes: VariableStore[] = []; + private errors: string[] = []; + + /** + * 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': + case 'blob': + return expression; + case 'list': + return list(expression.items.map((x) => this.evaluate(x))); + 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': + return this.evaluateFunctionCall(expression); + 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': { + const entry = toDict(this.evaluate(expression.expression)).items.get(expression.entryName); + if (!entry) { + throw VimError.fromCode(ErrorCode.KeyNotPresentInDictionary, expression.entryName); + } + return entry; + } + 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` + if (fref.body) { + return fref.body(expression.args.map((x) => this.evaluate(x))); + } else { + return this.evaluateFunctionCall( + funcCall( + fref.name, + (fref.arglist?.items ?? []).concat(expression.args.map((x) => this.evaluate(x))), + ), + ); + } + } + case 'methodCall': { + const obj = this.evaluate(expression.expression); + return this.evaluateFunctionCall( + funcCall(expression.methodName, [obj, ...expression.args]), + ); + } + case 'lambda': { + return { + type: 'funcref', + name: `${lambdaNumber++}`, + body: (args: Value[]) => { + // TODO: handle wrong # of args + const store: VariableStore = new Map(); + for (let i = 0; i < args.length; i++) { + store.set(expression.args[i], new Variable(args[i])); + } + + this.localScopes.push(store); + const retval = this.evaluate(expression.body); + this.localScopes.pop(); + return retval; + }, + }; + } + 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, lock: boolean): 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.length > 0 && 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) { + if (lock) { + throw VimError.fromCode(ErrorCode.CannotModifyExistingVariable); + } + if (_var.locked) { + throw VimError.fromCode(ErrorCode.ValueIsLocked, varExpr.name); + } + _var.value = value; + } else { + store.set(varExpr.name, new Variable(value, lock)); + } + } + } + + 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') { + // TODO: v:count, v:count1, v:prevcount + // TODO: v:operator + // TODO: v:register + // TODO: v:searchforward + // TODO: v:statusmsg, v:warningmsg, v:errmsg + 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 === 't_blob') { + return int(10); + } else if (varExpr.name === 'numbermax') { + return int(Number.MAX_VALUE); + } else if (varExpr.name === 'numbermin') { + return int(Number.MIN_VALUE); + } else if (varExpr.name === 'numbersize') { + // NOTE: In VimScript this refers to a 64 bit integer; we have a 64 bit float because JavaScript + return int(64); + } else if (varExpr.name === 'errors') { + return list(this.errors.map(str)); + } + + // 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 : idx; + if (idx < 0 || idx >= sequence.items.length) { + throw VimError.fromCode(ErrorCode.ListIndexOutOfRange, idx.toString()); + } + return sequence.items[idx]; + } + 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); + } + case 'blob': { + const bytes = new Uint8Array(sequence.data); + return int(bytes[toInt(index)]); + } + } + } + + 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); + } + case 'blob': { + return blob(new Uint8Array(sequence.data).slice(_start, _end + 1)); + } + } + } + + private evaluateUnary(operator: UnaryOp, operand: Expression): NumberValue | FloatValue { + return mapNumber(this.evaluate(operand), (x: number) => { + switch (operator) { + case '+': + return x; + case '-': + return -x; + case '!': + return x === 0 ? 1 : 0; + default: + throw new Error('Impossible'); + } + }); + } + + private evaluateBinary(operator: BinaryOp, lhsExpr: Expression, rhsExpr: Expression): Value { + let [lhs, rhs] = [this.evaluate(lhsExpr), this.evaluate(rhsExpr)]; + + const arithmetic = (f: (x: number, y: number) => number) => { + const numType = lhs.type === 'float' || rhs.type === 'float' ? float : int; + if (lhs.type === 'string') { + lhs = int(toInt(lhs)); + } + if (rhs.type === 'string') { + rhs = int(toInt(rhs)); + } + return numType(f(toFloat(lhs), toFloat(rhs))); + }; + + switch (operator) { + case '+': + if (lhs.type === 'list' && rhs.type === 'list') { + return listExpr(lhs.items.concat(rhs.items)) as ListValue; + } else { + return arithmetic((x, y) => x + y); + } + case '-': + return arithmetic((x, y) => x - y); + case '*': + return arithmetic((x, y) => x * y); + case '/': + return arithmetic((x, y) => x / y); + case '.': + case '..': + return str(toString(lhs) + toString(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, + topLevel: boolean = true, + ): boolean { + if (operator === 'is' && lhsExpr.type !== rhsExpr.type) { + return false; + } + + 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], false), + ) + ); + case 'is': + return lhsExpr.items === rhsExpr.items; + 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)!, false), + ) + ); + case 'is': + return lhs.items === rhs.items; + 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') { + if (topLevel) { + // Strings are automatically coerced to numbers, except within a list/dict + // i.e. 4 == "4" but [4] != ["4"] + [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 + } + } + } + + private evaluateFunctionCall(call: FunctionCallExpression): Value { + const getArgs = (min: number, max?: number) => { + if (max === undefined) { + max = min; + } + if (call.args.length < min) { + throw VimError.fromCode(ErrorCode.NotEnoughArgs, call.func); + } + if (call.args.length > max) { + throw VimError.fromCode(ErrorCode.TooManyArgs, call.func); + } + const args: Array = call.args.map((arg) => this.evaluate(arg)); + while (args.length < max) { + args.push(undefined); + } + return args; + }; + switch (call.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 'add': { + const [l, expr] = getArgs(2); + // TODO: should also work with blob + const lst = toList(l!); + lst.items.push(expr!); + return lst; + } + 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); + // eslint-disable-next-line no-bitwise + return int(toInt(x!) & toInt(y!)); + } + // TODO: assert_*() + case 'assert_equal': { + const [expected, actual, msg] = getArgs(2, 3); + if (this.evaluateComparison('==', true, expected!, actual!)) { + return int(0); + } + this.errors.push( + msg + ? toString(msg) + : `Expected ${displayValue(expected!)} but got ${displayValue(actual!)}`, // TODO: Include file & line + ); + return int(1); + } + case 'assert_notequal': { + const [expected, actual, msg] = getArgs(2, 3); + if (this.evaluateComparison('!=', true, expected!, actual!)) { + return int(0); + } + this.errors.push( + msg ? toString(msg) : `Expected not equal to ${displayValue(expected!)}`, // TODO: Include file & line + ); + return int(1); + } + case 'assert_report': { + this.errors.push(toString(getArgs(1)[0]!)); + return int(1); + } + case 'assert_true': { + const [actual, msg] = getArgs(2, 3); + if (this.evaluateComparison('==', true, bool(true), actual!)) { + return int(0); + } + this.errors.push( + msg ? toString(msg) : `Expected True but got ${displayValue(actual!)}`, // TODO: Include file & line + ); + return int(1); + } + // TODO: call() + case 'ceil': { + const [x] = getArgs(1); + return float(Math.ceil(toFloat(x!))); + } + case 'copy': { + const [x] = getArgs(1); + switch (x?.type) { + case 'list': + return list([...x.items]); + case 'dict_val': + return { + type: 'dict_val', + items: new Map(x.items), + }; + } + 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 'count': { + let [comp, expr, ic, start] = getArgs(2, 4); + const matchCase = toInt(ic ?? bool(false)) === 0; + if (start !== undefined) { + if (comp!.type !== 'list') { + throw VimError.fromCode(ErrorCode.InvalidArgument474); + } + if (toInt(start) >= comp!.items.length) { + throw VimError.fromCode(ErrorCode.ListIndexOutOfRange); + } + while (toInt(start) < 0) { + start = int(toInt(start) + comp!.items.length); + } + } + let count = 0; + switch (comp!.type) { + // TODO: case 'string': + case 'list': + const startIdx = start ? toInt(start) : 0; + for (let i = startIdx; i < comp!.items.length; i++) { + if (this.evaluateComparison('==', matchCase, comp!.items[i], expr!)) { + count++; + } + } + break; + case 'dict_val': + for (const val of comp!.items.values()) { + if (this.evaluateComparison('==', matchCase, val, expr!)) { + count++; + } + } + break; + default: + throw VimError.fromCode(ErrorCode.ArgumentOfMaxMustBeAListOrDictionary); + } + return int(count); + } + 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!))); + } + // TODO: exists() + case 'exp': { + const [x] = getArgs(1); + return float(Math.exp(toFloat(x!))); + } + // TODO: extend() + // TODO: filter() + // TODO: flatten() + case 'float2nr': { + const [x] = getArgs(1); + return int(toFloat(x!)); + } + // TODO: fullcommand() + 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 'get': { + const [_haystack, _idx, _default] = getArgs(2, 3); + const haystack = this.evaluate(_haystack!); + if (haystack.type === 'list') { + let idx = toInt(this.evaluate(_idx!)); + idx = idx < 0 ? haystack.items.length + idx : idx; + return idx < haystack.items.length ? haystack.items[idx] : (_default ?? int(0)); + } else if (haystack.type === 'blob') { + const bytes = new Uint8Array(haystack.data); + let idx = toInt(this.evaluate(_idx!)); + idx = idx < 0 ? bytes.length + idx : idx; + return idx < bytes.length ? int(bytes[idx]) : (_default ?? int(-1)); + } else if (haystack.type === 'dict_val') { + const key = this.evaluate(_idx!); + const val = haystack.items.get(toString(key)); + return val ? val : (_default ?? int(0)); + } + return _default ?? int(0); + // TODO: get({func}, {what}) + } + // TODO: getcurpos() + // TODO: getline() + // TODO: getreg() + // TODO: getreginfo() + // TODO: getregtype() + // TODO: gettext() + case 'gettext': { + const [s] = getArgs(1); + return str(toString(s!)); + } + // TODO: glob2regpat() + case 'has': { + const [feature] = getArgs(1); + return bool(toString(feature!) === 'vscode'); + } + case 'has_key': { + const [d, k] = getArgs(2); + return bool(toDict(d!).items.has(toString(k!))); + } + // TODO: hasmapto() + // TODO: histadd()/histdel()/histget()/histnr() + // TODO: id() + case 'index': { + const [_haystack, _needle, _start, ic] = getArgs(2, 4); + const haystack = this.evaluate(_haystack!); + const needle = this.evaluate(_needle!); + + if (haystack.type === 'list') { + let start: number | undefined; + if (_start) { + start = toInt(_start); + start = start < 0 ? haystack.items.length + start : start; + } + + for (const [idx, item] of haystack.items.entries()) { + if (start && idx < start) { + continue; + } + if (this.evaluateComparison('==', true, item, needle)) { + return int(idx); + } + } + return int(-1); + } + // TODO: handle blob + throw VimError.fromCode(ErrorCode.ListOrBlobRequired); + } + // TODO: indexof() + // TODO: input()/inputlist() + // TODO: insert() + // TODO: invert() + case 'isinf': { + const [x] = getArgs(1); + const _x = toFloat(x!); + return int(_x === Infinity ? 1 : _x === -Infinity ? -1 : 0); + } + // TODO: islocked() + 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 'join': { + const [l, sep] = getArgs(1, 2); + return str( + toList(l!) + .items.map(toString) + .join(sep ? toString(sep) : ''), + ); + } + // TODO: json_encode()/json_decode() + case 'keys': { + const [d] = getArgs(1); + return list([...toDict(d!).items.keys()].map(str)); + } + case 'len': { + const [x] = getArgs(1); + switch (x!.type) { + case 'number': + return int(x!.value.toString().length); + case 'string': + return int(x!.value.length); + case 'list': + return int(x!.items.length); + case 'dict_val': + return int(x!.items.size); + case 'blob': + return int(x!.data.byteLength); + default: + throw VimError.fromCode(ErrorCode.InvalidTypeForLen); + } + } + case 'localtime': { + return int(Date.now() / 1000); + } + 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 + // eslint-disable-next-line no-fallthrough + default: + throw VimError.fromCode(ErrorCode.ArgumentOfMapMustBeAListDictionaryOrBlob); + } + } + // TODO: matchadd()/matchaddpos()/matcharg()/matchdelete() + // TODO: match()/matchend()/matchlist()/matchstr()/matchstrpos() + case 'max': { + const [l] = getArgs(1); + let values: Value[]; + if (l?.type === 'list') { + values = l.items; + } else if (l?.type === 'dict_val') { + values = [...l.items.values()]; + } else { + throw VimError.fromCode(ErrorCode.ArgumentOfMaxMustBeAListOrDictionary); + } + return int(values.length === 0 ? 0 : Math.max(...values.map(toInt))); + } + case 'min': { + const [l] = getArgs(1); + let values: Value[]; + if (l?.type === 'list') { + values = l.items; + } else if (l?.type === 'dict_val') { + values = [...l.items.values()]; + } else { + // TODO: This should say "min", but still have code 712 + throw VimError.fromCode(ErrorCode.ArgumentOfMaxMustBeAListOrDictionary); + } + return int(values.length === 0 ? 0 : Math.min(...values.map(toInt))); + } + // TODO: mode() + case 'or': { + const [x, y] = getArgs(2); + // eslint-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!))); + } + // TODO: printf() + // TODO: rand() + 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); + } + // TODO: reduce() + // TODO: reg_executing() + // TODO: reg_recorded() + // TODO: reg_recording() + // TODO: reltime*() + 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 'remove': { + const [_haystack, _idx, _end] = getArgs(2, 3); + const haystack = this.evaluate(_haystack!); + if (haystack.type === 'list') { + let idx = toInt(this.evaluate(_idx!)); + idx = idx < 0 ? haystack.items.length + idx : idx; + if (_end === undefined) { + return haystack.items.splice(idx, 1)[0]; // TODO: This doesn't remove the item? + } else { + // TODO: remove({list}, {idx}, {end}) + } + } + // TODO: remove({blob}, {idx}, [{end}]) + else if (haystack.type === 'dict_val') { + const key = toString(this.evaluate(_idx!)); + const val = haystack.items.get(key); + if (val) { + haystack.items.delete(key); + return val; + } + } + return int(0); + } + case 'reverse': { + const [l] = getArgs(1); + if (l?.type === 'list') { + l.items.reverse(); + return l; + } else if (l?.type === 'blob') { + l.data = new Uint8Array(l.data).reverse(); + return l; + } + return int(0); + } + case 'round': { + const [x] = getArgs(1); + const _x = toFloat(x!); + // Halfway between integers, Math.round() rounds toward infinity while Vim's round() rounds away from 0. + return float(_x < 0 ? -Math.round(-_x) : Math.round(_x)); + } + // TODO: setreg() + 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' || func.type === 'number') { + if (func.value === 1 || func.value === '1' || func.value === 'i') { + // Ignore case + compare = (x, y) => { + const [_x, _y] = [displayValue(x).toLowerCase(), displayValue(y).toLowerCase()]; + return _x === _y ? 0 : _x > _y ? 1 : -1; + }; + } 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') { + compare = (x, y) => + toInt( + this.evaluate({ + type: 'funcrefCall', + expression: func, + args: [x, y], + }), + ); + } else { + throw VimError.fromCode(ErrorCode.InvalidArgument474); + } + } else { + compare = (x, y) => (displayValue(x) > displayValue(y) ? 1 : -1); + } + // TODO: Numbers after Strings, Lists after Numbers + return list(l.items.sort(compare)); + } + case 'split': { + const [s, pattern, keepempty] = getArgs(1, 3); + // TODO: Actually parse pattern + const result = toString(s!).split(pattern && toString(pattern) ? toString(pattern) : /\s+/); + if (!(keepempty && toInt(this.evaluate(keepempty)))) { + if (result[0] === '') { + result.shift(); + } + if (result && result[result.length - 1] === '') { + result.pop(); + } + } + return list(result.map(str)); + } + case 'sqrt': { + const [x] = getArgs(1); + return float(Math.sqrt(toFloat(x!))); + } + // TODO: str2float() + case 'str2list': { + const [s, _ignored] = getArgs(1, 2); + const result: number[] = []; + for (const char of toString(s!)) { + result.push(char.charCodeAt(0)); + } + return list(result.map(int)); + } + // TODO: str2nr() + // TODO: stridx() + case 'string': { + const [x] = getArgs(1); + return str(displayValue(x!)); + } + case 'strlen': { + const [s] = getArgs(1); + return int(toString(s!).length); + } + // TODO: strpart() + // TODO: submatch() + // TODO: substitute() + 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()); + } + // TODO: tr() + case 'trim': { + const [_s, mask, _dir] = getArgs(1, 3); + // TODO: use mask + let s = toString(_s!); + const dir = _dir ? toInt(_dir) : 0; + if (dir === 0) { + // Trim start and end + s = s.trimStart().trimEnd(); + } else if (dir === 1) { + // Trim start + s = s.trimStart(); + } else if (dir === 2) { + // Trim end + s = s.trimEnd(); + } else { + throw VimError.fromCode(ErrorCode.InvalidArgument475, dir.toString()); + } + return str(s); + } + 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 int(6); + // case 'null': + // return int(7); + case 'blob': + return int(8); + default: + const guard: never = x; + throw new Error('type() got unexpected type'); + } + } + case 'uniq': { + const [l, func, dict] = getArgs(1, 3); + // TODO: Use func (see sort() and try to re-use implementation) + // TODO: Use dict + if (l!.type !== 'list') { + throw VimError.fromCode(ErrorCode.ArgumentOfSortMustBeAList); // TODO: Correct error message + } + if (l!.items.length > 1) { + let prev: Value = l!.items[0]; + for (let i = 1; i < l!.items.length; ) { + const val = l!.items[i]; + if (this.evaluateComparison('==', true, prev, val)) { + l!.items.splice(i, 1); + } else { + prev = val; + i++; + } + } + } + return l!; + } + case 'values': { + const [d] = getArgs(1); + return list([...toDict(d!).items.values()]); + } + // TODO: visualmode() + // TODO: wordcount() + case 'xor': { + const [x, y] = getArgs(2); + // eslint-disable-next-line no-bitwise + return int(toInt(x!) ^ toInt(y!)); + } + default: { + throw VimError.fromCode(ErrorCode.UnknownFunction_call, call.func); + } + } + } +} diff --git a/src/vimscript/expression/parser.ts b/src/vimscript/expression/parser.ts new file mode 100644 index 00000000000..82af0be588d --- /dev/null +++ b/src/vimscript/expression/parser.ts @@ -0,0 +1,441 @@ +import { + Parser, + regexp, + seq, + alt, + // eslint-disable-next-line id-denylist + string, + lazy, + // eslint-disable-next-line id-denylist + any, + optWhitespace, + takeWhile, + noneOf, +} from 'parsimmon'; +import { ErrorCode, VimError } from '../../error'; +import { binary, float, lambda, listExpr, int, str, blob } from './build'; +import { + BinaryOp, + BlobValue, + DictionaryExpression, + EntryExpression, + EnvVariableExpression, + Expression, + FloatValue, + FuncrefCallExpression, + FunctionCallExpression, + IndexExpression, + LambdaExpression, + ListExpression, + MethodCallExpression, + NumberValue, + OptionExpression, + RegisterExpression, + SliceExpression, + StringValue, + VariableExpression, +} from './types'; + +// TODO: Support dots between bytes +const blobParser: Parser = regexp(/0[z]/i).then( + regexp(/[0-1a-z]+/i).map((hexData) => { + if (hexData.length % 2 !== 0) { + throw VimError.fromCode(ErrorCode.BlobLiteralShouldHaveAnEvenNumberOfHexCharacters); + } + const data = new Uint8Array(new ArrayBuffer(hexData.length / 2)); + for (let i = 0; i < hexData.length; i += 2) { + data[i / 2] = Number.parseInt(hexData.substring(i, i + 2), 16); + } + return blob(data); + }), +); + +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)); +}); + +const floatParser: Parser = seq( + regexp(/\d+\.\d+/).map((x) => Number.parseFloat(x)), + alt(string('e'), string('E')) + .then( + seq(alt(string('+'), string('-')).fallback(undefined), regexp(/\d+/)).map(([sign, _num]) => { + const num = Number.parseInt(_num, 10); + if (sign === '-') { + return -num; + } + return num; + }), + ) + .fallback(0), +) + .map(([num, exp]) => float(num * Math.pow(10, exp))) + .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; + }) + .desc('a number'); + +const stringParser: Parser = alt( + string('\\') + // eslint-disable-next-line id-denylist + .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(')')), +); + +export const functionCallParser: Parser = seq( + regexp(/[a-z0-9_]+/).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: Variable/function with curly braces +const expr9Parser: Parser = alt( + blobParser, + 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, + }; + }; + }); + +// TODO: Support method call with lambda +const methodCallParser: Parser<(expr: Expression) => MethodCallExpression> = string('->') + .then(seq(regexp(/[a-z]+/i), functionArgsParser)) + .desc('a method call') + .map(([methodName, args]) => { + return (expression: Expression) => { + return { + type: 'methodCall', + methodName, + expression, + args, + }; + }; + }); + +const expr8Parser: Parser = seq( + expr9Parser, + alt<(expr: Expression) => Expression>( + indexParser, + sliceParser, + entryParser, + funcrefCallParser, + methodCallParser, + ).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 00000000000..daaaa5f373d --- /dev/null +++ b/src/vimscript/expression/types.ts @@ -0,0 +1,178 @@ +// -------------------- 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; + body?: (args: Value[]) => Value; + arglist?: ListValue; + dict?: DictionaryValue; +}; + +export type BlobValue = { + type: 'blob'; + data: ArrayBuffer; +}; + +export type Value = + | NumberValue + | FloatValue + | StringValue + | ListValue + | DictionaryValue + | FuncRefValue + | BlobValue; + +// -------------------- 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 MethodCallExpression = { + type: 'methodCall'; + expression: Expression; + methodName: string; + 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 + | MethodCallExpression + | EnvVariableExpression + | RegisterExpression + | FunctionCallExpression + | ComparisonExpression + | BinaryExpression + | UnaryExpression + | TernaryExpression; diff --git a/src/vimscript/parserUtils.ts b/src/vimscript/parserUtils.ts index 56fd01f0c18..cdcde395365 100644 --- a/src/vimscript/parserUtils.ts +++ b/src/vimscript/parserUtils.ts @@ -1,7 +1,10 @@ // eslint-disable-next-line id-denylist -import { alt, any, Parser, regexp, seq, string, succeed, whitespace } from 'parsimmon'; +import { alt, any, noneOf, Parser, regexp, seq, string, succeed, whitespace } from 'parsimmon'; +import { configuration } from '../configuration/configuration'; -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 integerParser: Parser = regexp(/-?\d+/).map((num) => Number.parseInt(num, 10)); export const bangParser: Parser = string('!') @@ -87,3 +90,34 @@ export const fileCmdParser: Parser = string('+') ) .fallback(undefined) .desc('[+cmd]'); + +// TODO: re-create parser when leader changes +const leaderParser = regexp(//).map(() => configuration.leader); // lazy evaluation of configuration.leader +const specialCharacters = regexp(/<(?:Esc|C-\w|A-\w|C-A-\w)>/); + +const specialCharacterParser = alt(specialCharacters, leaderParser); + +// TODO: Add more special characters +const escapedParser = string('\\') + // eslint-disable-next-line id-denylist + .then(any.fallback(undefined)) + .map((escaped) => { + if (escaped === undefined) { + return '\\\\'; + } else if (escaped === 'n') { + return '\n'; + } + return '\\' + escaped; + }); + +export const keystrokesExpressionParser: Parser = alt( + escapedParser, + specialCharacterParser, + noneOf('"'), +).many(); + +export const keystrokesExpressionForMacroParser: Parser = alt( + escapedParser, + specialCharacterParser, + noneOf('"'), +).many(); diff --git a/syntaxes/vimscript.tmLanguage.json b/syntaxes/vimscript.tmLanguage.json index 2524d09d9e1..babe6aebffb 100644 --- a/syntaxes/vimscript.tmLanguage.json +++ b/syntaxes/vimscript.tmLanguage.json @@ -8,6 +8,10 @@ "name": "comment.line", "match": "(^| |\t)\".*$" }, + { + "name": "entity.name.function", + "match": "^( |\t)*(let|const|eval|call)" + }, { "name": "entity.name.function", "match": "^( |\t)*(map|nmap|vmap|smap|xmap|omap|map!|imap|lmap|cmap)" @@ -24,6 +28,10 @@ "name": "entity.name.function", "match": "^( |\t)*set" }, + { + "name": "constant.numeric", + "match": "\\d+(\\.\\d+)?" + }, { "name": "constant", "match": "(?i)" diff --git a/test/cmd_line/put.test.ts b/test/cmd_line/put.test.ts index 6484ac6b86a..71d36c0ddcf 100644 --- a/test/cmd_line/put.test.ts +++ b/test/cmd_line/put.test.ts @@ -137,4 +137,12 @@ suite('put cmd_line', () => { await modeHandler.handleMultipleKeyEvents(':put=range(4,1,-2)\n'.split('')); assertEqualLines(['', '4', '2']); }); + + test('`:put=` repeats last expression', async () => { + Register.put(modeHandler.vimState, ''); + await modeHandler.handleMultipleKeyEvents(':put=[1,2,3]\n'.split('')); + assertEqualLines(['', '1', '2', '3']); + await modeHandler.handleMultipleKeyEvents(':put=\n'.split('')); + assertEqualLines(['', '1', '2', '3', '1', '2', '3']); + }); }); diff --git a/test/vimscript/exCommandParse.test.ts b/test/vimscript/exCommandParse.test.ts index 06b9e1919b4..18721b4fc6d 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 { add, int, str, variable, funcCall, list } 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'; @@ -323,6 +325,54 @@ 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), + lock: false, + }), + ); + exParseTest( + ':let foo += 5', + new LetCommand({ + operation: '+=', + variable: variable('foo'), + expression: int(5), + lock: false, + }), + ); + exParseTest( + ':let foo -= 5', + new LetCommand({ + operation: '-=', + variable: variable('foo'), + expression: int(5), + lock: false, + }), + ); + exParseTest( + ":let foo .= 'bar'", + new LetCommand({ + operation: '.=', + variable: variable('foo'), + expression: str('bar'), + lock: false, + }), + ); + + exParseTest( + ':const foo = 5', + new LetCommand({ operation: '=', variable: variable('foo'), expression: int(5), lock: true }), + ); + // TODO }); @@ -349,6 +399,18 @@ suite('Ex command parsing', () => { // No space, alpha register exParseFails(':putx'); exParseTest(':put!x', new PutExCommand({ bang: true, register: 'x' })); + + // Expression register + exParseTest(':put=', new PutExCommand({ bang: false, register: '=' })); + exParseTest(':put=5+2', new PutExCommand({ bang: false, fromExpression: add(int(5), int(2)) })); + exParseTest( + ':put = range(4)', + new PutExCommand({ bang: false, fromExpression: funcCall('range', [int(4)]) }), + ); + exParseTest( + ':put!=[1,2,3]', + new PutExCommand({ bang: true, fromExpression: list([int(1), int(2), int(3)]) }), + ); }); suite(':q[uit] and :qa[ll]', () => { diff --git a/test/vimscript/expression.test.ts b/test/vimscript/expression.test.ts new file mode 100644 index 00000000000..7c008303437 --- /dev/null +++ b/test/vimscript/expression.test.ts @@ -0,0 +1,825 @@ +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('Vimscript expressions', () => { + suite('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) }); + exprTest('-5.3', { expr: negative(float(5.3)), value: float(-5.3) }); + + exprTest('1.23e5', { expr: float(123000) }); + exprTest('-4.56E-3', { expr: negative(float(0.00456)) }); + exprTest('0.424e0', { expr: float(0.424) }); + + // By default, 6 decimal places when displayed (:help floating-point-precision) + exprTest('0.123456789', { expr: float(0.123456789), display: '0.123457' }); + }); + + 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('Blobs', () => { + exprTest('0zabcd', { + expr: { + type: 'blob', + data: new Uint8Array([171, 205]), + }, + }); + exprTest('0ZABCD', { + expr: { + type: 'blob', + data: new Uint8Array([171, 205]), + }, + }); + exprTest('0zabc', { + error: ErrorCode.BlobLiteralShouldHaveAnEvenNumberOfHexCharacters, + }); + }); + + 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), + }, + value: str('x'), + }); + + exprTest("'xyz'[1]", { + value: str('y'), + }); + + exprTest("'xyz'[0][1]", { + expr: { + type: 'index', + expression: { + type: 'index', + expression: str('xyz'), + index: int(0), + }, + index: int(1), + }, + }); + + exprTest("['a','b','c'][1]", { value: str('b') }); + + exprTest("#{one: 1, two: 2, three: 3}['one']", { value: int(1) }); + exprTest("#{one: 1, two: 2, three: 3}['two']", { value: int(2) }); + exprTest("#{one: 1, two: 2, three: 3}['three']", { value: int(3) }); + exprTest("#{one: 1, two: 2, three: 3}['four']", { + error: ErrorCode.KeyNotPresentInDictionary, + }); + + exprTest('0zABCD[0]', { value: int(171) }); + exprTest('0zABCD[1]', { value: int(205) }); + // TODO: Blob, negative index + }); + + suite('Entry', () => { + exprTest('#{one: 1, two: 2, three: 3}.one', { value: int(1) }); + exprTest('#{one: 1, two: 2, three: 3}.two', { value: int(2) }); + exprTest('#{one: 1, two: 2, three: 3}.three', { value: int(3) }); + exprTest('#{one: 1, two: 2, three: 3}.four', { error: ErrorCode.KeyNotPresentInDictionary }); + }); + + 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('Blob', () => { + exprTest('0zDEADBEEF[1:2]', { + display: '0zADBE', + }); + }); + }); + + 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), + }); + + exprTest('4/5', { value: int(0) }); + exprTest('4/5.0', { display: '0.8' }); + exprTest('4.0/5', { display: '0.8' }); + }); + + 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('Method calls', () => { + exprTest('[1,2,3]->reverse()', { + value: list([int(3), int(2), int(1)]), + }); + // exprTest('[1,2,3,4,5,6]->filter({x->x%2==0})->map({x->x*10})', { + // value: list([int(20), int(40), int(60)]), + // }); + exprTest('[1,2,3]->map({k,v->v*10})->join("|")', { + value: str('10|20|30'), + }); + }); + + 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('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('0 is {}', { value: bool(false) }); + exprTest('[4] == ["4"]', { value: bool(false) }); + }); + }); + + suite('Conversions', () => { + suite('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('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('Builtin functions', () => { + suite('assert_*', () => { + exprTest('assert_equal(1, 1)', { value: int(0) }); + exprTest('assert_equal(1, 2)', { value: int(1) }); + }); + + suite('count', () => { + exprTest('add([1,2,3], 4)', { display: '[1, 2, 3, 4]' }); + exprTest('add(add(add([], 1), 2), 3)', { display: '[1, 2, 3]' }); + }); + + suite('count', () => { + exprTest('count([1,2,3,2,3,2,1], 2)', { value: int(3) }); + exprTest('count(["apple", "banana", "Apple", "carrot", "APPLE"], "Apple")', { + value: int(1), + }); + exprTest('count(["apple", "banana", "Apple", "carrot", "APPLE"], "Apple", v:true)', { + value: int(3), + }); + exprTest('count(["apple", "banana", "Apple", "carrot", "APPLE"], "Apple", v:true, 2)', { + value: int(2), + }); + exprTest('count(["apple", "banana", "Apple", "carrot", "APPLE"], "Apple", v:true, -1)', { + value: int(1), + }); + + exprTest('count(#{a:3,b:2,c:3}, 3)', { value: int(2) }); + exprTest('count(#{apple:"apple",b:"banana",c:"APPLE"}, "apple")', { value: int(1) }); + exprTest('count(#{apple:"apple",b:"banana",c:"APPLE"}, "apple", v:true)', { value: int(2) }); + }); + + 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', () => { + 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('float2nr', () => { + exprTest('float2nr(123)', { value: int(123) }); + exprTest('float2nr(40.0)', { value: int(40) }); + exprTest('float2nr(65.7)', { value: int(65) }); + exprTest('float2nr(-20.7)', { value: int(-20) }); + }); + + suite('fmod', () => { + exprTest('fmod(11, 3)', { value: float(2.0) }); + exprTest('fmod(4.2, 1.0)', { display: '0.2' }); + exprTest('fmod(4.2, -1.0)', { display: '0.2' }); + exprTest('fmod(-4.2, 1.0)', { display: '-0.2' }); + exprTest('fmod(-4.2, -1.0)', { display: '-0.2' }); + }); + + suite('get', () => { + exprTest('get([2,4,6], 1)', { value: int(4) }); + exprTest('get([2,4,6], -1)', { value: int(6) }); + exprTest('get([2,4,6], 3)', { value: int(0) }); + exprTest('get([2,4,6], 3, 999)', { value: int(999) }); + + exprTest('get(0zABCDEF, 1)', { value: int(205) }); + exprTest('get(0zABCDEF, -1)', { value: int(239) }); + exprTest('get(0zABCDEF, 3)', { value: int(-1) }); + exprTest('get(0zABCDEF, 3, 999)', { value: int(999) }); + + exprTest('get(#{a: 1, b: 2, c: 3}, "b")', { value: int(2) }); + exprTest('get(#{a: 1, b: 2, c: 3}, "x")', { value: int(0) }); + exprTest('get(#{a: 1, b: 2, c: 3}, "x", 999)', { value: int(999) }); + }); + + suite('has_key', () => { + exprTest('has_key(#{a:1, b:2, c:3}, "b")', { value: bool(true) }); + exprTest('has_key(#{a:1, b:2, c:3}, "d")', { value: bool(false) }); + }); + + suite('index', () => { + exprTest('index(["a","b","c"], "c")', { value: int(2) }); + exprTest('index(["a","b","c"], "k")', { value: int(-1) }); + exprTest('index(["A","C","D","C"], "C", 1)', { value: int(1) }); + exprTest('index(["A","C","D","C"], "C", 2)', { value: int(3) }); + exprTest('index(["A","C","D","C"], "C", -2)', { value: int(3) }); + exprTest('index(["A","C","D","C"], "C", 5)', { value: int(-1) }); + }); + + suite('isnan/isinf', () => { + exprTest('isnan(2.0 / 3.0)', { value: bool(false) }); + exprTest('isnan(0.0 / 0.0)', { value: bool(true) }); + + exprTest('isinf(2.0 / 3.0)', { value: int(0) }); + exprTest('isinf(1.0 / 0.0)', { value: int(1) }); + exprTest('isinf(-1.0 / 0.0)', { value: int(-1) }); + }); + + suite('join', () => { + exprTest('join([1,2,3])', { value: str('123') }); + exprTest('join([1,2,3], ",")', { value: str('1,2,3') }); + }); + + suite('len', () => { + exprTest('len(12345)', { value: int(5) }); + exprTest('len(012345)', { value: int(4) }); + exprTest('len(-8)', { value: int(2) }); + exprTest('len("hello world!")', { value: int(12) }); + exprTest('len([5, 2, 3, 7])', { value: int(4) }); + exprTest('len(#{a:1, b:2, c:3})', { value: int(3) }); + exprTest('len(function("abs"))', { error: ErrorCode.InvalidTypeForLen }); + }); + + 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)]), + }); + + exprTest('map([10, 20, 30], {k -> 2 * k})', { + value: list([int(0), int(2), int(4)]), + }); + exprTest('map([10, 20, 30], {k, v -> k + v})', { + value: list([int(10), int(21), int(32)]), + }); + + // TODO: map() with builtin Funcref + }); + + suite('max', () => { + exprTest('max([])', { value: int(0) }); + exprTest('max({})', { value: int(0) }); + exprTest('max([4, 3, 1, 5, 2])', { value: int(5) }); + exprTest('max(#{ten:10,twenty:20,thirty:30})', { value: int(30) }); + exprTest('max([1.2, 1.5])', { error: ErrorCode.UsingAFloatAsANumber }); + exprTest("max('1,2,3')", { error: ErrorCode.ArgumentOfMaxMustBeAListOrDictionary }); + }); + suite('min', () => { + exprTest('min([])', { value: int(0) }); + exprTest('min({})', { value: int(0) }); + exprTest('min([4, 3, 1, 5, 2])', { value: int(1) }); + exprTest('min(#{ten:10,twenty:20,thirty:30})', { value: int(10) }); + exprTest('min([1.2, 1.5])', { error: ErrorCode.UsingAFloatAsANumber }); + exprTest("min('1,2,3')", { error: ErrorCode.ArgumentOfMaxMustBeAListOrDictionary }); + }); + + suite('tolower', () => { + exprTest("tolower('Hello, World!')", { display: 'hello, world!' }); + exprTest('tolower(123)', { display: '123' }); + exprTest('tolower(1.23)', { error: ErrorCode.UsingFloatAsAString }); + }); + suite('toupper', () => { + exprTest("toupper('Hello, World!')", { display: 'HELLO, WORLD!' }); + exprTest('toupper(123)', { display: '123' }); + exprTest('toupper(1.23)', { error: ErrorCode.UsingFloatAsAString }); + }); + + suite('range', () => { + 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(2, -2, -2)', { display: '[2, 0, -2]' }); + exprTest('range(0)', { display: '[]' }); + exprTest('range(1, 10, 0)', { error: ErrorCode.StrideIsZero }); + exprTest('range(2, 0)', { error: ErrorCode.StartPastEnd }); + exprTest('range(0, 2, -1)', { error: ErrorCode.StartPastEnd }); + }); + + // TODO: remove() + + suite('repeat', () => { + 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('reverse', () => { + exprTest('reverse([1, 2, 3])', { display: '[3, 2, 1]' }); + exprTest('reverse(0zABCDEF)', { display: '0zEFCDAB' }); + }); + + suite('str2list', () => { + exprTest('str2list("ABC")', { value: list([int(65), int(66), int(67)]) }); + exprTest('str2list("á")', { value: list([int(97), int(769)]) }); + }); + + suite('string', () => { + 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('strlen', () => { + exprTest('strlen("")', { value: int(0) }); + exprTest('strlen("654321")', { value: int(6) }); + exprTest('strlen(654321)', { value: int(6) }); + exprTest('strlen([1,2,3])', { error: ErrorCode.UsingListAsAString }); + }); + + suite('split', () => { + exprTest('split(" a\t\tb c ")', { value: list([str('a'), str('b'), str('c')]) }); + exprTest('split(" a\t\tb c ", "", 1)', { + value: list([str(''), str('a'), str('b'), str('c'), str('')]), + }); + exprTest('split("a,b,c,", ",")', { value: list([str('a'), str('b'), str('c')]) }); + exprTest('split("a,b,c,", ",", v:true)', { + value: list([str('a'), str('b'), str('c'), str('')]), + }); + }); + + suite('trim', () => { + exprTest("trim(' me ')", { value: str('me') }); + exprTest("trim(' me ', ' ', 0)", { value: str('me') }); + exprTest("trim(' me ', ' ', 1)", { value: str('me ') }); + exprTest("trim(' me ', ' ', 2)", { value: str(' me') }); + // TODO: Test mask + }); + + suite('uniq', () => { + // exprTest("uniq([1,2,1,1,1,'1',3,2,2,3])", { display: "[1, 2, 1, '1', 3, 2, 3]" }); + // TODO + }); + + 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']", + }); + + for (const func of ["'i'", "'1'", '1']) { + exprTest(`sort(['A', 'c', 'B', 'a', 'C', 'b'], ${func})`, { + display: "['A', 'a', 'B', 'b', 'c', 'C']", + }); + } + + exprTest('sort([4,2,1,3,5])', { display: '[1, 2, 3, 4, 5]' }); + exprTest('sort([4,2,1,3,5], {x,y->x-y})', { display: '[1, 2, 3, 4, 5]' }); + exprTest('sort([4,2,1,3,5], {x,y->y-x})', { display: '[5, 4, 3, 2, 1]' }); + // TODO + }); + }); +});