Skip to content

Commit d8f8169

Browse files
authored
feat: support importing widget modules by hash (#557)
The diff on `userWidget.tsx` explains the purpose of this PR.
1 parent 2a1241f commit d8f8169

File tree

4 files changed

+74
-11
lines changed

4 files changed

+74
-11
lines changed

lean4-infoview/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"@leanprover/infoview-api": "~0.4.0",
5353
"@vscode/codicons": "^0.0.32",
5454
"@vscode-elements/react-elements": "^0.5.0",
55+
"es-module-lexer": "^1.5.4",
5556
"es-module-shims": "^1.7.3",
5657
"react-fast-compare": "^3.2.2",
5758
"tachyons": "^4.12.0",

lean4-infoview/rollup.config.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,9 @@ const configs = [
7474
{
7575
output: {
7676
...output,
77-
// Put `es-module-shims` in shim mode with support for dynamic `import`
77+
// Put `es-module-shims` in shim mode, needed to support dynamic `import`s.
78+
// This code has to be set before `es-module-shims` is loaded,
79+
// so we put it in the Rollup intro.
7880
intro: 'window.esmsInitOptions = { shimMode: true }',
7981
},
8082
plugins,

lean4-infoview/src/infoview/userWidget.tsx

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as esmlexer from 'es-module-lexer'
12
import * as React from 'react'
23

34
import {
@@ -13,27 +14,86 @@ import { GoalsLocation } from './goalLocation'
1314
import { useRpcSession } from './rpcSessions'
1415
import { DocumentPosition, mapRpcError, useAsyncPersistent } from './util'
1516

16-
async function dynamicallyLoadModule(hash: string, code: string): Promise<any> {
17+
async function dynamicallyLoadModule(hash: string, code: string): Promise<[any, string]> {
1718
const file = new File([code], `widget_${hash}.js`, { type: 'text/javascript' })
1819
const url = URL.createObjectURL(file)
19-
return await import(url)
20+
return [await import(url), url]
2021
}
2122

22-
const moduleCache = new Map<string, any>()
23+
/** Maps module hash to (loaded module, its URI). */
24+
const moduleCache = new Map<string, [any, string]>()
2325

2426
/**
2527
* Fetch source code from Lean and dynamically import it as a JS module.
2628
*
27-
* The source must hash to `hash` (in Lean) and must have been annotated with `@[widget]`
28-
* or `@[widget_module]` at some point before `pos`. */
29+
* The source must hash to `hash` (in Lean)
30+
* and must have been annotated with `@[widget]` or `@[widget_module]`
31+
* at some point before `pos`.
32+
*
33+
* If `hash` does not correspond to a registered module,
34+
* the promise is rejected with an error.
35+
*
36+
* #### Experimental `import` support for widget modules
37+
*
38+
* The module may import other `@[widget_module]`s by hash
39+
* using the URI scheme `'widget_module:hash,<hash>'`
40+
* where `<hash>` is a decimal representation
41+
* of the hash stored in `Lean.Widget.Module.javascriptHash`.
42+
*
43+
* In the future,
44+
* we may support importing widget modules by their fully qualified Lean name
45+
* (e.g. `'widget_module:name,Lean.Meta.Tactic.TryThis.tryThisWidget'`),
46+
* or some way to assign widget modules a more NPM-friendly name
47+
* so that the usual URIs (e.g. `'@leanprover-community/pro-widgets'`) work.
48+
*/
2949
export async function importWidgetModule(rs: RpcSessionAtPos, pos: DocumentPosition, hash: string): Promise<any> {
3050
if (moduleCache.has(hash)) {
3151
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
32-
return moduleCache.get(hash)!
52+
const [mod, _] = moduleCache.get(hash)!
53+
return mod
3354
}
3455
const resp = await Widget_getWidgetSource(rs, pos, hash)
35-
const mod = await dynamicallyLoadModule(hash, resp.sourcetext)
36-
moduleCache.set(hash, mod)
56+
let src = resp.sourcetext
57+
58+
/*
59+
* Now we want to handle imports of other `@[widget_module]`s in `src`.
60+
* At least two ways of doing this are possible:
61+
* 1. Set a module resolution hook in `es-module-shims` to look through a global list of resolvers,
62+
* and register such a resolver here before loading a new module.
63+
* The resolver would add appropriate entries into the import map
64+
* before `src` is loaded and makes use of those entries.
65+
* However, resolution hooking and dynamic import maps are not standard features
66+
* so necessarily require `es-module-shims`;
67+
* they would not work with any current browser's ES module implementation.
68+
* Furthermore, this variant involves complex global state.
69+
* 2. Before loading the module, parse its imports,
70+
* recursively import any widget modules,
71+
* and replace widget module imports with `blob:` URIs.
72+
* We do this as it is independent of `es-module-shims`.
73+
* A disadvantage is that this variant does not modify the global import map,
74+
* so any module that is not imported as a widget module (e.g. is imported from NPM)
75+
* cannot import widget modules.
76+
*/
77+
78+
await esmlexer.init
79+
const [imports] = esmlexer.parse(src)
80+
// How far indices into `src` after the last-processed `import`
81+
// are offset from indices into `resp.sourcetext`
82+
let off = 0
83+
for (const i of imports) {
84+
const HASH_URI_SCHEME = 'widget_module:hash,'
85+
if (i.n?.startsWith(HASH_URI_SCHEME)) {
86+
const h = i.n.substring(HASH_URI_SCHEME.length)
87+
await importWidgetModule(rs, pos, h)
88+
// `moduleCache.has(h)` is a postcondition of `importWidgetModule`
89+
const [_, uri] = moduleCache.get(h)!
90+
// Replace imported module name with the new URI
91+
src = src.substring(0, i.s + off) + uri + src.substring(i.e + off)
92+
off += uri.length - i.n.length
93+
}
94+
}
95+
const [mod, uri] = await dynamicallyLoadModule(hash, src)
96+
moduleCache.set(hash, [mod, uri])
3797
return mod
3898
}
3999

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)