diff --git a/deno.jsonc b/deno.jsonc index d3e6c890..60a0d5a5 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -8,6 +8,11 @@ "./batch": "./batch/mod.ts", "./buffer": "./buffer/mod.ts", "./bufname": "./bufname/mod.ts", + "./eval": "./eval/mod.ts", + "./eval/expression": "./eval/expression.ts", + "./eval/stringify": "./eval/stringify.ts", + "./eval/string": "./eval/string.ts", + "./eval/use-eval": "./eval/use_eval.ts", "./function": "./function/mod.ts", "./function/nvim": "./function/nvim/mod.ts", "./function/vim": "./function/vim/mod.ts", @@ -41,12 +46,13 @@ ] }, "imports": { - "@core/unknownutil": "jsr:@core/unknownutil@^4.0.0", + "@core/unknownutil": "jsr:@core/unknownutil@^4.3.0", "@denops/core": "jsr:@denops/core@^7.0.0", "@denops/test": "jsr:@denops/test@^3.0.1", "@lambdalisue/errorutil": "jsr:@lambdalisue/errorutil@^1.0.0", "@lambdalisue/itertools": "jsr:@lambdalisue/itertools@^1.1.2", "@lambdalisue/unreachable": "jsr:@lambdalisue/unreachable@^1.0.1", + "@nick/dispose": "jsr:@nick/dispose@^1.1.0", "@std/assert": "jsr:@std/assert@^1.0.0", "@std/fs": "jsr:@std/fs@^1.0.0", "@std/path": "jsr:@std/path@^1.0.2", @@ -61,6 +67,11 @@ "jsr:@denops/std/batch": "./batch/mod.ts", "jsr:@denops/std/buffer": "./buffer/mod.ts", "jsr:@denops/std/bufname": "./bufname/mod.ts", + "jsr:@denops/std/eval": "./eval/mod.ts", + "jsr:@denops/std/eval/expression": "./eval/expression.ts", + "jsr:@denops/std/eval/stringify": "./eval/stringify.ts", + "jsr:@denops/std/eval/string": "./eval/string.ts", + "jsr:@denops/std/eval/use-eval": "./eval/use_eval.ts", "jsr:@denops/std/function": "./function/mod.ts", "jsr:@denops/std/function/nvim": "./function/nvim/mod.ts", "jsr:@denops/std/function/vim": "./function/vim/mod.ts", diff --git a/eval/expression.ts b/eval/expression.ts new file mode 100644 index 00000000..d426d81b --- /dev/null +++ b/eval/expression.ts @@ -0,0 +1,120 @@ +/** + * This module provides utilities for creating Vim expressions in TypeScript. + * + * @module + */ + +import type { Predicate } from "@core/unknownutil/type"; +import { isIntersectionOf } from "@core/unknownutil/is/intersection-of"; +import { isLiteralOf } from "@core/unknownutil/is/literal-of"; +import { isObjectOf } from "@core/unknownutil/is/object-of"; +import { + isVimEvaluatable, + type VimEvaluatable, + vimExpressionOf, +} from "./vim_evaluatable.ts"; +import { stringify } from "./stringify.ts"; + +/** + * An object that defines a Vim's expression. + * + * Note that although it inherits from primitive `string` for convenience, it + * actually inherits from the `String` wrapper object. + * + * ```typescript + * import { assertEquals } from "jsr:@std/assert/equals"; + * import { expr } from "jsr:@denops/std/eval/expression"; + * + * const s: string = expr`foo`; + * assertEquals(typeof s, "object"); // is not "string" + * ``` + */ +export type Expression = string & ExpressionProps; + +interface ExpressionProps extends VimEvaluatable { + /** + * Used by the `JSON.stringify` to enable the transformation of an object's data to JSON serialization. + */ + toJSON(): string; + + readonly [Symbol.toStringTag]: "Expression"; +} + +/** + * Create a {@linkcode Expression} that evaluates as a Vim expression. + * + * `raw_vim_expression` is a string text representing a raw Vim expression. + * Backslashes are treated as Vim syntax rather than JavaScript string escape + * sequences. Note that the raw Vim expression has not been verified and is + * therefore **UNSAFE**. + * + * `${value}` is the expression to be inserted at the current position, whose + * value is converted to the corresponding Vim expression. The conversion is + * safe and an error is thrown if it cannot be converted. Note that if the + * value contains `Expression` it will be inserted as-is, this is **UNSAFE**. + * + * ```typescript + * import { assertEquals } from "jsr:@std/assert/equals"; + * import { expr } from "jsr:@denops/std/eval/expression"; + * + * assertEquals( + * expr`raw_vim_expression`.toString(), + * "raw_vim_expression", + * ); + * + * const value = ["foo", 123, null]; + * assertEquals( + * expr`raw_vim_expression + ${value}`.toString(), + * "raw_vim_expression + ['foo',123,v:null]" + * ); + * ``` + */ +export function expr( + template: TemplateStringsArray, + ...substitutions: TemplateSubstitutions +): Expression { + const values = substitutions.map(stringify); + const raw = String.raw(template, ...values); + return new ExpressionImpl(raw) as unknown as Expression; +} + +/** + * Returns `true` if the value is a {@linkcode Expression}. + * + * ```typescript + * import { assert, assertFalse } from "jsr:@std/assert"; + * import { isExpression, expr } from "jsr:@denops/std/eval/expression"; + * + * assert(isExpression(expr`123`)); + * + * assertFalse(isExpression("456")); + * ``` + */ +export const isExpression: Predicate = isIntersectionOf([ + // NOTE: Do not check `isString` here, because `Expression` has a different type in definition (primitive `string`) and implementation (`String`). + isObjectOf({ + [Symbol.toStringTag]: isLiteralOf("Expression"), + }), + isVimEvaluatable, +]) as unknown as Predicate; + +// deno-lint-ignore no-explicit-any +type TemplateSubstitutions = any[]; + +class ExpressionImpl extends String implements ExpressionProps { + get [Symbol.toStringTag]() { + return "Expression" as const; + } + + [vimExpressionOf](): string { + return this.valueOf(); + } + + toJSON(): string { + return this[vimExpressionOf](); + } + + [Symbol.for("Deno.customInspect")]() { + return `[${this[Symbol.toStringTag]}: ${Deno.inspect(this.valueOf())}]`; + } +} diff --git a/eval/expression_test.ts b/eval/expression_test.ts new file mode 100644 index 00000000..20ee6284 --- /dev/null +++ b/eval/expression_test.ts @@ -0,0 +1,73 @@ +import { assertEquals } from "@std/assert"; +import { test } from "@denops/test"; +import { vimExpressionOf } from "./vim_evaluatable.ts"; +import { rawString } from "./string.ts"; + +import { expr, isExpression } from "./expression.ts"; + +Deno.test("expr()", async (t) => { + await t.step("returns an `Expression`", () => { + const actual = expr`foo`; + assertEquals(actual[Symbol.toStringTag], "Expression"); + }); +}); + +test({ + mode: "all", + name: "Expression", + fn: async (denops, t) => { + const expression = expr`[42 + ${123}, ${"foo"}, ${null}]`; + await t.step(".@@vimExpressionOf() returns a string", async (t) => { + const actual = expression[vimExpressionOf](); + assertEquals(typeof actual, "string"); + + await t.step("that evaluates as a Vim expression", async () => { + assertEquals(await denops.eval(actual), [165, "foo", null]); + }); + }); + await t.step(".toJSON() returns same as @@vimExpressionOf()", () => { + const actual = expression.toJSON(); + const expected = expression[vimExpressionOf](); + assertEquals(actual, expected); + }); + await t.step(".toString() returns same as @@vimExpressionOf()", () => { + const actual = expression.toString(); + const expected = expression[vimExpressionOf](); + assertEquals(actual, expected); + }); + await t.step('is inspected as `[Expression: "..."]`', () => { + assertEquals( + Deno.inspect(expression), + `[Expression: "[42 + 123, 'foo', v:null]"]`, + ); + }); + }, +}); + +Deno.test("isExpression()", async (t) => { + await t.step("returns true if the value is Expression", () => { + const actual = isExpression(expr`foo`); + assertEquals(actual, true); + }); + await t.step("returns false if the value is", async (t) => { + const tests: readonly [name: string, value: unknown][] = [ + ["string", "foo"], + ["number", 123], + ["undefined", undefined], + ["null", null], + ["true", true], + ["false", false], + ["Function", () => 0], + ["symbol", Symbol("bar")], + ["Array", [0, 1]], + ["Object", { key: "a" }], + ["RawString", rawString`baz`], + ]; + for (const [name, value] of tests) { + await t.step(name, () => { + const actual = isExpression(value); + assertEquals(actual, false); + }); + } + }); +}); diff --git a/eval/mod.ts b/eval/mod.ts new file mode 100644 index 00000000..72dbd3a3 --- /dev/null +++ b/eval/mod.ts @@ -0,0 +1,38 @@ +/** + * A module to provide values that can be evaluated in Vim. + * + * ```typescript + * import type { Denops } from "jsr:@denops/std"; + * import * as fn from "jsr:@denops/std/function"; + * import type { Expression, RawString } from "jsr:@denops/std/eval"; + * import { expr, rawString, stringify, useEval } from "jsr:@denops/std/eval"; + * + * export async function main(denops: Denops): Promise { + * // Create `Expression` with `expr`. + * const vimExpression: Expression = expr`expand('')`; + * + * // Create `RawString` with `rawString`. + * const vimKeySequence: RawString = rawString`\echo 'foo'\`; + * + * // Use values in `useEval` block. + * await useEval(denops, async (denops) => { + * await denops.cmd('echo expr', { expr: vimExpression }); + * await fn.feedkeys(denops, vimKeySequence); + * }); + * + * // Convert values to a string that can be parsed Vim's `eval()`. + * const vimEvaluable: string = stringify({ + * expr: vimExpression, + * keys: vimKeySequence, + * }); + * await denops.cmd('echo eval(value)', { value: vimEvaluable }); + * } + * ``` + * + * @module + */ + +export * from "./expression.ts"; +export * from "./string.ts"; +export * from "./stringify.ts"; +export * from "./use_eval.ts"; diff --git a/eval/string.ts b/eval/string.ts new file mode 100644 index 00000000..66b6393a --- /dev/null +++ b/eval/string.ts @@ -0,0 +1,158 @@ +/** + * This module provides utilities for creating Vim string in TypeScript. + * + * @module + */ + +import type { Predicate } from "@core/unknownutil/type"; +import { isIntersectionOf } from "@core/unknownutil/is/intersection-of"; +import { isLiteralOf } from "@core/unknownutil/is/literal-of"; +import { isObjectOf } from "@core/unknownutil/is/object-of"; +import { + isVimEvaluatable, + type VimEvaluatable, + vimExpressionOf, +} from "./vim_evaluatable.ts"; + +/** + * An object that defines a Vim's string constant. + * It represents a double-quoted Vim string. + * + * `RawString` is different when it is treated as a JavaScript string and when + * it is serialized. If specified to {@linkcode JSON.stringify} it will become + * a Vim string constant. + * + * Note that although it inherits from primitive `string` for convenience, it + * actually inherits from the `String` wrapper object. + * + * ```typescript + * import { assertEquals } from "jsr:@std/assert/equals"; + * import { rawString } from "jsr:@denops/std/eval/string"; + * + * const s: string = rawString`foo`; + * assertEquals(s.toString(), "foo"); + * assertEquals(JSON.stringify(s), '"\\"foo\\""'); + * assertEquals(typeof s, "object"); // is not "string" + * ``` + */ +export type RawString = string & RawStringProps; + +interface RawStringProps extends VimEvaluatable { + /** + * Used by the `JSON.stringify` to enable the transformation of an object's data to JSON serialization. + */ + toJSON(): string; + + readonly [Symbol.toStringTag]: "RawString"; +} + +/** + * Create a {@linkcode RawString} that evaluates as a Vim string constant. + * + * A string constant accepts these special characters: + * + * \000 three-digit octal number (e.g., "\316") + * \00 two-digit octal number (must be followed by non-digit) + * \0 one-digit octal number (must be followed by non-digit) + * \x.. byte specified with two hex numbers (e.g., "\x1f") + * \x. byte specified with one hex number (must be followed by non-hex char) + * \X.. same as \x.. + * \X. same as \x. + * \u.... character specified with up to 4 hex numbers, stored according to the + * current value of 'encoding' (e.g., "\u02a4") + * \U.... same as \u but allows up to 8 hex numbers. + * \b backspace + * \e escape + * \f formfeed 0x0C + * \n newline + * \r return + * \t tab + * \\ backslash + * \" double quote + * \` back quote + * \${ liteal string '${' + * \ Special key named "xxx". e.g. "\" for CTRL-W. This is for use + * in mappings, the 0x80 byte is escaped. + * To use the double quote character it must be escaped: "". + * Don't use to get a UTF-8 character, use \uxxxx as + * mentioned above. + * \<*xxx> Like \ but prepends a modifier instead of including it in the + * character. E.g. "\" is one character 0x17 while "\<*C-w>" is four + * bytes: 3 for the CTRL modifier and then character "W". + * + * ```typescript + * import { assertEquals } from "jsr:@std/assert/equals"; + * import { rawString } from "jsr:@denops/std/eval/string"; + * + * assertEquals( + * rawString`foo`.toString(), + * "foo", + * ); + * + * assertEquals( + * rawString`foo,${123},${"bar"}`.toString(), + * "foo,123,bar", + * ); + * + * assertEquals( + * rawString`\`.toString(), + * "\\", + * ); + * ``` + * + * @see useEval for usage + */ +export function rawString( + template: TemplateStringsArray, + ...substitutions: TemplateSubstitutions +): RawString { + const raw = String.raw(template, ...substitutions); + return new RawStringImpl(raw) as unknown as RawString; +} + +/** + * Returns `true` if the value is a {@linkcode RawString}. + * + * ```typescript + * import { isRawString, rawString } from "jsr:@denops/std/eval/string"; + * + * isRawString(rawString`foo`); + * // true + * + * isRawString("foo"); + * // false + * ``` + */ +export const isRawString: Predicate = isIntersectionOf([ + // NOTE: Do not check `isString` here, because `RawString` has a different type in definition (primitive `string`) and implementation (`String`). + isObjectOf({ + [Symbol.toStringTag]: isLiteralOf("RawString"), + }), + isVimEvaluatable, +]) as unknown as Predicate; + +// deno-lint-ignore no-explicit-any +type TemplateSubstitutions = any[]; + +class RawStringImpl extends String implements RawStringProps { + get [Symbol.toStringTag]() { + return "RawString" as const; + } + + #cached?: string; + + [vimExpressionOf](): string { + this.#cached ??= `"${ + this.valueOf().replaceAll(/\\.|(")/g, (m, q: string) => q ? `\\${q}` : m) + }"`; + return this.#cached; + } + + toJSON(): string { + return this[vimExpressionOf](); + } + + [Symbol.for("Deno.customInspect")]() { + return `[${this[Symbol.toStringTag]}: ${Deno.inspect(this.valueOf())}]`; + } +} diff --git a/eval/string_test.ts b/eval/string_test.ts new file mode 100644 index 00000000..20d47f55 --- /dev/null +++ b/eval/string_test.ts @@ -0,0 +1,72 @@ +import { assertEquals } from "@std/assert"; +import { test } from "@denops/test"; +import { vimExpressionOf } from "./vim_evaluatable.ts"; +import { expr } from "./expression.ts"; + +import { isRawString, rawString } from "./string.ts"; + +Deno.test("rawString()", async (t) => { + await t.step("returns a `RawString`", () => { + const actual = rawString`foo`; + assertEquals(actual[Symbol.toStringTag], "RawString"); + }); +}); + +test({ + mode: "all", + name: "RawString", + fn: async (denops, t) => { + const raw = rawString`foo,${123},${"foo"},${null}`; + await t.step(".@@vimExpressionOf() returns a string", async (t) => { + const actual = raw[vimExpressionOf](); + assertEquals(typeof actual, "string"); + + await t.step("that evaluates as a Vim string constant", async () => { + assertEquals( + await denops.eval(actual), + "foo,123,foo,null", + ); + }); + }); + await t.step(".toJSON() returns same as @@vimExpressionOf()", () => { + const actual = raw.toJSON(); + const expected = raw[vimExpressionOf](); + assertEquals(actual, expected); + }); + await t.step(".toString() returns a JavaScript string", () => { + const actual = raw.toString(); + assertEquals(actual, "foo,123,foo,null"); + }); + await t.step('is inspected as `[RawString: "..."]`', () => { + assertEquals(Deno.inspect(raw), `[RawString: "foo,123,foo,null"]`); + }); + }, +}); + +Deno.test("isRawString()", async (t) => { + await t.step("returns true if the value is RawString", () => { + const actual = isRawString(rawString`foo`); + assertEquals(actual, true); + }); + await t.step("returns false if the value is", async (t) => { + const tests: readonly [name: string, value: unknown][] = [ + ["string", "foo"], + ["number", 123], + ["undefined", undefined], + ["null", null], + ["true", true], + ["false", false], + ["Function", () => 0], + ["symbol", Symbol("bar")], + ["Array", [0, 1]], + ["Object", { key: "a" }], + ["Expression", expr`baz`], + ]; + for (const [name, value] of tests) { + await t.step(name, () => { + const actual = isRawString(value); + assertEquals(actual, false); + }); + } + }); +}); diff --git a/eval/stringify.ts b/eval/stringify.ts new file mode 100644 index 00000000..6d6a821b --- /dev/null +++ b/eval/stringify.ts @@ -0,0 +1,172 @@ +/** + * This module provides the function to serialize JavaScript values into Vim values. + * + * @module + */ + +import { isArray } from "@core/unknownutil/is/array"; +import { isBoolean } from "@core/unknownutil/is/boolean"; +import { isCustomJsonable } from "@core/unknownutil/is/custom-jsonable"; +import { isFunction } from "@core/unknownutil/is/function"; +import { isInstanceOf } from "@core/unknownutil/is/instance-of"; +import { isNullish } from "@core/unknownutil/is/nullish"; +import { isNumber } from "@core/unknownutil/is/number"; +import { isRecord } from "@core/unknownutil/is/record"; +import { isString } from "@core/unknownutil/is/string"; +import { isSymbol } from "@core/unknownutil/is/symbol"; +import { isUndefined } from "@core/unknownutil/is/undefined"; +import { isExprString } from "../helper/expr_string.ts"; +import { + isVimEvaluatable, + type VimEvaluatable, + vimExpressionOf, +} from "./vim_evaluatable.ts"; + +/** + * Converts a JavaScript value to a string that can be parsed with Vim `eval()`. + * + * JavaScript values and their conversion results: + * | `value` type | result | + * | ------------------ | ------------------------------- | + * | `string` | 'string' | + * | `number` | 123 or 123.123456 or 1.123456e8 | + * | `Infinity` | (1.0/0) | + * | `-Infinity` | (-1.0/0) | + * | `NaN` | (0.0/0) | + * | `bigint` | * throws `TypeError` * | + * | `undefined` | v:null | + * | `null` | v:null | + * | `Function` | v:null | + * | `Symbol` | v:null | + * | `Array` | [item,item] | + * | `Object` | {key:value,key:value} | + * | `ArrayBuffer` | 0z00112233445566778899 | + * | `Expression` | 42 + 123 | + * | `RawString` | "\echo 'hello'\" | + * + * - `Boolean`, `Number`, `String`, and `BigInt` (obtainable via `Object()`) objects are converted to the corresponding primitive values. `Symbol` objects (obtainable via `Object()`) are treated as plain objects. + * - `undefined`, `Function`, and `Symbol` values are either omitted (when found in an object) or changed to null (when found in an array or a single value). + * - `Infinity` and `NaN` numbers are converted to the corresponding Vim formula. Note that if they are included in the value returned from Vim, they will be converted to `null`. + * - The special {@linkcode [eval].Expression|Expression} or {@linkcode [eval].RawString|RawString} objects are serialized to Vim's expression. + * - If `Object` has a `toJSON()` method, it will be invoked and the result will be serialized again. But if the return value of `toJSON()` has a `toJSON()` method, it will **NOT** be invoked again. + * + * When a `Array` or `Object` has a circular reference, a `TypeError` will be thrown. + * + * ```typescript + * import type { Denops } from "jsr:@denops/std"; + * import { expr } from "jsr:@denops/std/eval/expression"; + * import { stringify } from "jsr:@denops/std/eval/stringify"; + * + * export async function main(denops: Denops): Promise { + * const value = { + * cword: expr`expand('')`, + * lines: ["foo", "bar"], + * }; + * await denops.cmd('echo eval(expr)', { expr: stringify(value) }); + * } + * ``` + * + * @throws {TypeError} If the value contains a circular reference or a `bigint`. + */ +export function stringify(value: unknown): string { + const ref = new WeakSet(); + const addRef = (value: unknown[] | Record) => { + if (ref.has(value)) { + throw new TypeError("Converting circular structure to Vim dict"); + } + ref.add(value); + }; + const reduce = (value: unknown, key: string | number): string => { + if (isVimEvaluatable(value)) { + return toVimExpression(value); + } + if (isExprString(value)) { + return `"${value.replaceAll('"', '\\"')}"`; + } + if (isCustomJsonable(value)) { + value = value.toJSON(key); + if (isVimEvaluatable(value)) { + return toVimExpression(value); + } + if (isExprString(value)) { + return `"${value.replaceAll('"', '\\"')}"`; + } + } + if (isNullish(value) || isFunction(value) || isSymbol(value)) { + return "v:null"; + } + if (isBoolean(value) || isInstanceOfBoolean(value)) { + // v:true or v:false + return `v:${value}`; + } + if (isNumber(value) || isInstanceOfNumber(value)) { + // Replace `5e-10` to `5.0e-10` + return `${value}`.replace(/^(\d+)e/, "$1.0e"); + } + if (isString(value) || isInstanceOfString(value)) { + // Vim literal-string + return `'${value.replaceAll("'", "''")}'`; + } + if (value instanceof ArrayBuffer) { + // Vim blob + return toBlobLiteral(value); + } + if (isArray(value)) { + // Vim list + addRef(value); + const res = `[${value.map(reduce).join(",")}]`; + ref.delete(value); + return res; + } + if (isRecord(value)) { + // Vim dict + addRef(value); + const res = `{${ + Object.entries(value) + .filter(([, value]) => !isIgnoreRecordValue(value)) + .map(([key, value]) => + `'${key.replaceAll("'", "''")}':${reduce(value, key)}` + ) + .join(",") + }}`; + ref.delete(value); + return res; + } + const type = Object.prototype.toString.call(value).slice(8, -1); + throw new TypeError(`${type} value can't be serialized`); + }; + return reduce(value, ""); +} + +function isIgnoreRecordValue(x: unknown): boolean { + return isUndefined(x) || isFunction(x) || isSymbol(x); +} + +const isInstanceOfBoolean = isInstanceOf(Boolean); +const isInstanceOfNumber = isInstanceOf(Number); +const isInstanceOfString = isInstanceOf(String); + +function toVimExpression(expr: VimEvaluatable): string { + const s = expr[vimExpressionOf](); + if (!isString(s)) { + throw new TypeError("@@vimExpressionOf() returns not a string"); + } + return s; +} + +/** Cache of hex strings: 00 01 ... fe ff */ +let hex: string[]; + +function toBlobLiteral(buffer: ArrayBuffer): string { + hex ??= Array.from( + { length: 256 }, + (_, i) => i.toString(16).padStart(2, "0"), + ); + const array = new Uint8Array(buffer); + const { length } = array; + let res = "0z"; + for (let i = 0; i < length; ++i) { + res += hex[array[i]]; + } + return res; +} diff --git a/eval/stringify_test.ts b/eval/stringify_test.ts new file mode 100644 index 00000000..03b98c86 --- /dev/null +++ b/eval/stringify_test.ts @@ -0,0 +1,340 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { assertSpyCallArgs, assertSpyCalls, spy } from "@std/testing/mock"; +import { test } from "@denops/test"; +import { expr } from "./expression.ts"; +import { rawString } from "./string.ts"; +import { type VimEvaluatable, vimExpressionOf } from "./vim_evaluatable.ts"; +import { exprQuote } from "../helper/expr_string.ts"; + +import { stringify } from "./stringify.ts"; + +Deno.test("stringify()", async (t) => { + await t.step("stringify undefined to `v:null`", () => { + const actual = stringify(undefined); + assertEquals(actual, "v:null"); + }); + await t.step("stringify null to `v:null`", () => { + const actual = stringify(null); + assertEquals(actual, "v:null"); + }); + await t.step("stringify Function to `v:null`", () => { + const actual = stringify(() => {}); + assertEquals(actual, "v:null"); + }); + await t.step("stringify Symbol to `v:null`", () => { + const actual = stringify(Symbol("foo")); + assertEquals(actual, "v:null"); + }); + await t.step("stringify true to `v:true`", () => { + const actual = stringify(true); + assertEquals(actual, "v:true"); + }); + await t.step("stringify false to `v:false`", () => { + const actual = stringify(false); + assertEquals(actual, "v:false"); + }); + await t.step("stringify Boolean object to `v:true` or `v:false`", () => { + const actualTrue = stringify(new Boolean(true)); + assertEquals(actualTrue, "v:true"); + const actualFalse = stringify(new Boolean(false)); + assertEquals(actualFalse, "v:false"); + }); + await t.step("stringify integer number to integer number", () => { + const actual = stringify(42); + assertEquals(actual, "42"); + }); + await t.step("stringify float number to float number", () => { + const actual = stringify(21.948); + assertEquals(actual, "21.948"); + }); + await t.step( + "stringify exponential number to Vim's exponential number", + () => { + const actual = stringify(5e-10); + assertEquals( + actual, + "5.0e-10", + "Vim's exponential number requires `.0`", + ); + }, + ); + await t.step("stringify Number object to number", () => { + const actualInteger = stringify(new Number(42)); + assertEquals(actualInteger, "42"); + const actualFloat = stringify(new Number(21.948)); + assertEquals(actualFloat, "21.948"); + const actualExponential = stringify(new Number(5e-10)); + assertEquals( + actualExponential, + "5.0e-10", + "Vim's exponential number requires `.0`", + ); + }); + await t.step("stringify string to Vim's literal-string", () => { + const actual = stringify("foo's bar"); + assertEquals(actual, "'foo''s bar'"); + }); + await t.step("stringify String object to Vim's literal-string", () => { + const actual = stringify(new String("foo's bar")); + assertEquals(actual, "'foo''s bar'"); + }); + await t.step("stringify Expression as-is", () => { + const actual = stringify( + expr`feedkeys("\call Foo(\"bar\")\")`, + ); + assertEquals(actual, 'feedkeys("\\call Foo(\\"bar\\")\\")'); + }); + await t.step("stringify RawString to Vim's expr-string", () => { + const actual = stringify(rawString`\call Foo("bar")\`); + assertEquals(actual, '"\\call Foo(\\"bar\\")\\"'); + }); + await t.step("stringify ExprString to Vim's expr-string", () => { + const actual = stringify(exprQuote`\call Foo("bar")\`); + assertEquals(actual, '"\\call Foo(\\"bar\\")\\"'); + }); + await t.step("stringify array to Vim's list", () => { + const actual = stringify(["foo", 42, null, undefined]); + assertEquals(actual, "['foo',42,v:null,v:null]"); + }); + await t.step("stringify object to Vim's dict", () => { + const actual = stringify({ + foo: "foo", + bar: 42, + // undefined value is omitted. + undefinedValue: undefined, + // null value is keeped. + nullValue: null, + // Function value is omitted. + functionValue: () => {}, + // Symbol key is omitted. + [Symbol("foo")]: "symbol key", + // Symbol value is omitted. + symbolValue: Symbol("foo"), + }); + assertEquals(actual, "{'foo':'foo','bar':42,'nullValue':v:null}"); + }); + await t.step('calls `toJSON` with key: ""', () => { + const x = { + toJSON: (_key?: string | number) => "foo", + }; + using s = spy(x, "toJSON"); + stringify(x); + assertSpyCalls(s, 1); + assertSpyCallArgs(s, 0, [""]); + }); + await t.step("calls `toJSON` with key: Array index", () => { + const y = { + toJSON: (_key?: string | number) => "foo", + }; + const x = [y, "bar", y]; + using s = spy(y, "toJSON"); + stringify(x); + assertSpyCalls(s, 2); + assertSpyCallArgs(s, 0, [0]); + assertSpyCallArgs(s, 1, [2]); + }); + await t.step("calls `toJSON` with key: Object key", () => { + const z = { + toJSON: (_key?: string | number) => "foo", + }; + const x = { a: z, y: { b: z } }; + using s = spy(z, "toJSON"); + stringify(x); + assertSpyCalls(s, 2); + assertSpyCallArgs(s, 0, ["a"]); + assertSpyCallArgs(s, 1, ["b"]); + }); + await t.step("does not call nested `toJSON`", () => { + const z = { + toJSON: (_key?: string | number) => "foo", + }; + const y = { + toJSON: (_key?: string | number) => z, + }; + const x = { a: y }; + using s1 = spy(y, "toJSON"); + using s2 = spy(z, "toJSON"); + stringify(x); + assertSpyCalls(s1, 1); + assertSpyCallArgs(s1, 0, ["a"]); + assertSpyCalls(s2, 0); + }); + await t.step("stringify Expression that returns from `toJSON`", () => { + const x = { + toJSON: () => expr`feedkeys("\call Foo(\"bar\")\")`, + }; + const actual = stringify(x); + assertEquals(actual, 'feedkeys("\\call Foo(\\"bar\\")\\")'); + }); + await t.step("stringify RawString that returns from `toJSON`", () => { + const x = { + toJSON: () => rawString`\call Foo("bar")\`, + }; + const actual = stringify(x); + assertEquals(actual, '"\\call Foo(\\"bar\\")\\"'); + }); + await t.step("stringify ExprString that returns from `toJSON`", () => { + const x = { + toJSON: () => exprQuote`\call Foo("bar")\`, + }; + const actual = stringify(x); + assertEquals(actual, '"\\call Foo(\\"bar\\")\\"'); + }); + await t.step("stringify object that has `toJSON` method", () => { + const actual = stringify({ + foo: 42, + toJSON: () => [123, "bar"], + }); + assertEquals(actual, "[123,'bar']"); + }); + await t.step("stringify function that has `toJSON` method", () => { + const actual = stringify(Object.assign( + () => {}, + { + toJSON: () => [123, "bar"], + }, + )); + assertEquals(actual, "[123,'bar']"); + }); + await t.step("stringify Date that has native `toJSON` method", () => { + // NOTE: `Date.prototype.toJSON` returns a string representing date in the same ISO format as `Date.prototype.toISOString()`. + const actual = stringify(new Date("2007-08-31T12:34:56.000Z")); + assertEquals(actual, "'2007-08-31T12:34:56.000Z'"); + }); + await t.step("stringify ArrayBuffer to Vim's blob", () => { + const typedArray = new Uint8Array( + [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], + ); + const actual = stringify(typedArray.buffer); + assertEquals(actual, "0z89504e470d0a1a0a"); + }); + await t.step("throws if value contains BigInt", () => { + assertThrows( + () => stringify(92382417n), + TypeError, + "BigInt value can't be serialized", + ); + }); + await t.step("stringify BigInt that has `toJSON` method", () => { + Object.assign(BigInt.prototype, { + toJSON(): string { + return this.toString(); + }, + }); + try { + const actual = stringify(92382417n); + assertEquals(actual, "'92382417'"); + } finally { + // deno-lint-ignore no-explicit-any + delete (BigInt.prototype as any).toJSON; + } + }); + await t.step("stringify complex object", () => { + const actual = stringify([ + null, + undefined, + 42, + true, + () => {}, + Symbol("foo"), + "bar", + { + toJSON: () => [123, "baz"], + }, + new Date("2007-08-31T12:34:56.000Z"), + { + k0: null, + k1: undefined, + k2: [ + { + [Symbol("foo")]: 123, + key: 234, + expr: expr`foo#bar()`, + rstr: rawString`\U0001F680`, + }, + ], + k3: new Uint8Array([0x01, 0xff, 0x00, 0x7f]).buffer, + }, + ]); + assertEquals( + actual, + `[v:null,v:null,42,v:true,v:null,v:null,'bar',[123,'baz'],'2007-08-31T12:34:56.000Z',{'k0':v:null,'k2':[{'key':234,'expr':foo#bar(),'rstr':"\\U0001F680"}],'k3':0z01ff007f}]`, + ); + }); + await t.step("throws if @@vimExpressionOf() returns not a string", () => { + const invalid: VimEvaluatable = { + [vimExpressionOf](): string { + return 123 as unknown as string; + }, + }; + const x = { invalid }; + assertThrows( + () => stringify(x), + TypeError, + "@@vimExpressionOf() returns not a string", + ); + }); + await t.step("throws if value contains circular structure array", () => { + const z: unknown[] = []; + const x = [[z]]; + z.push(x); + assertThrows( + () => stringify(x), + TypeError, + "Converting circular structure", + ); + }); + await t.step("throws if value contains circular structure record", () => { + const x = { y: { z: {} } }; + Object.assign(x.y.z, { x }); + assertThrows( + () => stringify(x), + TypeError, + "Converting circular structure", + ); + }); + await t.step("does not throw if value contains the same reference", () => { + const r = { v: "foo" }; + const x = { a: r, b: r, y: { c: r } }; + stringify(x); + }); +}); + +test({ + mode: "all", + name: "Vim evaluates the stringify() value of", + fn: async (denops, t) => { + const tests: readonly [name: string, input: unknown, expected: string][] = [ + ["undefined", undefined, "v:null"], + ["null", null, "v:null"], + ["Function", () => 0, "v:null"], + ["symbol", Symbol("foo"), "v:null"], + ["true", true, "v:true"], + ["false", false, "v:false"], + ["Boolean(1)", Boolean(1), "v:true"], + ["Boolean(0)", Boolean(0), "v:false"], + ["123", 123, "123"], + ["123.456", 123.456, "123.456"], + ["5e-10", 5e-10, "5.0e-10"], + ["Number(123)", Number(123), "123"], + ["Number(123.456)", Number(123.456), "123.456"], + ["Number(5e-10)", Number(5e-10), "5.0e-10"], + [`"foo's bar"`, "foo's bar", `"foo's bar"`], + [`String("foo's bar")`, String("foo's bar"), "'foo''s bar'"], + ["Expression", expr`42 + 123`, "165"], + ["RawString", rawString`\echo 1\`, '"\\echo 1\\"'], + ["Array", [123, "foo", undefined], "[123,'foo',v:null]"], + ["Object", { a: 123, b: "foo", c: undefined }, "{'a':123,'b':'foo'}"], + ]; + for (const [name, input, expected] of tests) { + await t.step(name, async () => { + await denops.cmd("let v:errors = []"); + const actual = stringify(input); + await denops.eval(`assert_equal(${expected}, eval(actual))`, { + actual, + }); + assertEquals(await denops.eval("v:errors"), []); + }); + } + }, +}); diff --git a/eval/use_eval.ts b/eval/use_eval.ts new file mode 100644 index 00000000..a15e35a0 --- /dev/null +++ b/eval/use_eval.ts @@ -0,0 +1,131 @@ +/** + * This module provides the function to use Vim expressions within blocks. + * + * @module + */ + +import type { Context, Denops, Dispatcher, Meta } from "@denops/core"; +import { isString } from "@core/unknownutil/is/string"; +import { isUndefined } from "@core/unknownutil/is/undefined"; +import { ulid } from "@std/ulid"; +import { execute } from "../helper/execute.ts"; +import { stringify } from "./stringify.ts"; + +/** + * Allows to use {@linkcode [eval].Expression|Expression} and {@linkcode [eval].RawString|RawString} transparently within blocks. + * + * ```typescript + * import type { Denops } from "jsr:@denops/std"; + * import * as fn from "jsr:@denops/std/function"; + * import { expr } from "jsr:@denops/std/eval/expression"; + * import { rawString } from "jsr:@denops/std/eval/string"; + * import { useEval } from "jsr:@denops/std/eval/use-eval"; + * + * export async function main(denops: Denops): Promise { + * await useEval(denops, async (denops) => { + * await denops.cmd('echo cword', { cword: expr`expand('')` }) + * await fn.feedkeys(denops, rawString`\echo 'foo'\`) + * }); + * } + * ``` + */ +export async function useEval( + denops: Denops, + executor: (helper: UseEvalHelper) => Promise, +): Promise { + const helper = new UseEvalHelper(denops); + return await executor(helper); +} + +const cacheKey = "denops_std/eval/use_eval@1"; + +async function ensurePrerequisites(denops: Denops): Promise { + if (isString(denops.context[cacheKey])) { + return denops.context[cacheKey]; + } + const suffix = ulid(); + denops.context[cacheKey] = suffix; + const script = ` + let g:loaded_denops_std_eval_useEval_${suffix} = 1 + + function DenopsStd_Eval_UseEval_Call_${suffix}(fn, args) abort + return call(a:fn, eval(a:args)) + endfunction + `; + await execute(denops, script); + return suffix; +} + +function trimEndOfArgs(args: unknown[]): unknown[] { + const last = args.findIndex(isUndefined); + return last < 0 ? args : args.slice(0, last); +} + +class UseEvalHelper implements Denops { + #denops: Denops; + + constructor(denops: Denops) { + this.#denops = denops; + } + + get name(): string { + return this.#denops.name; + } + + get meta(): Meta { + return this.#denops.meta; + } + + get interrupted(): AbortSignal | undefined { + return this.#denops.interrupted; + } + + get context(): Record { + return this.#denops.context; + } + + get dispatcher(): Dispatcher { + return this.#denops.dispatcher; + } + + set dispatcher(dispatcher: Dispatcher) { + this.#denops.dispatcher = dispatcher; + } + + redraw(force?: boolean): Promise { + return this.#denops.redraw(force); + } + + async call(fn: string, ...args: unknown[]): Promise { + const suffix = await ensurePrerequisites(this.#denops); + return this.#denops.call( + `DenopsStd_Eval_UseEval_Call_${suffix}`, + fn, + stringify(trimEndOfArgs(args)), + ); + } + + async batch(...calls: [string, ...unknown[]][]): Promise { + const suffix = await ensurePrerequisites(this.#denops); + const callHelper = `DenopsStd_Eval_UseEval_Call_${suffix}`; + return await this.#denops.batch( + ...calls.map(([fn, ...args]): [string, ...unknown[]] => [ + callHelper, + fn, + stringify(trimEndOfArgs(args)), + ]), + ); + } + + async cmd(cmd: string, ctx: Context = {}): Promise { + await this.call("denops#api#cmd", cmd, ctx); + } + + eval(expr: string, ctx: Context = {}): Promise { + return this.call("denops#api#eval", expr, ctx); + } + + dispatch(name: string, fn: string, ...args: unknown[]): Promise { + return this.#denops.dispatch(name, fn, ...args); + } +} diff --git a/eval/use_eval_test.ts b/eval/use_eval_test.ts new file mode 100644 index 00000000..920903db --- /dev/null +++ b/eval/use_eval_test.ts @@ -0,0 +1,912 @@ +import { + assert, + assertEquals, + assertRejects, + assertStrictEquals, +} from "@std/assert"; +import { assertSpyCalls, resolvesNext, spy, stub } from "@std/testing/mock"; +import { DisposableStack } from "@nick/dispose"; +import type { BatchError, Denops } from "@denops/core"; +import { test } from "@denops/test"; +import { expr } from "./expression.ts"; +import { rawString } from "./string.ts"; + +import { useEval } from "./use_eval.ts"; + +test({ + mode: "all", + name: "useEval()", + fn: async (denops, t) => { + await t.step("calls the 'executor'", async () => { + const executor = spy(() => Promise.resolve()); + await useEval(denops, executor); + assertSpyCalls(executor, 1); + }); + await t.step("resolves a value that the 'executor' resolves", async () => { + const actual = await useEval(denops, (_helper) => { + return Promise.resolve("foo"); + }); + assertEquals(actual, "foo"); + }); + await t.step("rejects an error that the 'executor' throws", async () => { + await assertRejects( + () => + useEval(denops, (_helper) => { + throw new Error("test error"); + }), + Error, + "test error", + ); + }); + await t.step("rejects an error that the 'executor' rejects", async () => { + await assertRejects( + () => + useEval(denops, (_helper) => { + return Promise.reject(new Error("test error")); + }), + Error, + "test error", + ); + }); + await t.step( + "calls 'denops.call()', 'denops.cmd()', 'denops.eval()' or 'denops.batch()' sequentially", + async () => { + await using testFn = await useTestFn(denops); + await useEval(denops, async (helper) => { + await helper.call("TestFn", "foo"); + await helper.cmd("call TestFn(1)"); + await helper.eval("TestFn(v:true)"); + await helper.batch( + ["TestFn", "bar"], + ["TestFn", "baz"], + ); + }); + const actual = await testFn.callArgs(); + assertEquals(actual, [ + ["foo"], + [1], + [true], + ["bar"], + ["baz"], + ]); + }, + ); + }, +}); + +test({ + mode: "all", + name: "UseEvalHelper", + fn: async (denops, t) => { + await t.step(".redraw()", async (t) => { + await t.step("calls 'denops.redraw()'", async () => { + using denops_redraw = spy(denops, "redraw"); + await useEval(denops, async (helper) => { + await helper.redraw(); + }); + assertSpyCalls(denops_redraw, 1); + }); + }); + await t.step(".call()", async (t) => { + await t.step("calls Vim function", async () => { + await using testFn = await useTestFn(denops); + await useEval(denops, async (helper) => { + await helper.call("TestFn", "foo", 1, true); + }); + const actual = await testFn.callArgs(); + assertEquals(actual, [["foo", 1, true]]); + }); + await t.step( + "calls Vim function without after 'undefined' args", + async () => { + await using testFn = await useTestFn(denops); + await useEval(denops, async (helper) => { + await helper.call("TestFn", 1, 2, undefined, 3, 4); + }); + const actual = await testFn.callArgs(); + assertEquals(actual, [[1, 2]]); + }, + ); + await t.step("calls Vim function with Expression args", async () => { + await using testFn = await useTestFn(denops); + await using _buf = await useNewBuf(denops); + await useEval(denops, async (denops) => { + await denops.call( + "TestFn", + expr`#{ a: "foo", b: 1, c: v:true }`, + expr`feedkeys("ifoo\", 'nx')`, + expr`feedkeys('ibar\baz', 'nx')`, + ); + }); + const actual = await testFn.callArgs(); + assertEquals(actual, [ + [{ a: "foo", b: 1, c: true }, 0, 0], + ]); + const lines = await denops.call("getline", 1, 2); + assertEquals(lines, [ + "foo", + "bar\\baz", + ]); + }); + await t.step("calls Vim function with RawString args", async () => { + await using _testFn = await useTestFn(denops); + await using _buf = await useNewBuf(denops); + await useEval(denops, async (denops) => { + await denops.call( + "TestFn", + rawString`a\`, + rawString`b\`, + rawString`c\`, + ); + }); + assert( + await denops.eval( + 'g:test_fn_call_args ==# [["a\\", "b\\", "c\\"]]', + ), + ); + }); + await t.step("calls Vim function with complex args", async () => { + await using testFn = await useTestFn(denops); + await using _encoding = await setEncoding(denops, "utf-8"); + await using _buf = await useNewBuf(denops); + await denops.call("execute", [ + "insert", + "foobar", + ".", + ]); + await useEval(denops, async (denops) => { + await denops.call( + "TestFn", + [ + null, + undefined, + 42, + true, + () => {}, + Symbol("foo"), + "bar", + { + toJSON: () => [123, "baz"], + }, + new Date("2007-08-31T12:34:56.000Z"), + { + k0: null, + k1: undefined, + k2: [ + { + [Symbol("foo")]: 123, + key: 234, + expr: expr`getline(1)`, + rstr: rawString`\U0001F680`, + }, + ], + }, + ], + ); + }); + const actual = await testFn.callArgs(); + assertEquals(actual, [[[ + null, + null, + 42, + true, + null, + null, + "bar", + [ + 123, + "baz", + ], + "2007-08-31T12:34:56.000Z", + { + k0: null, + k2: [ + { + key: 234, + expr: "foobar", + rstr: `\uD83D\uDE80`, // u1F680 is surrogate pair + }, + ], + }, + ]]]); + }); + await t.step("resolves a result of Vim function", async () => { + let actual: unknown; + await useEval(denops, async (helper) => { + actual = await helper.call("range", 2, 4); + }); + assertEquals(actual, [2, 3, 4]); + }); + await t.step("rejects an error that Vim throws", async () => { + await useEval(denops, async (helper) => { + await assertRejects( + () => helper.call("notexistsfn"), + Error, + "Unknown function: notexistsfn", + ); + }); + }); + await t.step( + "rejects a TypeError if the 'ctx' is unserializable", + async () => { + await using testFn = await useTestFn(denops); + await assertRejects( + async () => { + await useEval(denops, async (denops) => { + await denops.call("TestFn", 10n); + }); + }, + TypeError, + "BigInt value can't be serialized", + ); + const actual = await testFn.callArgs(); + assertEquals(actual, []); + }, + ); + }); + await t.step(".cmd()", async (t) => { + await t.step("executes Vim command", async () => { + await using testFn = await useTestFn(denops); + await useEval(denops, async (helper) => { + await helper.cmd("call TestFn('foo', 1, v:true)"); + }); + const actual = await testFn.callArgs(); + assertEquals(actual, [["foo", 1, true]]); + }); + await t.step("executes Vim command with Expression 'ctx'", async () => { + await using testFn = await useTestFn(denops); + await using _buf = await useNewBuf(denops); + await useEval(denops, async (denops) => { + await denops.cmd( + "call TestFn(a, b, c)", + { + b: expr`#{ a: "foo", b: 1, c: v:true }`, + c: expr`feedkeys("ifoo\", 'nx')`, + a: expr`feedkeys('ibar\baz', 'nx')`, + }, + ); + }); + const actual = await testFn.callArgs(); + assertEquals(actual, [ + [0, { a: "foo", b: 1, c: true }, 0], + ]); + const lines = await denops.call("getline", 1, 2); + assertEquals(lines, [ + "foo", + "bar\\baz", + ]); + }); + await t.step("executes Vim command with RawString 'ctx'", async () => { + await using _testFn = await useTestFn(denops); + await using _buf = await useNewBuf(denops); + await useEval(denops, async (denops) => { + await denops.cmd( + "call TestFn(a, b, c)", + { + a: rawString`a\`, + b: rawString`b\`, + c: rawString`c\`, + }, + ); + }); + assert( + await denops.eval( + 'g:test_fn_call_args ==# [["a\\", "b\\", "c\\"]]', + ), + ); + }); + await t.step("executes Vim command with complex 'ctx'", async () => { + await using testFn = await useTestFn(denops); + await using _encoding = await setEncoding(denops, "utf-8"); + await using _buf = await useNewBuf(denops); + await denops.call("execute", [ + "new", + "insert", + "foobar", + ".", + ]); + await useEval(denops, async (denops) => { + await denops.cmd( + "call TestFn(a)", + { + a: [ + null, + undefined, + 42, + true, + () => {}, + Symbol("foo"), + "bar", + { + toJSON: () => [123, "baz"], + }, + new Date("2007-08-31T12:34:56.000Z"), + { + k0: null, + k1: undefined, + k2: [ + { + [Symbol("foo")]: 123, + key: 234, + expr: expr`getline(1)`, + rstr: rawString`\U0001F680`, + }, + ], + }, + ], + }, + ); + }); + const actual = await testFn.callArgs(); + assertEquals(actual, [[[ + null, + null, + 42, + true, + null, + null, + "bar", + [ + 123, + "baz", + ], + "2007-08-31T12:34:56.000Z", + { + k0: null, + k2: [ + { + key: 234, + expr: "foobar", + rstr: `\uD83D\uDE80`, // u1F680 is surrogate pair + }, + ], + }, + ]]]); + }); + await t.step("rejects an error that Vim throws", async () => { + await useEval(denops, async (helper) => { + await assertRejects( + () => helper.cmd("call notexistsfn()"), + Error, + "Unknown function: notexistsfn", + ); + }); + }); + await t.step( + "rejects a TypeError if the 'ctx' is unserializable", + async () => { + await using testFn = await useTestFn(denops); + await assertRejects( + async () => { + await useEval(denops, async (denops) => { + await denops.cmd("call TestFn(a)", { a: 10n }); + }); + }, + TypeError, + "BigInt value can't be serialized", + ); + const actual = await testFn.callArgs(); + assertEquals(actual, []); + }, + ); + }); + await t.step(".eval()", async (t) => { + await t.step("evaluates Vim expression", async () => { + await using testFn = await useTestFn(denops); + await useEval(denops, async (helper) => { + await helper.eval("TestFn('foo', 1, v:true)"); + }); + const actual = await testFn.callArgs(); + assertEquals(actual, [ + ["foo", 1, true], + ]); + }); + await t.step( + "evaluates Vim expression with Expression 'ctx'", + async () => { + await using testFn = await useTestFn(denops); + await using _buf = await useNewBuf(denops); + await useEval(denops, async (denops) => { + await denops.eval( + "TestFn(a, b, c)", + { + b: expr`#{ a: "foo", b: 1, c: v:true }`, + c: expr`feedkeys("ifoo\", 'nx')`, + a: expr`feedkeys('ibar\baz', 'nx')`, + }, + ); + }); + const actual = await testFn.callArgs(); + assertEquals(actual, [ + [0, { a: "foo", b: 1, c: true }, 0], + ]); + const lines = await denops.call("getline", 1, 2); + assertEquals(lines, [ + "foo", + "bar\\baz", + ]); + }, + ); + await t.step( + "evaluates Vim expression with RawString 'ctx'", + async () => { + await using _testFn = await useTestFn(denops); + await using _buf = await useNewBuf(denops); + await useEval(denops, async (denops) => { + await denops.eval( + "TestFn(a, b, c)", + { + a: rawString`a\`, + b: rawString`b\`, + c: rawString`c\`, + }, + ); + }); + assert( + await denops.eval( + 'g:test_fn_call_args ==# [["a\\", "b\\", "c\\"]]', + ), + ); + }, + ); + await t.step("evaluates Vim expression with complex 'ctx'", async () => { + await using testFn = await useTestFn(denops); + await using _encoding = await setEncoding(denops, "utf-8"); + await using _buf = await useNewBuf(denops); + await denops.call("execute", [ + "insert", + "foobar", + ".", + ]); + await useEval(denops, async (denops) => { + await denops.eval( + "TestFn(a)", + { + a: [ + null, + undefined, + 42, + true, + () => {}, + Symbol("foo"), + "bar", + { + toJSON: () => [123, "baz"], + }, + new Date("2007-08-31T12:34:56.000Z"), + { + k0: null, + k1: undefined, + k2: [ + { + [Symbol("foo")]: 123, + key: 234, + expr: expr`getline(1)`, + rstr: rawString`\U0001F680`, + }, + ], + }, + ], + }, + ); + }); + const actual = await testFn.callArgs(); + assertEquals(actual, [[[ + null, + null, + 42, + true, + null, + null, + "bar", + [ + 123, + "baz", + ], + "2007-08-31T12:34:56.000Z", + { + k0: null, + k2: [ + { + key: 234, + expr: "foobar", + rstr: `\uD83D\uDE80`, // u1F680 is surrogate pair + }, + ], + }, + ]]]); + }); + await t.step("resolves a result of Vim expression", async () => { + let actual: unknown; + await useEval(denops, async (helper) => { + actual = await helper.eval("range(2, 4)"); + }); + assertEquals(actual, [2, 3, 4]); + }); + await t.step("rejects an error that Vim throws", async () => { + await useEval(denops, async (helper) => { + await assertRejects( + () => helper.eval("notexistsfn()"), + Error, + "Unknown function: notexistsfn", + ); + }); + }); + await t.step( + "rejects a TypeError if the 'ctx' is unserializable", + async () => { + await using testFn = await useTestFn(denops); + await assertRejects( + async () => { + await useEval(denops, async (denops) => { + await denops.eval("TestFn(a)", { a: 10n }); + }); + }, + TypeError, + "BigInt value can't be serialized", + ); + const actual = await testFn.callArgs(); + assertEquals(actual, []); + }, + ); + }); + await t.step(".batch()", async (t) => { + await t.step("calls Vim functions", async () => { + await using testFn = await useTestFn(denops); + await useEval(denops, async (helper) => { + await helper.batch( + ["TestFn", "foo"], + ["TestFn", 1], + ["TestFn", true], + ); + }); + const actual = await testFn.callArgs(); + assertEquals(actual, [ + ["foo"], + [1], + [true], + ]); + }); + await t.step( + "calls Vim functions without after 'undefined' args", + async () => { + await using testFn = await useTestFn(denops); + await useEval(denops, async (helper) => { + await helper.batch( + ["TestFn", 1, 2, undefined, 3, 4], + ); + }); + const actual = await testFn.callArgs(); + assertEquals(actual, [[1, 2]]); + }, + ); + await t.step("calls Vim functions with Expression args", async () => { + await using testFn = await useTestFn(denops); + await using _buf = await useNewBuf(denops); + await useEval(denops, async (denops) => { + await denops.batch( + [ + "TestFn", + expr`#{ a: "foo", b: 1, c: v:true }`, + expr`feedkeys("ifoo\", 'nx')`, + ], + [ + "TestFn", + expr`feedkeys('ibar\baz', 'nx')`, + ], + ); + }); + const actual = await testFn.callArgs(); + assertEquals(actual, [ + [{ a: "foo", b: 1, c: true }, 0], + [0], + ]); + const lines = await denops.call("getline", 1, 2); + assertEquals(lines, [ + "foo", + "bar\\baz", + ]); + }); + await t.step("calls Vim functions with RawString args", async () => { + await using _testFn = await useTestFn(denops); + await using _buf = await useNewBuf(denops); + await useEval(denops, async (denops) => { + await denops.batch( + [ + "TestFn", + rawString`a\`, + rawString`b\`, + ], + [ + "TestFn", + rawString`c\`, + ], + ); + }); + assert( + await denops.eval( + 'g:test_fn_call_args ==# [["a\\", "b\\"], ["c\\"]]', + ), + ); + }); + await t.step("calls Vim functions with complex args", async () => { + await using testFn = await useTestFn(denops); + await using _encoding = await setEncoding(denops, "utf-8"); + await using _buf = await useNewBuf(denops); + await denops.call("execute", [ + "insert", + "foobar", + ".", + ]); + await useEval(denops, async (denops) => { + await denops.batch( + [ + "TestFn", + [ + null, + undefined, + 42, + true, + () => {}, + Symbol("foo"), + "bar", + { + toJSON: () => [123, "baz"], + }, + new Date("2007-08-31T12:34:56.000Z"), + { + k0: null, + k1: undefined, + k2: [ + { + [Symbol("foo")]: 123, + key: 234, + expr: expr`getline(1)`, + rstr: rawString`\U0001F680`, + }, + ], + }, + ], + ], + ); + }); + const actual = await testFn.callArgs(); + assertEquals(actual, [[[ + null, + null, + 42, + true, + null, + null, + "bar", + [ + 123, + "baz", + ], + "2007-08-31T12:34:56.000Z", + { + k0: null, + k2: [ + { + key: 234, + expr: "foobar", + rstr: `\uD83D\uDE80`, // u1F680 is surrogate pair + }, + ], + }, + ]]]); + }); + await t.step("resolves results of Vim functions", async () => { + let actual: unknown; + await useEval(denops, async (helper) => { + actual = await helper.batch( + ["range", 0, 2], + ["range", 2, 4], + ["matchstr", "hello", "el*"], + ); + }); + assertEquals(actual, [ + [0, 1, 2], + [2, 3, 4], + "ell", + ]); + }); + await t.step("resolves an empty array if no arguments", async () => { + let actual: unknown; + await useEval(denops, async (helper) => { + actual = await helper.batch(); + }); + assertEquals(actual, []); + }); + await t.step("rejects a BatchError that Vim throws", async () => { + await useEval(denops, async (helper) => { + const error = await assertRejects( + () => + helper.batch( + ["range", 3], + ["range", 2, 4], + ["notexistsfn"], + ["range", 3], + ), + Error, + "Unknown function: notexistsfn", + ); + assertEquals(error.name, "BatchError"); + assertEquals((error as BatchError).results, [ + [0, 1, 2], + [2, 3, 4], + ]); + }); + }); + await t.step( + "rejects a TypeError if the 'ctx' is unserializable", + async () => { + await using testFn = await useTestFn(denops); + await assertRejects( + async () => { + await useEval(denops, async (denops) => { + await denops.batch( + ["TestFn", "foo"], + ["TestFn", 10n], + ); + }); + }, + TypeError, + "BigInt value can't be serialized", + ); + const actual = await testFn.callArgs(); + assertEquals(actual, []); + }, + ); + }); + await t.step(".dispatch()", async (t) => { + await t.step("calls 'denops.dispatch()'", async () => { + using denops_dispatch = stub(denops, "dispatch", resolvesNext([1])); + await useEval(denops, async (helper) => { + await helper.dispatch("pluginA", "foo", "bar", 42, false); + }); + assertEquals(denops_dispatch.calls.map((c) => c.args), [ + ["pluginA", "foo", "bar", 42, false], + ]); + }); + await t.step("resolves a result of 'denops.dispatch()'", async () => { + using _denops_dispatch = stub( + denops, + "dispatch", + resolvesNext(["one"]), + ); + let actual: unknown; + await useEval(denops, async (helper) => { + actual = await helper.dispatch("pluginA", "foo", "bar"); + }); + assertEquals(actual, "one"); + }); + await t.step( + "rejects an error that 'denops.dispatch()' rejects", + async () => { + using _denops_dispatch = stub( + denops, + "dispatch", + resolvesNext([new Error("test plugin error")]), + ); + await useEval(denops, async (helper) => { + await assertRejects( + () => helper.dispatch("pluginA", "foo", "bar"), + Error, + "test plugin error", + ); + }); + }, + ); + }); + await t.step(".name", async (t) => { + await t.step("returns 'denops.name'", async () => { + let actual: unknown; + await useEval(denops, (helper) => { + actual = helper.name; + return Promise.resolve(); + }); + assertStrictEquals(actual, denops.name); + }); + }); + await t.step(".meta", async (t) => { + await t.step("returns 'denops.meta'", async () => { + let actual: unknown; + await useEval(denops, (helper) => { + actual = helper.meta; + return Promise.resolve(); + }); + assertStrictEquals(actual, denops.meta); + }); + }); + await t.step(".interrupted", async (t) => { + await t.step("returns 'denops.interrupted'", async () => { + let actual: unknown; + await useEval(denops, (helper) => { + actual = helper.interrupted; + return Promise.resolve(); + }); + assertStrictEquals(actual, denops.interrupted); + }); + }); + await t.step(".context", async (t) => { + await t.step("returns 'denops.context'", async () => { + let actual: unknown; + await useEval(denops, (helper) => { + actual = helper.context; + return Promise.resolve(); + }); + assertStrictEquals(actual, denops.context); + }); + }); + await t.step(".dispatcher", async (t) => { + const MY_DISPATCHER = { + foo: () => {}, + }; + await t.step("sets to 'denops.dispatcher'", async () => { + using stack = new DisposableStack(); + stack.adopt(denops.dispatcher, (saved) => { + denops.dispatcher = saved; + }); + await useEval(denops, (helper) => { + helper.dispatcher = MY_DISPATCHER; + return Promise.resolve(); + }); + assertStrictEquals(denops.dispatcher, MY_DISPATCHER); + }); + await t.step("returns 'denops.dispatcher'", async () => { + using stack = new DisposableStack(); + stack.adopt(denops.dispatcher, (saved) => { + denops.dispatcher = saved; + }); + denops.dispatcher = MY_DISPATCHER; + let actual: unknown; + await useEval(denops, (helper) => { + actual = helper.dispatcher; + return Promise.resolve(); + }); + assertStrictEquals(actual, MY_DISPATCHER); + }); + }); + }, +}); + +async function useTestFn(denops: Denops) { + await denops.call("execute", [ + "let g:test_fn_call_args = []", + "function TestFn(...) abort", + " call add(g:test_fn_call_args, a:000->copy())", + "endfunction", + ], ""); + return { + async callArgs() { + return await denops.eval("g:test_fn_call_args"); + }, + async [Symbol.asyncDispose]() { + await denops.call("execute", [ + "unlet! g:test_fn_call_args", + "delfunction! TestFn", + ]); + }, + }; +} + +async function useNewBuf(denops: Denops) { + await denops.cmd("new"); + const bufNr = await denops.call("bufnr") as number; + return { + async [Symbol.asyncDispose]() { + await denops.cmd(`${bufNr}bwipeout!`); + }, + }; +} + +async function setEncoding(denops: Denops, encoding: string) { + const saved = await denops.eval("&encoding"); + await denops.cmd("let &encoding = encoding", { encoding }); + return { + async [Symbol.asyncDispose]() { + await denops.cmd("let &encoding = saved", { saved }); + }, + }; +} diff --git a/eval/vim_evaluatable.ts b/eval/vim_evaluatable.ts new file mode 100644 index 00000000..6ed1db0e --- /dev/null +++ b/eval/vim_evaluatable.ts @@ -0,0 +1,20 @@ +import type { Predicate } from "@core/unknownutil/type"; +import { isFunction } from "@core/unknownutil/is/function"; +import { isObjectOf } from "@core/unknownutil/is/object-of"; + +/** @internal */ +// NOTE: Use `Symbol.for` instead of `Symbol` because multiple versions of libraries may be mixedly used. +export const vimExpressionOf = Symbol.for("denops.eval.VimExpressionOf"); + +/** @internal */ +export type VimEvaluatable = { + /** @internal */ + [vimExpressionOf](): string; +}; + +/** @internal */ +export const isVimEvaluatable: Predicate = isObjectOf({ + [vimExpressionOf]: isFunction as Predicate< + VimEvaluatable[typeof vimExpressionOf] + >, +}); diff --git a/eval/vim_evaluatable_test.ts b/eval/vim_evaluatable_test.ts new file mode 100644 index 00000000..7cd8209c --- /dev/null +++ b/eval/vim_evaluatable_test.ts @@ -0,0 +1,32 @@ +import { assertEquals } from "@std/assert"; + +import { isVimEvaluatable, vimExpressionOf } from "./vim_evaluatable.ts"; + +Deno.test("isVimEvaluatable()", async (t) => { + await t.step("returns true if the value is VimEvaluatable", () => { + const actual = isVimEvaluatable({ + [vimExpressionOf]: () => {}, + }); + assertEquals(actual, true); + }); + await t.step("returns false if the value is", async (t) => { + const tests: readonly [name: string, value: unknown][] = [ + ["string", "foo"], + ["number", 123], + ["undefined", undefined], + ["null", null], + ["true", true], + ["false", false], + ["Function", () => 0], + ["symbol", Symbol("bar")], + ["Array", [0, 1]], + ["Object", { key: "a" }], + ]; + for (const [name, value] of tests) { + await t.step(name, () => { + const actual = isVimEvaluatable(value); + assertEquals(actual, false); + }); + } + }); +}); diff --git a/helper/expr_string.ts b/helper/expr_string.ts index 3a37cafa..b0e83408 100644 --- a/helper/expr_string.ts +++ b/helper/expr_string.ts @@ -23,6 +23,7 @@ * ``` * * @module + * @deprecated Use {@linkcode [eval].rawString|rawString} */ import type { Context, Denops, Dispatcher, Meta } from "@denops/core"; import type { Predicate } from "@core/unknownutil/type"; @@ -45,6 +46,8 @@ const EXPR_STRING_MARK = "__denops_expr_string"; /** * String that marked as Vim's string constant format. + * + * @deprecated Use {@linkcode [eval].rawString|rawString} and {@linkcode [eval].RawString|RawString} */ export type ExprString = string & { /** @@ -96,6 +99,7 @@ async function ensurePrerequisites(denops: Denops): Promise { * ``` * * @see useExprString for usage + * @deprecated Use {@linkcode [eval].rawString|rawString} */ export function exprQuote( template: TemplateStringsArray, @@ -120,12 +124,15 @@ const isInstanceOfString = isInstanceOf(String); * console.log(isExprString(exprQuote`foo`)); // outputs: true * console.log(isExprString("foo")); // outputs: false * ``` + * + * @deprecated Use {@linkcode [eval].rawString|rawString} and {@linkcode [eval].isRawString|isRawString} */ export const isExprString: Predicate = isObjectOf({ // NOTE: `ExprString` has a different type in definition (primitive `string`) and implementation (`String`). Only checks `EXPR_STRING_MARK` existence. [EXPR_STRING_MARK]: isLiteralOf(1), }) as unknown as Predicate; +// NOTE: Do not use [@core/unknownutil@4.3.0/is/custom-jsonable], it's changes behaviour. function isJsonable(x: unknown): x is Jsonable { return x != null && isFunction((x as Jsonable).toJSON); } diff --git a/helper/keymap.ts b/helper/keymap.ts index 6ac48f6e..750595c3 100644 --- a/helper/keymap.ts +++ b/helper/keymap.ts @@ -1,18 +1,15 @@ import type { Denops } from "@denops/core"; import { isArray } from "@core/unknownutil/is/array"; import { isString } from "@core/unknownutil/is/string"; -import { - exprQuote as q, - type ExprString, - isExprString, - useExprString, -} from "./expr_string.ts"; import { batch } from "../batch/mod.ts"; -import { register } from "../lambda/mod.ts"; +import { isRawString, type RawString, rawString } from "../eval/string.ts"; +import { useEval } from "../eval/use_eval.ts"; import { feedkeys } from "../function/mod.ts"; +import { type ExprString, isExprString } from "../helper/expr_string.ts"; +import { add } from "../lambda/mod.ts"; export type Keys = { - keys: string | ExprString; + keys: string | RawString | ExprString; remap: boolean; }; @@ -23,7 +20,7 @@ function toArray(x: T | T[]): T[] { } function toKeys(keys: KeysSpecifier): Keys { - if (isString(keys) || isExprString(keys)) { + if (isString(keys) || isRawString(keys) || isExprString(keys)) { return { keys, remap: false }; } return keys; @@ -36,7 +33,7 @@ function toKeys(keys: KeysSpecifier): Keys { * ```typescript * import type { Entrypoint } from "jsr:@denops/std"; * import * as fn from "jsr:@denops/std/function"; - * import { exprQuote as q } from "jsr:@denops/std/helper/expr_string"; + * import { rawString } from "jsr:@denops/std/eval"; * import { send } from "jsr:@denops/std/helper/keymap"; * * export const main: Entrypoint = async (denops) => { @@ -50,14 +47,14 @@ function toKeys(keys: KeysSpecifier): Keys { * * // Send special keys. * await send(denops, [ - * q`${"\\".repeat(6)}`, + * rawString`${"\\".repeat(6)}`, * "baz", * ]); * // "baz" * console.log(await fn.getline(denops, ".")); * * // Send remaped keys. - * await send(denops, { keys: q`\`, remap: true }); + * await send(denops, { keys: rawString`\`, remap: true }); * // "bazsend" * console.log(await fn.getline(denops, ".")); * }, @@ -72,19 +69,15 @@ export async function send( keys: KeysSpecifier | KeysSpecifier[], ): Promise { const { promise: waiter, resolve } = Promise.withResolvers(); - const id = register(denops, () => resolve(), { once: true }); + using lo = add(denops, () => resolve()); await Promise.all([ - useExprString(denops, async (denops) => { + useEval(denops, async (denops) => { await batch(denops, async (denops) => { const normKeys = toArray(keys).map(toKeys); for (const key of normKeys) { await feedkeys(denops, key.keys, key.remap ? "m" : "n"); } - await feedkeys( - denops, - q`\call denops#notify('${denops.name}', '${id}', [])\`, - "n", - ); + await feedkeys(denops, rawString`\call ${lo.notify(1)}\`, "n"); }); }), waiter, diff --git a/helper/keymap_test.ts b/helper/keymap_test.ts index dd8c6e5e..a524b09d 100644 --- a/helper/keymap_test.ts +++ b/helper/keymap_test.ts @@ -1,57 +1,85 @@ import { assertEquals } from "@std/assert"; -import { is } from "@core/unknownutil"; import { test } from "@denops/test"; +import { AsyncDisposableStack } from "@nick/dispose"; import * as fn from "../function/mod.ts"; -import { exprQuote as q } from "./expr_string.ts"; -import { type KeysSpecifier, send } from "./keymap.ts"; - -function toArray(x: T | T[]): T[] { - return is.Array(x) ? x : [x]; -} - -type Spec = { - name: string; - keys: KeysSpecifier | KeysSpecifier[]; - expect: string; -}; +import { rawString } from "../eval/string.ts"; +import { exprQuote } from "./expr_string.ts"; +import { send } from "./keymap.ts"; test({ - mode: "all", + mode: "vim", name: "send()", fn: async (denops, t) => { - const specs: Spec[] = [ - { - name: "normal key", - keys: "foo", - expect: "foo", - }, - { - name: "special key", - keys: q`foo\bar\\`, - expect: "foba", - }, - { - name: "remapped key", - keys: { keys: q`\`, remap: true }, - expect: "foo", - }, - ]; - await denops.cmd("inoremap foo"); - - for (const spec of specs) { - await t.step({ - name: spec.name, - fn: async () => { - await fn.deletebufline(denops, "%", 1, "$"); - assertEquals(await fn.getline(denops, 1), ""); - await send(denops, [ - q`\`, - "i", - ...toArray(spec.keys), - ]); - assertEquals(await fn.getline(denops, 1), spec.expect); - }, - }); - } + await t.step("sends normal keys with string", async () => { + await fn.deletebufline(denops, "%", 1, "$"); + await fn.setline(denops, 1, "foobar"); + await send(denops, "gg0lll"); + assertEquals(await fn.getpos(denops, "."), [0, 1, 4, 0]); + }); + await t.step("sends normal keys with string[]", async () => { + await fn.deletebufline(denops, "%", 1, "$"); + await fn.setline(denops, 1, "foobar"); + await send(denops, ["gg0lll", "hh", "lll"]); + assertEquals(await fn.getpos(denops, "."), [0, 1, 5, 0]); + }); + await t.step("sends special keys with RawString", async () => { + await fn.deletebufline(denops, "%", 1, "$"); + await send(denops, rawString`\call setline(1, 'foo')\`); + assertEquals(await fn.getline(denops, 1), "foo"); + }); + await t.step("sends special keys with RawString[]", async () => { + await fn.deletebufline(denops, "%", 1, "$"); + await send(denops, [ + rawString`\call setline(1, 'foo')\`, + rawString`\call append(0, 'bar')\`, + rawString`\call append(0, 'baz')\`, + ]); + assertEquals(await fn.getline(denops, 1, "$"), ["baz", "bar", "foo"]); + }); + await t.step("sends special keys with ExprString", async () => { + await fn.deletebufline(denops, "%", 1, "$"); + await send(denops, exprQuote`\call setline(1, 'foo')\`); + assertEquals(await fn.getline(denops, 1), "foo"); + }); + await t.step("sends special keys with ExprString[]", async () => { + await fn.deletebufline(denops, "%", 1, "$"); + await send(denops, [ + exprQuote`\call setline(1, 'foo')\`, + exprQuote`\call append(0, 'bar')\`, + exprQuote`\call append(0, 'baz')\`, + ]); + assertEquals(await fn.getline(denops, 1, "$"), ["baz", "bar", "foo"]); + }); + await t.step("sends not remapped keys with Keys", async () => { + await using stack = new AsyncDisposableStack(); + stack.defer(() => denops.cmd("nunmap k")); + await denops.cmd("nnoremap k "); + await fn.deletebufline(denops, "%", 1, "$"); + await fn.setline(denops, 1, ["foo", "bar", "baz"]); + await send(denops, { keys: "gg0jlk", remap: false }); + assertEquals(await fn.getpos(denops, "."), [0, 1, 2, 0]); + }); + await t.step("sends remapped keys with Keys", async () => { + await using stack = new AsyncDisposableStack(); + stack.defer(() => denops.cmd("nunmap k")); + await denops.cmd("nnoremap k "); + await fn.deletebufline(denops, "%", 1, "$"); + await fn.setline(denops, 1, ["foo", "bar", "baz"]); + await send(denops, { keys: "gg0jlk", remap: true }); + assertEquals(await fn.getpos(denops, "."), [0, 3, 2, 0]); + }); + await t.step("sends mixed keys with Keys[]", async () => { + await using stack = new AsyncDisposableStack(); + stack.defer(() => denops.cmd("nunmap k")); + await denops.cmd("nnoremap k "); + await fn.deletebufline(denops, "%", 1, "$"); + await fn.setline(denops, 1, ["foo", "bar", "baz"]); + await send(denops, [ + { keys: rawString`gg0\lk`, remap: false }, + { keys: "k", remap: true }, + { keys: "k", remap: false }, + ]); + assertEquals(await fn.getpos(denops, "."), [0, 1, 2, 0]); + }); }, }); diff --git a/lambda/mod.ts b/lambda/mod.ts index 44df177f..b4bc85d6 100644 --- a/lambda/mod.ts +++ b/lambda/mod.ts @@ -32,6 +32,13 @@ */ import type { Denops } from "@denops/core"; +import type { Predicate } from "@core/unknownutil/type"; +import { isArray } from "@core/unknownutil/is/array"; +import { isLiteralOf } from "@core/unknownutil/is/literal-of"; +import { isLiteralOneOf } from "@core/unknownutil/is/literal-one-of"; +import { isTupleOf } from "@core/unknownutil/is/tuple-of"; +import { stringify } from "../eval/stringify.ts"; +import { expr, type Expression } from "../eval/expression.ts"; /** * Lambda function identifier @@ -143,7 +150,7 @@ export function unregister( return false; } -export interface Lambda { +export interface Lambda extends Disposable { readonly id: Identifier; /** @@ -161,7 +168,7 @@ export interface Lambda { * } * ``` */ - notify(...args: unknown[]): string; + notify(...args: unknown[]): Expression; /** * Create a Vim script expression to request the lambda function @@ -178,7 +185,7 @@ export interface Lambda { * } * ``` */ - request(...args: unknown[]): string; + request(...args: unknown[]): Expression; /** * Dispose the lambda function @@ -197,8 +204,6 @@ export interface Lambda { * ``` */ dispose(): void; - - [Symbol.dispose](): void; } /** @@ -224,21 +229,67 @@ export interface Lambda { * lo.dispose(); * } * ``` + * + * You can pass JSON serializable values, {@linkcode Expression} or + * {@linkcode [eval].RawString|RawString} for {@linkcode [lambda].notify|notify} + * or {@linkcode [lambda].request|request} arguments. + * + * ```typescript + * import type { Denops } from "jsr:@denops/std"; + * import * as lambda from "jsr:@denops/std/lambda"; + * import { expr, rawString } from "jsr:@denops/std/eval"; + * + * export async function main(denops: Denops): Promise { + * const a = lambda.add(denops, (cword: unknown) => { + * // Do what ever you want. + * return rawString`\`; + * }); + * await denops.cmd(`nmap ${ + * a.request(expr`expand("")`) + * }`); + * } + * ``` */ export function add(denops: Denops, fn: Fn): Lambda { - const id = register(denops, fn); - const name = denops.name; + const fnWrapper = async (...args: unknown[]) => { + if (isFnWrapperArgs(args)) { + const [, type, fnArgs] = args; + if (type === "notify") { + await fn(...fnArgs); + } else { + return stringify(await fn(...fnArgs)); + } + } else { + return await fn(...args); + } + }; + const id = register(denops, fnWrapper); + const { name } = denops; return { id, notify: (...args: unknown[]) => { - args = args.map((v) => JSON.stringify(v)); - return `denops#notify('${name}', '${id}', [${args}])`; + const fnArgs: FnWrapperArgs = [VIM_REQUEST_FLAG, "notify", args]; + return expr`denops#notify(${name}, ${id}, ${fnArgs})`; }, request: (...args: unknown[]) => { - args = args.map((v) => JSON.stringify(v)); - return `denops#request('${name}', '${id}', [${args}])`; + const fnArgs: FnWrapperArgs = [VIM_REQUEST_FLAG, "request", args]; + return expr`eval(denops#request(${name}, ${id}, ${fnArgs}))`; }, dispose: () => unregister(denops, id), - [Symbol.dispose]: () => unregister(denops, id), + [Symbol.dispose]: () => void unregister(denops, id), }; } + +const VIM_REQUEST_FLAG = "__denops_std__lambda__vim_request@1"; + +type FnWrapperArgs = [ + flag: typeof VIM_REQUEST_FLAG, + type: "notify" | "request", + fnArgs: unknown[], +]; + +const isFnWrapperArgs = isTupleOf([ + isLiteralOf(VIM_REQUEST_FLAG), + isLiteralOneOf(["notify", "request"] as const), + isArray, +]) satisfies Predicate; diff --git a/lambda/mod_test.ts b/lambda/mod_test.ts index 21879cb9..acdf33a5 100644 --- a/lambda/mod_test.ts +++ b/lambda/mod_test.ts @@ -1,134 +1,391 @@ -import { assertEquals, assertRejects } from "@std/assert"; +import { assertEquals, assertRejects, assertStringIncludes } from "@std/assert"; +import { + assertSpyCallArgs, + assertSpyCalls, + resolvesNext, + returnsNext, + spy, +} from "@std/testing/mock"; +import { assert, is } from "@core/unknownutil"; import { test } from "@denops/test"; +import { expr, rawString } from "../eval/mod.ts"; import * as lambda from "./mod.ts"; +const NOOP = () => {}; + test({ mode: "all", name: "lambda", fn: async (denops, t) => { - await t.step({ - name: "register() registers a lambda function", - fn: async () => { - const id = lambda.register( - denops, - () => Promise.resolve("0"), - ); - assertEquals(await denops.dispatch(denops.name, id), "0"); - assertEquals(await denops.dispatch(denops.name, id), "0"); - }, - }); + await t.step("register()", async (t) => { + await t.step("if a non-async 'fn' is specified", async (t) => { + await t.step("registers a lambda function", async (t) => { + const fn = spy(returnsNext(["foo", "bar", "baz"])); + const id = lambda.register(denops, fn); + assertSpyCalls(fn, 0); + assertEquals(await denops.dispatch(denops.name, id), "foo"); + assertSpyCalls(fn, 1); - await t.step({ - name: - "register() registers an oneshot lambda functions when 'once' option is specified", - fn: async () => { - const id = lambda.register( - denops, - () => Promise.resolve("0"), - { once: true }, - ); - assertEquals(await denops.dispatch(denops.name, id), "0"); - - // The method will be removed - await assertRejects( - async () => { - await denops.dispatch(denops.name, id); - }, - `No method '${id}' exists`, - ); - }, + await t.step("which can be called multiple times", async () => { + assertEquals(await denops.dispatch(denops.name, id), "bar"); + assertEquals(await denops.dispatch(denops.name, id), "baz"); + assertSpyCalls(fn, 3); + }); + }); + await t.step("if the 'fn' throws an error", async (t) => { + await t.step("the lambda function rejects", async () => { + const fn = returnsNext([new Error("test error")]); + const id = lambda.register(denops, fn); + + const error = await assertRejects( + () => denops.dispatch(denops.name, id), + ); + assertStringIncludes(error as string, "test error"); + }); + }); + }); + await t.step("if an async 'fn' is specified", async (t) => { + await t.step("registers a lambda function", async (t) => { + const fn = spy(resolvesNext(["foo", "bar", "baz"])); + const id = lambda.register(denops, fn); + assertSpyCalls(fn, 0); + assertEquals(await denops.dispatch(denops.name, id), "foo"); + assertSpyCalls(fn, 1); + + await t.step("which can be called multiple times", async () => { + assertEquals(await denops.dispatch(denops.name, id), "bar"); + assertEquals(await denops.dispatch(denops.name, id), "baz"); + assertSpyCalls(fn, 3); + }); + }); + await t.step("if the 'fn' rejects an error", async (t) => { + await t.step("the lambda function rejects", async () => { + const fn = resolvesNext([new Error("test error")]); + const id = lambda.register(denops, fn); + + const error = await assertRejects( + () => denops.dispatch(denops.name, id), + ); + assertStringIncludes(error as string, "test error"); + }); + }); + }); + await t.step("if 'once' option is specified", async (t) => { + await t.step("registers an oneshot lambda function", async (t) => { + const fn = spy(returnsNext(["foo"])); + const id = lambda.register(denops, fn, { once: true }); + assertSpyCalls(fn, 0); + assertEquals(await denops.dispatch(denops.name, id), "foo"); + assertSpyCalls(fn, 1); + + await t.step("which will be removed if called once", async () => { + const error = await assertRejects( + () => denops.dispatch(denops.name, id), + ); + assertStringIncludes( + error as string, + "denops.dispatcher[name] is not a function", + ); + assertSpyCalls(fn, 1); + }); + }); + await t.step("if the 'fn' throws an error", async (t) => { + const fn = spy(returnsNext([new Error("test error")])); + const id = lambda.register(denops, fn, { once: true }); + await denops.dispatch(denops.name, id).catch(NOOP); + assertSpyCalls(fn, 1); + + await t.step("which will be removed if called once", async () => { + const error = await assertRejects( + () => denops.dispatch(denops.name, id), + ); + assertStringIncludes( + error as string, + "denops.dispatcher[name] is not a function", + ); + assertSpyCalls(fn, 1); + }); + }); + await t.step("if the 'fn' rejects an error", async (t) => { + const fn = spy(resolvesNext([new Error("test error")])); + const id = lambda.register(denops, fn, { once: true }); + await denops.dispatch(denops.name, id).catch(NOOP); + assertSpyCalls(fn, 1); + + await t.step("which will be removed if called once", async () => { + const error = await assertRejects( + () => denops.dispatch(denops.name, id), + ); + assertStringIncludes( + error as string, + "denops.dispatcher[name] is not a function", + ); + assertSpyCalls(fn, 1); + }); + }); + }); }); + await t.step("unregister()", async (t) => { + await t.step("if 'id' is registered", async (t) => { + await t.step("unregisters the lambda function", async () => { + const fn = spy(returnsNext(["foo"])); + const id = lambda.register(denops, fn); + lambda.unregister(denops, id); - await t.step({ - name: - "unregister() unregisters a lambda function identified by the identifier", - fn: async () => { - const id = lambda.register( - denops, - () => Promise.resolve("0"), - ); - assertEquals(lambda.unregister(denops, id), true); - - // The method is removed - await assertRejects( - async () => { - await denops.dispatch(denops.name, id); - }, - `No method '${id}' exists`, - ); - }, + const error = await assertRejects( + () => denops.dispatch(denops.name, id), + ); + assertStringIncludes( + error as string, + "denops.dispatcher[name] is not a function", + ); + assertSpyCalls(fn, 0); + }); + await t.step("returns `true`", () => { + const id = lambda.register(denops, () => "foo"); + assertEquals(lambda.unregister(denops, id), true); + }); + }); + await t.step("if 'id' is not registered", async (t) => { + await t.step("does not unregister lambda functions", async () => { + const fn = spy(returnsNext(["foo"])); + const id = lambda.register(denops, fn); + lambda.unregister(denops, "not-registered-id"); + + assertSpyCalls(fn, 0); + assertEquals( + await denops.dispatch(denops.name, id), + "foo", + "The method is available", + ); + assertSpyCalls(fn, 1); + }); + await t.step("returns `false`", () => { + assertEquals(lambda.unregister(denops, "not-registered-id"), false); + }); + }); }); + await t.step("add()", async (t) => { + await t.step("registers a lambda function", async () => { + // Unregister all methods + denops.dispatcher = {}; + const fn = spy(returnsNext(["foo", "bar"])); + using lo = lambda.add(denops, fn); - await t.step({ - name: "add() registers a lambda function and returns a Lambda object", - fn: async () => { - let counter = 0; - using lo = lambda.add( - denops, - () => { - counter++; - return counter; - }, - ); - assertEquals( - lo.request(), - `denops#request('${denops.name}', '${lo.id}', [])`, + assertEquals(Object.keys(denops.dispatcher), [lo.id]); + assertSpyCalls(fn, 0); + assertEquals(await denops.dispatch(denops.name, lo.id), "foo"); + assertEquals(await denops.dispatch(denops.name, lo.id), "bar"); + assertSpyCalls(fn, 2); + }); + await t.step("returns a Lambda object", () => { + using lo = lambda.add(denops, () => "foo"); + + assert( + lo, + is.ObjectOf({ + id: is.String, + notify: is.Function, + request: is.Function, + dispose: is.Function, + [Symbol.dispose]: is.Function, + }), ); + }); + }); + await t.step("Lambda", async (t) => { + await t.step(".notify() returns a Vim expression", async (t) => { + await t.step("that calls the registered `fn`", async (t) => { + const tests: readonly ( + [name: string, args: unknown[], expected: unknown[]] + )[] = [ + ["no", [], []], + ["string", ["foo", "bar"], ["foo", "bar"]], + ["number", [123, 456], [123, 456]], + ["undefined", [undefined], [null]], + ["null", [null], [null]], + ["boolean", [true, false, false], [true, false, false]], + ["Function", [() => 0, () => 1], [null, null]], + ["symbol", [Symbol("foo"), Symbol("bar")], [null, null]], + ["Array", [["a", 0], ["b", 1]], [["a", 0], ["b", 1]]], + [ + "Object", + [{ foo: "a" }, { bar: 0 }], + [{ foo: "a" }, { bar: 0 }], + ], + [ + "Object with symbol keys", + [{ [Symbol("foo")]: "a" }, { [Symbol("bar")]: "b" }], + [{}, {}], + ], + ["Expression", [expr`100 + 23`], [123]], + ["RawString", [rawString`\U0001F41C`], ["\u{0001F41C}"]], + ]; + for (const [name, args, expected] of tests) { + await t.step(`with ${name} arguments`, async () => { + const fn = spy(returnsNext("foo")); + using lo = lambda.add(denops, fn); + assertSpyCalls(fn, 0); + await denops.eval(lo.notify(...args)); + assertSpyCalls(fn, 1); + assertSpyCallArgs(fn, 0, expected); + }); + } + }); + await t.step("that evaluates to 0", async (t) => { + const tests: readonly ( + [name: string, returned: unknown] + )[] = [ + ["string", "foo"], + ["number", 123], + ["undefined", undefined], + ["null", null], + ["true", true], + ["false", false], + ["Function", () => 0], + ["symbol", Symbol("foo")], + ["Array", [0, 1]], + ["Object", { foo: "a" }], + ["Object with symbol keys", { [Symbol("foo")]: "a" }], + ["Expression", expr`100 + 23`], + ["RawString", rawString`\U0001F41C`], + ]; + for (const [name, returned] of tests) { + await t.step(`which returns ${name}`, async () => { + const fn = spy(returnsNext([returned])); + using lo = lambda.add(denops, fn); + const actual = await denops.eval(lo.notify()); + assertEquals(actual, 0); + }); + } + }); + }); + await t.step(".request() returns a Vim expression", async (t) => { + await t.step("that calls the registered `fn`", async (t) => { + const tests: readonly ( + [name: string, args: unknown[], expected: unknown[]] + )[] = [ + ["no", [], []], + ["string", ["foo", "bar"], ["foo", "bar"]], + ["number", [123, 456], [123, 456]], + ["undefined", [undefined], [null]], + ["null", [null], [null]], + ["boolean", [true, false, false], [true, false, false]], + ["Function", [() => 0, () => 1], [null, null]], + ["symbol", [Symbol("foo"), Symbol("bar")], [null, null]], + ["Array", [["a", 0], ["b", 1]], [["a", 0], ["b", 1]]], + [ + "Object", + [{ foo: "a" }, { bar: 0 }], + [{ foo: "a" }, { bar: 0 }], + ], + [ + "Object with symbol keys", + [{ [Symbol("foo")]: "a" }, { [Symbol("bar")]: "b" }], + [{}, {}], + ], + ["Expression", [expr`100 + 23`], [123]], + ["RawString", [rawString`\U0001F41C`], ["\u{0001F41C}"]], + ]; + for (const [name, args, expected] of tests) { + await t.step(`with ${name} arguments`, async () => { + const fn = spy(returnsNext(["foo"])); + using lo = lambda.add(denops, fn); + assertSpyCalls(fn, 0); + await denops.eval(lo.request(...args)); + assertSpyCalls(fn, 1); + assertSpyCallArgs(fn, 0, expected); + }); + } + }); + await t.step("that evaluates the registered `fn`", async (t) => { + const tests: readonly ( + [name: string, returned: unknown, expected: unknown] + )[] = [ + ["string", "foo", "foo"], + ["number", 123, 123], + ["undefined", undefined, null], + ["null", null, null], + ["true", true, true], + ["false", false, false], + ["Function", () => 0, null], + ["symbol", Symbol("foo"), null], + ["Array", [0, 1], [0, 1]], + ["Object", { foo: "a" }, { foo: "a" }], + ["Object with symbol keys", { [Symbol("foo")]: "a" }, {}], + ["Expression", expr`100 + 23`, 123], + ["RawString", rawString`\U0001F41C`, "\u{0001F41C}"], + ]; + for (const [name, returned, expected] of tests) { + await t.step(`which returns ${name}`, async () => { + const fn = spy(returnsNext([returned])); + using lo = lambda.add(denops, fn); + const actual = await denops.eval(lo.request()); + assertEquals(actual, expected); + }); + } + }); + }); + await t.step(".dispose() unregisters the lambda function", async () => { + // Unregister all methods + denops.dispatcher = {}; + const fn = spy(returnsNext(["foo"])); + using lo = lambda.add(denops, fn); + await denops.dispatch(denops.name, lo.id); + assertSpyCalls(fn, 1); assertEquals( - lo.notify(), - `denops#notify('${denops.name}', '${lo.id}', [])`, + Object.keys(denops.dispatcher), + [lo.id], + "The method is available", ); - assertEquals(await denops.eval(lo.request()), 1); - await denops.eval(lo.notify()); - assertEquals(counter, 2); - }, - }); + lo.dispose(); - await t.step({ - name: - "add() registers a lambda function with arguments and returns a Lambda object", - fn: async () => { - using lo = lambda.add( - denops, - (a: unknown, b: unknown, c: unknown) => { - return [a, b, c]; - }, + const error = await assertRejects( + () => denops.dispatch(denops.name, lo.id), ); - assertEquals( - lo.request(1, "2", [3]), - `denops#request('${denops.name}', '${lo.id}', [1,"2",[3]])`, + assertStringIncludes( + error as string, + "denops.dispatcher[name] is not a function", ); + assertSpyCalls(fn, 1); assertEquals( - lo.notify(1, "2", [3]), - `denops#notify('${denops.name}', '${lo.id}', [1,"2",[3]])`, + Object.keys(denops.dispatcher), + [], + "The method is unregistered", ); - assertEquals(await denops.eval(lo.request(1, "2", [3])), [1, "2", [3]]); - }, - }); - - await t.step({ - name: "add() return a disposable object", - fn: async () => { + }); + await t.step("is diposable", async () => { // Unregister all methods denops.dispatcher = {}; + const fn = spy(returnsNext(["foo"])); + let id: string; { - let counter = 0; - using lo = lambda.add( - denops, - () => { - counter++; - return counter; - }, + using lo = lambda.add(denops, fn); + id = lo.id; + await denops.dispatch(denops.name, lo.id); + + assertSpyCalls(fn, 1); + assertEquals( + Object.keys(denops.dispatcher), + [lo.id], + "The method is available", ); - assertEquals(await denops.eval(lo.request()), 1); - await denops.eval(lo.notify()); - assertEquals(counter, 2); - // Still available - assertEquals(Object.keys(denops.dispatcher), [lo.id]); } - // Unregistered automatically - assertEquals(Object.keys(denops.dispatcher), []); - }, + + const error = await assertRejects( + () => denops.dispatch(denops.name, id), + ); + assertStringIncludes( + error as string, + "denops.dispatcher[name] is not a function", + ); + assertSpyCalls(fn, 1); + assertEquals( + Object.keys(denops.dispatcher), + [], + "The method is unregistered", + ); + }); }); }, });