Skip to content

Commit 9f7a83b

Browse files
authored
Merge pull request #214 from vim-denops/exprstr
👍 Add expr-string helper
2 parents 86dda39 + 4d7127a commit 9f7a83b

File tree

2 files changed

+720
-0
lines changed

2 files changed

+720
-0
lines changed

denops_std/helper/expr_string.ts

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import type {
2+
Context,
3+
Denops,
4+
Dispatcher,
5+
Meta,
6+
} from "https://deno.land/x/denops_core@v5.0.0/mod.ts";
7+
import is from "https://deno.land/x/unknownutil@v3.10.0/is.ts";
8+
import { execute } from "./execute.ts";
9+
import { generateUniqueString } from "../util.ts";
10+
11+
const EXPR_STRING_MARK = "__denops_expr_string";
12+
13+
/**
14+
* String that marked as Vim's string constant format.
15+
*/
16+
export type ExprString = string & {
17+
/**
18+
* @internal
19+
*/
20+
readonly [EXPR_STRING_MARK]: 1;
21+
};
22+
23+
type Jsonable = {
24+
toJSON(key: string | number | undefined): string;
25+
};
26+
27+
// deno-lint-ignore no-explicit-any
28+
type TemplateSubstitutions = any[];
29+
30+
const cacheKey = "denops_std/helper/expr_string@1";
31+
32+
async function ensurePrerequisites(denops: Denops): Promise<string> {
33+
if (is.String(denops.context[cacheKey])) {
34+
return denops.context[cacheKey];
35+
}
36+
const suffix = generateUniqueString();
37+
denops.context[cacheKey] = suffix;
38+
const script = `
39+
let g:loaded_denops_std_helper_exprStr_${suffix} = 1
40+
41+
function DenopsStdHelperExprStringCall_${suffix}(fn, args) abort
42+
return call(a:fn, eval(a:args))
43+
endfunction
44+
`;
45+
await execute(denops, script);
46+
return suffix;
47+
}
48+
49+
/**
50+
* Tagged template function that marks a string as Vim's string constant format.
51+
* Returns a `String` wrapper object instead of a primitive string.
52+
*
53+
* ```typescript
54+
* import { exprQuote } from "./expr_string.ts";
55+
*
56+
* console.log(exprQuote`foo` == "foo"); // outputs: true
57+
* console.log(exprQuote`foo` === "foo"); // outputs: false
58+
* console.log(exprQuote`foo,${40 + 2}` == "foo,42"); // outputs: true
59+
* ```
60+
*
61+
* @see useExprString for usage
62+
*/
63+
export function exprQuote(
64+
template: TemplateStringsArray,
65+
...substitutions: TemplateSubstitutions
66+
): ExprString {
67+
const raw = String.raw(template, ...substitutions);
68+
return Object.assign(raw, {
69+
[EXPR_STRING_MARK]: 1 as const,
70+
});
71+
}
72+
73+
const isInstanceOfBoolean = is.InstanceOf(Boolean);
74+
const isInstanceOfNumber = is.InstanceOf(Number);
75+
const isInstanceOfString = is.InstanceOf(String);
76+
77+
/**
78+
* Returns `true` if the value is a string marked as Vim's string constant format.
79+
*
80+
* ```typescript
81+
* import { exprQuote, isExprString } from "./expr_string.ts";
82+
*
83+
* console.log(isExprString(exprQuote`foo`)); // outputs: true
84+
* console.log(isExprString("foo")); // outputs: false
85+
* ```
86+
*/
87+
export function isExprString(x: unknown): x is ExprString {
88+
return is.ObjectOf({
89+
[EXPR_STRING_MARK]: is.LiteralOf(1),
90+
})(x);
91+
}
92+
93+
function isJsonable(x: unknown): x is Jsonable {
94+
return x != null && is.Function((x as Jsonable).toJSON);
95+
}
96+
97+
function isIgnoreRecordValue(x: unknown): boolean {
98+
return is.Undefined(x) || is.Function(x) || is.Symbol(x);
99+
}
100+
101+
/**
102+
* @internal
103+
*/
104+
export function vimStringify(value: unknown, key?: string | number): string {
105+
if (isJsonable(value)) {
106+
return vimStringify(JSON.parse(value.toJSON(key)));
107+
}
108+
if (isExprString(value)) {
109+
// Return Vim's expr-string
110+
return `"${value.replaceAll('"', '\\"')}"`;
111+
}
112+
if ((is.Nullish(value) || is.Function(value) || is.Symbol(value))) {
113+
return "v:null";
114+
}
115+
if (is.Boolean(value) || isInstanceOfBoolean(value)) {
116+
// Return v:true or v:false
117+
return `v:${value}`;
118+
}
119+
if (is.Number(value) || isInstanceOfNumber(value)) {
120+
// Replace `5e-10` to `5.0e-10`
121+
return `${value}`.replace(/^(\d+)e/, "$1.0e");
122+
}
123+
if (is.String(value) || isInstanceOfString(value)) {
124+
// Returns Vim's literal-string
125+
return `'${value.replaceAll("'", "''")}'`;
126+
}
127+
if (is.Array(value)) {
128+
// Returns Vim's list
129+
return `[${value.map(vimStringify).join(",")}]`;
130+
}
131+
if (is.Record(value)) {
132+
// Returns Vim's dict
133+
return `{${
134+
Object.entries(value)
135+
.filter(([, value]) => !isIgnoreRecordValue(value))
136+
.map(([key, value]) =>
137+
`'${key.replaceAll("'", "''")}':${vimStringify(value, key)}`
138+
)
139+
.join(",")
140+
}}`;
141+
}
142+
const type = Object.prototype.toString.call(value).slice(8, -1);
143+
throw new TypeError(`${type} value can't be serialized`);
144+
}
145+
146+
function trimEndOfArgs(args: unknown[]): unknown[] {
147+
const last = args.findIndex(is.Undefined);
148+
return last < 0 ? args : args.slice(0, last);
149+
}
150+
151+
class ExprStringHelper implements Denops {
152+
#denops: Denops;
153+
154+
constructor(denops: Denops) {
155+
this.#denops = denops;
156+
}
157+
158+
get name(): string {
159+
return this.#denops.name;
160+
}
161+
162+
get meta(): Meta {
163+
return this.#denops.meta;
164+
}
165+
166+
get context(): Record<string | number | symbol, unknown> {
167+
return this.#denops.context;
168+
}
169+
170+
get dispatcher(): Dispatcher {
171+
return this.#denops.dispatcher;
172+
}
173+
174+
set dispatcher(dispatcher: Dispatcher) {
175+
this.#denops.dispatcher = dispatcher;
176+
}
177+
178+
redraw(force?: boolean): Promise<void> {
179+
return this.#denops.redraw(force);
180+
}
181+
182+
async call(fn: string, ...args: unknown[]): Promise<unknown> {
183+
const suffix = await ensurePrerequisites(this.#denops);
184+
return this.#denops.call(
185+
`DenopsStdHelperExprStringCall_${suffix}`,
186+
fn,
187+
vimStringify(trimEndOfArgs(args)),
188+
);
189+
}
190+
191+
async batch(...calls: [string, ...unknown[]][]): Promise<unknown[]> {
192+
const suffix = await ensurePrerequisites(this.#denops);
193+
const callHelper = `DenopsStdHelperExprStringCall_${suffix}`;
194+
return this.#denops.batch(
195+
...calls.map(([fn, ...args]): [string, ...unknown[]] => [
196+
callHelper,
197+
fn,
198+
vimStringify(trimEndOfArgs(args)),
199+
]),
200+
);
201+
}
202+
203+
async cmd(cmd: string, ctx: Context = {}): Promise<void> {
204+
await this.call("denops#api#cmd", cmd, ctx);
205+
}
206+
207+
eval(expr: string, ctx: Context = {}): Promise<unknown> {
208+
return this.call("denops#api#eval", expr, ctx);
209+
}
210+
211+
dispatch(name: string, fn: string, ...args: unknown[]): Promise<unknown> {
212+
return this.#denops.dispatch(name, fn, ...args);
213+
}
214+
}
215+
216+
/**
217+
* Call the denops function using Vim's string constant format.
218+
*
219+
* ```typescript
220+
* import { Denops } from "../mod.ts";
221+
* import { exprQuote as q, useExprString } from "./expr_string.ts";
222+
* import * as fn from "../function/mod.ts";
223+
*
224+
* export async function main(denops: Denops): Promise<void> {
225+
* await useExprString(denops, async (denops) => {
226+
* await fn.feedkeys(denops, q`\<Cmd>echo 'foo'\<CR>`)
227+
* await denops.cmd('echo value', { value: q`\U0001F680` })
228+
* });
229+
* }
230+
* ```
231+
*/
232+
export function useExprString<T extends unknown>(
233+
denops: Denops,
234+
executor: (helper: ExprStringHelper) => Promise<T>,
235+
): Promise<T> {
236+
const helper = new ExprStringHelper(denops);
237+
return executor(helper);
238+
}

0 commit comments

Comments
 (0)