diff --git a/debian/changelog b/debian/changelog index 6d46888dc..4020c9391 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,5 +1,17 @@ wb-mqtt-homeui (2.250.0) stable; urgency=medium + * Rules editor: imports are typed against the controller's own module + files. The language service resolves a rule's import specifiers through + the engine (Editor.ResolveModule RPC) - relative ones next to the file, + bare ones from the module directories - fetches the modules (transitively, + bounded) into its virtual file system and re-lints when an import added + while typing arrives, so exported types, signatures and completions of + imported modules are real instead of `any`; firmware without the RPC keeps + the wildcard fallback. Compiler options follow the engine: module + "preserve", allowImportingTsExtensions, a paths map for bare specifiers. + wb-rules.d.ts re-synced (import.meta). The explicit-format rule file + extensions the engine now loads (.mjs/.mts ES modules, .cjs/.cts classic + scripts) are editable: language mode, rename and copy keep them. * Rules editor: TypeScript support. .ts rule files can be created and edited with an in-browser TypeScript language service: live error squiggles while typing, hover type info and type-aware completions, diff --git a/frontend/src/pages/rules/[rule]/edit-rule-resolve-module.test.tsx b/frontend/src/pages/rules/[rule]/edit-rule-resolve-module.test.tsx new file mode 100644 index 000000000..ea2c98bf3 --- /dev/null +++ b/frontend/src/pages/rules/[rule]/edit-rule-resolve-module.test.tsx @@ -0,0 +1,122 @@ +// @vitest-environment happy-dom +// The import resolver handed to the language service: it goes through +// Editor.ResolveModule only when the controller advertises the method, and +// the advertisement check never delays the service itself (a negative +// answer takes the full advertisement timeout - firmware with GetTypes but +// without ResolveModule is the firmware in the field). +import { act, render, waitFor } from '@testing-library/react'; +import EditRulePage from './edit-rule'; + +const { rulesMock, paramsMock, getExtensionsMock, loadTsSupportMock } = vi.hoisted(() => ({ + rulesMock: { + rule: { + name: 'test-rule.js', + initName: 'test-rule.js', + content: 'import { x } from "mod";', + enabled: true, + error: null as any, + }, + load: vi.fn(async () => {}), + save: vi.fn(async () => 'test-rule.js'), + rename: vi.fn(async () => 'renamed.js'), + resetRule: vi.fn(), + setRule: vi.fn(), + setRuleName: vi.fn(), + checkIsNameUnique: vi.fn(async () => true), + tsCheckDiags: [], + checkTsFile: vi.fn(async () => {}), + clearTsCheck: vi.fn(), + }, + paramsMock: { '*': 'test-rule.js' } as Record, + getExtensionsMock: vi.fn(() => [] as any[]), + loadTsSupportMock: vi.fn(), +})); + +vi.mock('@/services', () => import('@/test/mocks/services')); +vi.mock('@/stores/rules', () => ({ rulesStore: rulesMock })); +vi.mock('@/stores/rules/autocomplete', () => ({ getExtensions: getExtensionsMock })); +vi.mock('@/stores/rules/autocomplete/ts-language-service', () => ({ + loadTsEditorSupport: loadTsSupportMock, +})); +vi.mock('@/stores/auth', () => ({ + authStore: { hasRights: vi.fn(() => true) }, + UserRole: { Admin: 'admin' }, +})); +vi.mock('@/stores/devices', () => ({ devicesStore: {} })); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useParams: () => paramsMock, + useNavigate: () => vi.fn(), + }; +}); +vi.mock('@/common/links', () => ({ + documentation: { en: { rule: '#rule-docs' } }, +})); +vi.mock('@/utils/prevent-page-leave', () => ({ + usePreventLeavePage: () => ({ setIsDirty: vi.fn() }), +})); +vi.mock('@/components/button', () => ({ + Button: ({ label, onClick }: any) => , +})); +vi.mock('@/components/code-editor', () => ({ + CodeEditor: () =>
, +})); +vi.mock('@/components/tag', () => ({ + Tag: ({ children }: any) => {children}, +})); +vi.mock('@/layouts/page', () => ({ + PageLayout: ({ children, actions }: any) =>
{actions}{children}
, +})); + +const { editorProxyMock } = await import('@/test/mocks/services'); + +// the resolver argument of the last loadTsEditorSupport call +const lastResolver = () => loadTsSupportMock.mock.calls.at(-1)![4] as (f: string, s: string) => Promise; + +beforeEach(() => { + vi.clearAllMocks(); + paramsMock['*'] = 'test-rule.js'; + rulesMock.load.mockResolvedValue(undefined); + getExtensionsMock.mockReturnValue([]); + editorProxyMock.GetTypes.mockResolvedValue({ content: 'declare const t: 1;' }); + loadTsSupportMock.mockResolvedValue({ + extensions: [], + completionSource: () => null, + getDiagnostics: () => [], + reseed: () => {}, + refreshImports: async () => false, + }); +}); + +describe('the import resolver passed to the language service', () => { + test('GetTypes without ResolveModule: the service builds without waiting, the resolver answers null', async () => { + let answerResolveModule: (has: boolean) => void = () => {}; + editorProxyMock.hasMethod.mockImplementation((m?: string) => (m === 'ResolveModule' + ? new Promise((resolve) => { + answerResolveModule = resolve; + }) + : Promise.resolve(true))); + render(); + // the service is built while the ResolveModule advertisement is still unanswered + await waitFor(() => expect(loadTsSupportMock).toHaveBeenCalled()); + const pending = lastResolver()('test-rule.js', 'mod'); + answerResolveModule(false); + expect(await pending).toBeNull(); + expect(editorProxyMock.ResolveModule).not.toHaveBeenCalled(); + }); + + test('advertising firmware: the resolver calls Editor.ResolveModule and maps a failure to null', async () => { + editorProxyMock.hasMethod.mockResolvedValue(true); + editorProxyMock.ResolveModule.mockResolvedValueOnce({ path: '/etc/wb-rules-modules/mod.js', content: 'export {}' }); + render(); + await waitFor(() => expect(loadTsSupportMock).toHaveBeenCalled()); + await act(async () => {}); + expect(await lastResolver()('test-rule.js', 'mod')) + .toEqual({ path: '/etc/wb-rules-modules/mod.js', content: 'export {}' }); + expect(editorProxyMock.ResolveModule).toHaveBeenCalledWith({ from: 'test-rule.js', specifier: 'mod' }); + editorProxyMock.ResolveModule.mockRejectedValueOnce({ code: 1003, message: 'cannot find module' }); + expect(await lastResolver()('test-rule.js', 'nope')).toBeNull(); + }); +}); diff --git a/frontend/src/pages/rules/[rule]/edit-rule-ts-support.test.tsx b/frontend/src/pages/rules/[rule]/edit-rule-ts-support.test.tsx index c352824c4..a97d54f17 100644 --- a/frontend/src/pages/rules/[rule]/edit-rule-ts-support.test.tsx +++ b/frontend/src/pages/rules/[rule]/edit-rule-ts-support.test.tsx @@ -102,6 +102,7 @@ describe('language service gating on Editor.GetTypes', () => { 'defineRule("test", {})', 'declare const controllerTypes: 1;', expect.any(String), + expect.any(Function), // the import resolver (Editor.ResolveModule) ); }); @@ -115,6 +116,7 @@ describe('language service gating on Editor.GetTypes', () => { 'defineRule("test", {})', undefined, expect.any(String), + expect.any(Function), ); }); diff --git a/frontend/src/pages/rules/[rule]/edit-rule.tsx b/frontend/src/pages/rules/[rule]/edit-rule.tsx index 8e2caeb7a..da0360c56 100644 --- a/frontend/src/pages/rules/[rule]/edit-rule.tsx +++ b/frontend/src/pages/rules/[rule]/edit-rule.tsx @@ -18,6 +18,7 @@ import { controllerDiagnostics } from '@/stores/rules/autocomplete/controller-di import { loadErrorDiagnostics } from '@/stores/rules/autocomplete/load-error'; import { buildControlsRegistry } from '@/stores/rules/autocomplete/registry'; import { runtimeErrorDiagnostics } from '@/stores/rules/autocomplete/runtime-errors'; +import { TS_RULE_FILE_EXTENSION_RX } from '@/stores/rules/rule-file-extension'; import { useAsyncAction } from '@/utils/async-action'; import { usePreventLeavePage } from '@/utils/prevent-page-leave'; import './styles.css'; @@ -38,7 +39,7 @@ const EditRulePage = observer(() => { const [problems, setProblems] = useState({ errors: 0, warnings: 0, total: 0 }); const editorViewRef = useRef(null); const ruleFileName = params['*'] || rule.name || ''; - const isTypeScript = ruleFileName.endsWith('.ts'); + const isTypeScript = TS_RULE_FILE_EXTENSION_RX.test(ruleFileName); const [tsSupport, setTsSupport] = useState(null); // a stable placeholder for an unsaved rule, so typing a title does not rebuild the service const servicePath = params['*'] || (isTypeScript ? 'unsaved.ts' : 'unsaved.js'); @@ -65,6 +66,19 @@ const EditRulePage = observer(() => { new Promise((resolve) => setTimeout(() => resolve(undefined), 5000)), ]); const registryDts = buildControlsRegistry(devicesStore); + // Imports are typed against the controller's own module files + // (Editor.ResolveModule); firmware without the method keeps the + // wildcard `any` for every import. The advertisement check is folded + // into the resolver rather than awaited up front: a negative answer + // takes the full advertisement timeout, which must not delay the + // language service of a file with no imports (the prefetch's own + // deadline bounds it for a file with some). + const hasResolveModule = Promise.resolve() + .then(() => editorProxy.hasMethod('ResolveModule')) + .catch(() => true); + const resolveModule = (from: string, specifier: string) => hasResolveModule.then((has) => (has + ? editorProxy.ResolveModule({ from, specifier }).then((r) => r ?? null, () => null) + : null)); Promise.all([ // the heavy TS chunk loads concurrently with the GetTypes reply hasGetTypes.then((has) => (has @@ -73,7 +87,7 @@ const EditRulePage = observer(() => { controllerTypes, ]) .then(([m, typesDts]) => (m && typesDts !== null - ? m.loadTsEditorSupport(servicePath, rule.content, typesDts, registryDts) + ? m.loadTsEditorSupport(servicePath, rule.content, typesDts, registryDts, resolveModule) : null)) .then( (support) => alive && setTsSupport(support), diff --git a/frontend/src/services/editor-proxy.ts b/frontend/src/services/editor-proxy.ts index 9dc1659ec..a272c4cd7 100644 --- a/frontend/src/services/editor-proxy.ts +++ b/frontend/src/services/editor-proxy.ts @@ -10,9 +10,12 @@ interface EditorProxyMethods { Rename: (params: { path: string; new_path: string }) => Promise; Check: (params: { path: string }) => Promise; GetTypes: () => Promise<{ content: string }>; + // the engine's own import resolution: `from` is a rule's virtual path or + // a module's absolute path, the reply the resolved module's path + source + ResolveModule: (params: { from: string; specifier: string }) => Promise<{ path: string; content: string }>; } export const editorProxy = createRpcProxy( 'wbrules/Editor', - ['ChangeState', 'List', 'Load', 'Save', 'Remove', 'Rename', 'Check', 'GetTypes'], + ['ChangeState', 'List', 'Load', 'Save', 'Remove', 'Rename', 'Check', 'GetTypes', 'ResolveModule'], ); diff --git a/frontend/src/stores/rules/autocomplete/import-refresh.test.ts b/frontend/src/stores/rules/autocomplete/import-refresh.test.ts new file mode 100644 index 000000000..64d06911f --- /dev/null +++ b/frontend/src/stores/rules/autocomplete/import-refresh.test.ts @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom +import { linter } from '@codemirror/lint'; +import { EditorState } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; +import { importRefreshPlugin } from './import-refresh'; +import { lintRefresher } from './lint-refresh'; + +// The plugin that keeps imports typed while typing: after a document change +// (debounced) it asks refreshImports for the current text; when a module +// arrived it re-runs the lint pass from the outside (a lint source with the +// refresher's needsRefresh). It runs once on view creation too. + +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +async function until(cond: () => boolean, timeoutMs = 3000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (cond()) return; + await wait(25); + } + throw new Error('condition not met in time'); +} + +function makeView( + refresh: (source: string) => Promise, + enabled: boolean, + onLint: () => void, +) { + const refresher = lintRefresher(); + return new EditorView({ + state: EditorState.create({ + doc: 'log(1);', + extensions: [ + linter(() => { + onLint(); + return []; + }, { delay: 50, needsRefresh: refresher.needsRefresh }), + importRefreshPlugin(refresh, refresher, enabled), + ], + }), + parent: document.body, + }); +} + +describe('importRefreshPlugin', () => { + it('runs once on creation, then after a debounced edit, and re-lints only when a module arrived', async () => { + const asked: string[] = []; + let lints = 0; + const view = makeView(async (src) => { + asked.push(src); + return src.includes('import'); + }, true, () => { + lints++; + }); + try { + // creation: one refresh with the initial text, nothing arrived - the + // lint pass count stays at the initial pass + await until(() => asked.length === 1); + expect(asked[0]).toBe('log(1);'); + await wait(300); + const lintsAfterInit = lints; + // typing: one debounced refresh for the final text, not one per keystroke + view.dispatch({ changes: { from: 0, insert: 'import' } }); + await wait(100); + view.dispatch({ changes: { from: 6, insert: ' "m";\n' } }); + await until(() => asked.length === 2); + expect(asked[1]).toBe('import "m";\nlog(1);'); + // a module arrived: a lint pass runs without a further document change + await until(() => lints > lintsAfterInit + 1); + } finally { + view.destroy(); + } + }); + + it('does nothing without a resolver (legacy firmware)', async () => { + const refresh = vi.fn(async () => true); + const view = makeView(refresh, false, () => {}); + try { + view.dispatch({ changes: { from: 0, insert: 'import "m";' } }); + await wait(700); + expect(refresh).not.toHaveBeenCalled(); + } finally { + view.destroy(); + } + }); + + it('a refresh landing after the view is destroyed does not touch it', async () => { + let settle: (v: boolean) => void = () => {}; + let lints = 0; + const view = makeView(() => new Promise((resolve) => { + settle = resolve; + }), true, () => { + lints++; + }); + await wait(600); // the creation refresh is in flight + view.destroy(); + const lintsAtDestroy = lints; + settle(true); + await wait(300); + expect(lints).toBe(lintsAtDestroy); + }); +}); diff --git a/frontend/src/stores/rules/autocomplete/import-refresh.ts b/frontend/src/stores/rules/autocomplete/import-refresh.ts new file mode 100644 index 000000000..78dd28d36 --- /dev/null +++ b/frontend/src/stores/rules/autocomplete/import-refresh.ts @@ -0,0 +1,46 @@ +import { ViewPlugin, type EditorView } from '@codemirror/view'; +import type { LintRefresher } from './types'; + +// Imports typed while you type. A specifier added in the editor is fetched +// from the controller (debounced) and the module dropped into the language +// service's environment by `refreshImports`; the lint pass is then re-run +// from the outside (see lint-refresh.ts), since the fetch completes after +// the keystroke's own pass. The same runs once when a view opens on a +// reused environment, so a rule reopened with new imports on disk catches +// up. `enabled` is false without a resolver (legacy firmware): the plugin +// then does nothing. +export function importRefreshPlugin( + refreshImports: (source: string) => Promise, + refresher: LintRefresher, + enabled: boolean, +) { + return ViewPlugin.define((view: EditorView) => { + let timer: ReturnType | null = null; + let disposed = false; + const run = () => { + timer = null; + refreshImports(view.state.doc.toString()).then( + (changed) => { + // never synchronously inside an update; and not after the view is + // gone (the page navigated away while the RPC was in flight) + if (changed && !disposed) setTimeout(() => !disposed && refresher.refresh(view), 0); + }, + () => {}, + ); + }; + const schedule = () => { + if (timer) clearTimeout(timer); + timer = setTimeout(run, 400); + }; + if (enabled) schedule(); + return { + update: (update) => { + if (update.docChanged && enabled) schedule(); + }, + destroy: () => { + disposed = true; + if (timer) clearTimeout(timer); + }, + }; + }); +} diff --git a/frontend/src/stores/rules/autocomplete/module-resolution.test.ts b/frontend/src/stores/rules/autocomplete/module-resolution.test.ts new file mode 100644 index 000000000..283e08fcf --- /dev/null +++ b/frontend/src/stores/rules/autocomplete/module-resolution.test.ts @@ -0,0 +1,176 @@ +import { + blankComments, + importSpecifiers, + joinVfs, + MODULES_ROOT, + newImportGraph, + prefetchImports, + vfsPathFor, +} from './module-resolution'; +import type { ModuleResolver, ResolvedModule } from './types'; + +describe('importSpecifiers', () => { + it('finds static, side-effect, dynamic and require specifiers once each, in order', () => { + const src = [ + 'import a from "x";', + 'import { b, c } from \'./lib/y.ts\';', + 'import * as ns from "dir/z";', + 'export { d } from "../up";', + 'export * from "x";', // repeated: once + 'import "side-effect";', + 'const m = await import("dyn");', + 'const r = require(\'cjs-mod\');', + 'log("not from \\"nope\\"");', + ].join('\n'); + expect(importSpecifiers(src)).toEqual([ + 'x', './lib/y.ts', 'dir/z', '../up', 'side-effect', 'dyn', 'cjs-mod', + ]); + }); + + it('keeps document order across the specifier forms', () => { + expect(importSpecifiers('import "sfx";\nimport a from "m";\nconst d = await import("dyn");')) + .toEqual(['sfx', 'm', 'dyn']); + }); + + it('ignores import.meta and identifiers merely named import', () => { + expect(importSpecifiers('log(import.meta.filename); const importer = 1;')).toEqual([]); + }); + + it('sees through comments inside a multi-line import clause and ignores commented-out imports', () => { + const src = [ + 'import {', + ' // don\'t use this one; it\'s slow', + ' helper, /* the "good" one */', + '} from "utils";', + '// import x from "commented-out";', + '/* import y from "also-out"; */', + 'const u = "http://example.com/"; import z from "after-url";', + '', + ].join('\n'); + expect(importSpecifiers(src)).toEqual(['utils', 'after-url']); + }); +}); + +describe('blankComments', () => { + it('blanks comments, keeps strings and the text length', () => { + const src = 'a // c\nb /* d\ne */ f "s//t" \'q/*r\' `t//u`'; + const out = blankComments(src); + expect(out).toHaveLength(src.length); + expect(out).toBe('a \nb \n f "s//t" \'q/*r\' `t//u`'); + }); +}); + +describe('vfs placement', () => { + it('joins and normalises paths', () => { + expect(joinVfs('/a/b', '../c/./d')).toBe('/a/c/d'); + expect(joinVfs('/', 'x')).toBe('/x'); + expect(joinVfs('/a', '../../x')).toBe('/x'); + }); + + it('places relative imports next to the importer with the resolved extension', () => { + expect(vfsPathFor('/rules/a.ts', './lib', '/etc/wb-rules/lib.ts')).toBe('/rules/lib.ts'); + expect(vfsPathFor('/rules/a.ts', './lib.js', '/etc/wb-rules/lib.ts')).toBe('/rules/lib.ts'); + expect(vfsPathFor('/a.js', './esmlib/sib.js', '/etc/wb-rules/esmlib/sib.js')).toBe('/esmlib/sib.js'); + expect(vfsPathFor('/rules/sub/a.ts', '../x', '/etc/wb-rules/rules/x.js')).toBe('/rules/x.js'); + }); + + it('places bare imports under the modules root, mirroring the specifier', () => { + expect(vfsPathFor('/a.ts', 'test/esm/typed', '/etc/wb-rules-modules/test/esm/typed.ts')) + .toBe(MODULES_ROOT + '/test/esm/typed.ts'); + expect(vfsPathFor('/a.ts', 'x.mod', '/usr/share/wb-rules-modules/x.mod.js')) + .toBe(MODULES_ROOT + '/x.mod.js'); + // a module's own relative import lands consistently + expect(vfsPathFor(MODULES_ROOT + '/test/esm/helper.js', './util.js', '/etc/wb-rules-modules/test/esm/util.js')) + .toBe(MODULES_ROOT + '/test/esm/util.js'); + }); + + it('keeps absolute imports at their path', () => { + expect(vfsPathFor('/a.ts', '/opt/x.js', '/opt/x.js')).toBe('/opt/x.js'); + }); +}); + +describe('prefetchImports', () => { + const files: Record = { + 'a.ts\0./lib.ts': { path: '/etc/wb-rules/lib.ts', content: 'import { u } from "util"; export const l = u;' }, + '/etc/wb-rules/lib.ts\0util': { + path: '/etc/wb-rules-modules/util.js', + content: 'import "./deep.js"; export const u = 1;', + }, + '/etc/wb-rules-modules/util.js\0./deep.js': { path: '/etc/wb-rules-modules/deep.js', content: 'export {};' }, + }; + const calls: string[] = []; + const resolver: ModuleResolver = async (from, spec) => { + calls.push(from + ' ' + spec); + return files[from + '\0' + spec] ?? null; + }; + + beforeEach(() => { + calls.length = 0; + }); + + it('follows imports transitively, placing each file once', async () => { + const graph = newImportGraph('/a.ts'); + const added = await prefetchImports( + resolver, graph, '/a.ts', 'a.ts', 'import { l } from "./lib.ts"; import "nope";', + ); + expect(added).toEqual(['/lib.ts', MODULES_ROOT + '/util.js', MODULES_ROOT + '/deep.js']); + expect(graph.files.get('/lib.ts')).toContain('export const l'); + expect(calls).toEqual([ + 'a.ts ./lib.ts', 'a.ts nope', '/etc/wb-rules/lib.ts util', '/etc/wb-rules-modules/util.js ./deep.js', + ]); + // a second run learns nothing new and asks nothing again + expect(await prefetchImports(resolver, graph, '/a.ts', 'a.ts', 'import { l } from "./lib.ts";')).toEqual([]); + expect(calls).toHaveLength(4); + // a new specifier is fetched incrementally + files['a.ts\0late'] = { path: '/etc/wb-rules-modules/late.js', content: 'export const late = 1;' }; + expect(await prefetchImports(resolver, graph, '/a.ts', 'a.ts', 'import { late } from "late";')) + .toEqual([MODULES_ROOT + '/late.js']); + }); + + it('bounds the number of files and the depth', async () => { + const wide = Array.from({ length: 10 }, (_, i) => `import "m${i}";`).join('\n'); + const many: ModuleResolver = async (_from, spec) => ({ path: `/mods/${spec}.js`, content: '' }); + const graph = newImportGraph('/a.ts'); + const added = await prefetchImports(many, graph, '/a.ts', 'a.ts', wide, { maxFiles: 3 }); + expect(added).toHaveLength(3); + + const chain: ModuleResolver = async (_from, spec) => { + const n = Number(spec.slice(1)); + return { path: `/mods/${spec}.js`, content: `import "c${n + 1}";` }; + }; + const deep = newImportGraph('/a.ts'); + expect(await prefetchImports(chain, deep, '/a.ts', 'a.ts', 'import "c0";', { maxDepth: 3 })) + .toEqual([MODULES_ROOT + '/c0.js', MODULES_ROOT + '/c1.js', MODULES_ROOT + '/c2.js']); + }); + + it('treats a throwing resolver and a malformed reply as unresolved', async () => { + const boom: ModuleResolver = async () => { + throw new Error('rpc down'); + }; + expect(await prefetchImports(boom, newImportGraph('/a.ts'), '/a.ts', 'a.ts', 'import "x";')).toEqual([]); + const junk: ModuleResolver = async (_from, spec) => (spec === 'nopath' + ? ({ content: 'x' } as ResolvedModule) + : ({ path: 'relative/x.js', content: 'x' })); + expect(await prefetchImports(junk, newImportGraph('/a.ts'), '/a.ts', 'a.ts', 'import "nopath"; import "rel";')) + .toEqual([]); + }); + + it('bounds the wall clock: a hung resolver is abandoned at the deadline', async () => { + const hung: ModuleResolver = () => new Promise(() => {}); + const started = Date.now(); + expect(await prefetchImports(hung, newImportGraph('/a.ts'), '/a.ts', 'a.ts', 'import "x"; import "y";', { + deadlineMs: 50, + })).toEqual([]); + expect(Date.now() - started).toBeLessThan(1000); + }); + + it('never replaces the file under edit with its on-disk copy (an import cycle back to the rule)', async () => { + const cyc: ModuleResolver = async (_from, spec) => (spec === './circ.ts' + ? { path: '/etc/wb-rules/circ.ts', content: 'import { a } from "./self.ts"; export const c = a;' } + : { path: '/etc/wb-rules/self.ts', content: 'ON DISK' }); + const graph = newImportGraph('/self.ts'); + expect(await prefetchImports(cyc, graph, '/self.ts', 'self.ts', 'import { c } from "./circ.ts";')) + .toEqual(['/circ.ts']); + expect(graph.files.has('/self.ts')).toBe(false); + }); +}); diff --git a/frontend/src/stores/rules/autocomplete/module-resolution.ts b/frontend/src/stores/rules/autocomplete/module-resolution.ts new file mode 100644 index 000000000..20b81d76d --- /dev/null +++ b/frontend/src/stores/rules/autocomplete/module-resolution.ts @@ -0,0 +1,243 @@ +import type { + ImportGraph, + ImportQueueItem, + ImportSet, + ModuleResolver, + PrefetchOptions, + ResolvedModule, +} from './types'; + +// Imports in the editor's language service. +// +// A rule file may import modules: bare specifiers ("my-helper", "dir/x") +// from the controller's module directories, relative ones ("./lib/x.ts") +// from next to the file, absolute ones as they are. The language service's +// virtual file system knows none of those files, so every import would fall +// to the `declare module "*"` wildcard: everything `any`, a `type` import an +// error, no completions for the module's exports. +// +// The controller resolves imports itself (Editor.ResolveModule: the engine's +// own resolution, returning the module file's source). This module scans a +// file for its specifiers, asks the resolver for each, places the sources in +// the virtual FS where TypeScript will find them, and follows the modules' +// own imports - bounded in files, depth and time, so a pathological module +// graph or an unresponsive controller cannot stall the editor. +// +// Placement (TypeScript resolves against the virtual FS exactly as against +// a real one, with bundler resolution): +// - relative: next to the importing file's virtual path, with the +// resolved file's real extension (`./lib` -> `lib.ts`); +// - absolute: at the real path; +// - bare: under MODULES_ROOT mirroring the specifier, mapped by the +// compiler option `paths: { "*": [MODULES_ROOT + "/*"] }`. +// A module's own relative imports then land where the controller resolves +// them too, because the module directories mirror the specifier structure. + +export const MODULES_ROOT = '/wb-rules-modules'; + +// at most this many module files are fetched for one rule (a wide import +// graph is served partially rather than stalling the editor) +const MAX_MODULE_FILES = 50; +// import chains deeper than this stay unresolved (the wildcard fallback) +const MAX_IMPORT_DEPTH = 8; +// wall-clock bound of one prefetch run +const DEFAULT_DEADLINE_MS = 5000; + +// CodeMirror normalizes line endings on ingest, so editor positions are +// LF-based; the language service must hold the same text or every +// diagnostic offset, completion and hover position after line 1 of a CRLF +// file drifts (one character per preceding line) +export const normalizeEol = (s: string) => s.replace(/\r\n/g, '\n'); + +// Blanks comments out of a source (keeping its length, so match indexes +// stay meaningful) while leaving string and template literals intact: a +// comment inside a multi-line import clause must not hide the import from +// the scan, and a specifier mentioned in a comment must not cost a lookup. +// A lexical pass, not a parser: a regex literal containing a quote or `//` +// can confuse it - the cost is one missed or wasted lookup, never an error. +export function blankComments(source: string): string { + const out: string[] = []; + let i = 0; + const n = source.length; + while (i < n) { + const c = source[i]; + const next = source[i + 1]; + if (c === '/' && next === '/') { + const end = source.indexOf('\n', i); + const stop = end < 0 ? n : end; + out.push(' '.repeat(stop - i)); + i = stop; + } else if (c === '/' && next === '*') { + const end = source.indexOf('*/', i + 2); + const stop = end < 0 ? n : end + 2; + // newlines kept so line-based reasoning elsewhere still holds + out.push(source.slice(i, stop).replace(/[^\n]/g, ' ')); + i = stop; + } else if (c === '"' || c === '\'' || c === '`') { + let j = i + 1; + while (j < n && source[j] !== c) { + if (source[j] === '\\') j++; + else if (c !== '`' && source[j] === '\n') break; // an unterminated string ends at the line + j++; + } + const stop = Math.min(n, j + 1); + out.push(source.slice(i, stop)); + i = stop; + } else { + out.push(c); + i++; + } + } + return out.join(''); +} + +// specifier positions: static import/export ... from "x", side-effect +// import "x", dynamic import("x") and require("x") +const SPECIFIER_RX = [ + /\b(?:import|export)\b[^'"`;]*?\bfrom\s*(['"])([^'"\n]+)\1/g, + /\bimport\s*(['"])([^'"\n]+)\1/g, + /\bimport\s*\(\s*(['"])([^'"\n]+)\1\s*\)/g, + /\brequire\s*\(\s*(['"])([^'"\n]+)\1\s*\)/g, +]; + +// the import specifiers of a source text, in order of first appearance, +// each once; comments are ignored +export function importSpecifiers(source: string): string[] { + const code = blankComments(source); + const found: { index: number; spec: string }[] = []; + for (const rx of SPECIFIER_RX) { + for (const m of code.matchAll(rx)) { + if (m[2]) found.push({ index: m.index, spec: m[2] }); + } + } + found.sort((a, b) => a.index - b.index); + const seen = new Set(); + const out: string[] = []; + for (const { spec } of found) { + if (seen.has(spec)) continue; + seen.add(spec); + out.push(spec); + } + return out; +} + +const isRelativeSpecifier = (spec: string) => spec.startsWith('./') || spec.startsWith('../'); +const isAbsoluteSpecifier = (spec: string) => spec.startsWith('/'); + +const dirname = (p: string) => { + const i = p.lastIndexOf('/'); + return i <= 0 ? '/' : p.slice(0, i); +}; +const basename = (p: string) => p.slice(p.lastIndexOf('/') + 1); + +// POSIX-style join with "." / ".." normalisation, always absolute +export function joinVfs(dir: string, rel: string): string { + const parts: string[] = []; + for (const seg of (dir + '/' + rel).split('/')) { + if (seg === '' || seg === '.') continue; + if (seg === '..') { + parts.pop(); + } else { + parts.push(seg); + } + } + return '/' + parts.join('/'); +} + +// where the resolved module goes in the virtual FS, see the header: the +// specifier's directory part as written (so "../x" keeps its "..") plus the +// resolved file's name +export function vfsPathFor(importerVfsPath: string, specifier: string, resolvedPath: string): string { + if (isAbsoluteSpecifier(specifier)) return resolvedPath; + const slash = specifier.lastIndexOf('/'); + const rel = (slash < 0 ? '' : specifier.slice(0, slash + 1)) + basename(resolvedPath); + return joinVfs(isRelativeSpecifier(specifier) ? dirname(importerVfsPath) : MODULES_ROOT, rel); +} + +// a well-formed reply: an absolute path and a source text +const isResolvedModule = (r: unknown): r is ResolvedModule => + !!r && + typeof (r as ResolvedModule).path === 'string' && + (r as ResolvedModule).path.startsWith('/') && + typeof (r as ResolvedModule).content === 'string'; + +export const newImportGraph = (rootVfsPath: string): ImportGraph => ({ + root: rootVfsPath, + files: new Map(), + asked: new Set(), +}); + +// Fetches the imports of `source` (a file at `vfsPath`, known to the +// controller as `from`) into `graph`, transitively. Returns the virtual FS +// paths added by this run (an empty array when nothing new was learned). +// Every resolver call is raced against the remaining deadline: a hung +// controller leaves the rest of the imports to the wildcard fallback +// instead of holding the editor. +export async function prefetchImports( + resolver: ModuleResolver, + graph: ImportGraph, + vfsPath: string, + from: string, + source: string, + options: PrefetchOptions = {}, +): Promise { + const maxFiles = options.maxFiles ?? MAX_MODULE_FILES; + const maxDepth = options.maxDepth ?? MAX_IMPORT_DEPTH; + const deadline = Date.now() + (options.deadlineMs ?? DEFAULT_DEADLINE_MS); + const added: string[] = []; + const queue: ImportQueueItem[] = [{ vfsPath, from, source, depth: 0 }]; + while (queue.length > 0) { + const item = queue.shift()!; + if (item.depth >= maxDepth) continue; + for (const spec of importSpecifiers(item.source)) { + const key = item.from + '\0' + spec; + if (graph.asked.has(key)) continue; + const left = deadline - Date.now(); + if (graph.files.size >= maxFiles || left <= 0) return added; + graph.asked.add(key); + let resolved: unknown; + let timer: ReturnType | undefined; + try { + resolved = await Promise.race([ + resolver(item.from, spec), + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), left); + }), + ]); + } catch { + resolved = null; + } + clearTimeout(timer); + if (!isResolvedModule(resolved)) continue; + const target = vfsPathFor(item.vfsPath, spec, resolved.path); + // the file under edit is never replaced by its on-disk copy (a cycle + // back to the rule); a module already placed keeps its first text + if (target === graph.root || graph.files.has(target)) continue; + graph.files.set(target, resolved.content); + added.push(target); + queue.push({ vfsPath: target, from: resolved.path, source: resolved.content, depth: item.depth + 1 }); + } + } + return added; +} + +// The imports of one language-service environment (the rule at `vfsPath`, +// known to the controller as `from`): `prefetch` collects them before the +// environment is built, `refresh` adds any new ones to a live environment. +// Without a resolver (legacy firmware) both are no-ops and every import +// falls to the `declare module "*"` any. +export function createImportSet(resolver: ModuleResolver | null, vfsPath: string, from: string): ImportSet { + const graph = newImportGraph(vfsPath); + const fetch = async (source: string) => (resolver ? prefetchImports(resolver, graph, vfsPath, from, source) : []); + return { + prefetch: async (source) => { + await fetch(source); + return graph.files; + }, + refresh: async (env, source) => { + const added = await fetch(source); + for (const modulePath of added) env.createFile(modulePath, normalizeEol(graph.files.get(modulePath)!)); + return added.length > 0; + }, + }; +} diff --git a/frontend/src/stores/rules/autocomplete/ts-diagnostics-linter.ts b/frontend/src/stores/rules/autocomplete/ts-diagnostics-linter.ts index 98e937c91..a10349169 100644 --- a/frontend/src/stores/rules/autocomplete/ts-diagnostics-linter.ts +++ b/frontend/src/stores/rules/autocomplete/ts-diagnostics-linter.ts @@ -2,16 +2,19 @@ import { linter, type Diagnostic as CmDiagnostic, type LintSource } from '@codem import type { Extension } from '@codemirror/state'; import type { VirtualTypeScriptEnvironment } from '@typescript/vfs'; import type { Diagnostic } from 'typescript'; -import type { TsModule } from './types'; +import type { LintRefresher, TsModule } from './types'; // TypeScript diagnostics as a CodeMirror lint source. Replaces valtown's tsLinter(), // which shows only the head of a chained message and drops the part that explains it. +// `refresher` lets an outside event (an imported module arriving) queue a pass +// without a document change (see lint-refresh.ts). export function tsDiagnosticsLinter( tsm: TsModule, env: VirtualTypeScriptEnvironment, path: string, + refresher?: LintRefresher, ): Extension { - return linter(tsDiagnosticsSource(tsm, env, path)); + return linter(tsDiagnosticsSource(tsm, env, path), refresher ? { needsRefresh: refresher.needsRefresh } : {}); } // exported for tests diff --git a/frontend/src/stores/rules/autocomplete/ts-language-service-imports.test.ts b/frontend/src/stores/rules/autocomplete/ts-language-service-imports.test.ts new file mode 100644 index 000000000..aa0c1d26b --- /dev/null +++ b/frontend/src/stores/rules/autocomplete/ts-language-service-imports.test.ts @@ -0,0 +1,113 @@ +import { loadTsEditorSupport } from './ts-language-service'; +import type { ModuleResolver, ResolvedModule } from './types'; + +// Imports typed against the controller's modules: the language service asks +// the resolver (Editor.ResolveModule) for every specifier and places the +// sources where TypeScript resolves them - relative next to the rule, bare +// under the modules root through the paths map. +describe('ts-language-service imports', () => { + const modules: Record = { + // a bare TypeScript module from the module directories + 'test/esm/typed': { + path: '/etc/wb-rules-modules/test/esm/typed.ts', + content: [ + 'export interface Point { x: number; y: number }', + 'export function typedAdd(a: number, b: number): number { return a + b; }', + '', + ].join('\n'), + }, + // a bare JavaScript ES module with its own relative import + 'test/esm/helper': { + path: '/etc/wb-rules-modules/test/esm/helper.js', + content: [ + 'import { double } from "./util.js";', + 'export function greet(name) { return "hi " + name + double(1); }', + '', + ].join('\n'), + }, + './util.js': { + path: '/etc/wb-rules-modules/test/esm/util.js', + content: 'export const double = (x) => x * 2;\n', + }, + // a sibling TypeScript file next to the rule + './lib/strings.ts': { + path: '/etc/wb-rules/lib/strings.ts', + content: 'export function takesString(s: string): string { return "got " + s; }\n', + }, + }; + const calls: string[] = []; + const resolver: ModuleResolver = async (from, spec) => { + calls.push(`${from} ${spec}`); + return modules[spec] ?? null; + }; + + beforeEach(() => { + calls.length = 0; + }); + + it('types a bare import from the module directories, including a type-only import', async () => { + const content = [ + 'import { typedAdd, type Point } from "test/esm/typed";', + 'const p: Point = { x: 1, y: 2 };', + 'log(typedAdd(p.x, p.y));', + 'log(typedAdd("one", 2));', // line 4: wrong argument type + '', + ].join('\n'); + const support = await loadTsEditorSupport('imports-bare.ts', content, undefined, '', resolver); + const diags = support.getDiagnostics(); + expect(diags).toHaveLength(1); + expect(diags[0].line).toBe(4); + expect(diags[0].message).toMatch(/string.*number|number.*string/); + expect(calls).toContain('imports-bare.ts test/esm/typed'); + }, 30000); + + it('types a relative .ts import next to the rule (allowImportingTsExtensions)', async () => { + const content = [ + 'import { takesString } from "./lib/strings.ts";', + 'log(takesString(42));', // line 2: number is not a string + '', + ].join('\n'); + const support = await loadTsEditorSupport('imports-relative.ts', content, undefined, '', resolver); + const diags = support.getDiagnostics(); + expect(diags).toHaveLength(1); + expect(diags[0].line).toBe(2); + }, 30000); + + it('follows a module\'s own relative import so its exports type-check', async () => { + const content = [ + 'import { greet } from "test/esm/helper";', + 'const s: string = greet("x");', + 'const n: number = greet("x");', // line 3: greet returns a string (inferred from the .js module) + '', + ].join('\n'); + const support = await loadTsEditorSupport('imports-transitive.ts', content, undefined, '', resolver); + expect(calls).toContain('/etc/wb-rules-modules/test/esm/helper.js ./util.js'); + const diags = support.getDiagnostics(); + expect(diags.map((d) => d.line)).toEqual([3]); + }, 30000); + + it('leaves an unresolvable import to the wildcard fallback (any, no error) and without a resolver too', async () => { + const content = [ + 'import { whatever } from "no-such-module";', + 'import { rel } from "./no-such-file.ts";', + 'log(whatever.anything, rel);', + '', + ].join('\n'); + const withResolver = await loadTsEditorSupport('imports-missing.ts', content, undefined, '', resolver); + expect(withResolver.getDiagnostics()).toEqual([]); + const without = await loadTsEditorSupport('imports-noresolver.ts', content, undefined, '', null); + expect(without.getDiagnostics()).toEqual([]); + expect(calls.filter((c) => c.startsWith('imports-noresolver.ts'))).toEqual([]); + }, 30000); + + it('fetches an import added after the environment was built (refreshImports)', async () => { + const support = await loadTsEditorSupport('imports-late.ts', 'log(1);\n', undefined, '', resolver); + expect(calls).toEqual([]); + const edited = 'import { takesString } from "./lib/strings.ts";\nlog(takesString(42));\n'; + support.reseed(edited); + expect(await support.refreshImports(edited)).toBe(true); + expect(support.getDiagnostics().map((d) => d.line)).toEqual([2]); + // nothing new the second time + expect(await support.refreshImports(edited)).toBe(false); + }, 30000); +}); diff --git a/frontend/src/stores/rules/autocomplete/ts-language-service.ts b/frontend/src/stores/rules/autocomplete/ts-language-service.ts index bb9448d19..10d287fa7 100644 --- a/frontend/src/stores/rules/autocomplete/ts-language-service.ts +++ b/frontend/src/stores/rules/autocomplete/ts-language-service.ts @@ -1,7 +1,10 @@ import type { Diagnostic, Node, Program, Type, TypeChecker } from 'typescript'; +import { importRefreshPlugin } from './import-refresh'; +import { lintRefresher } from './lint-refresh'; +import { createImportSet, MODULES_ROOT, normalizeEol } from './module-resolution'; import { tsDiagnosticsLinter } from './ts-diagnostics-linter'; import { withCompletionDetails } from './ts-help'; -import type { TsEditorSupport, TsModule } from './types'; +import type { ModuleResolver, TsEditorSupport, TsModule } from './types'; import wbRulesDts from './wb-rules.d.ts?raw'; // Browser-side TypeScript language service for rule files: live diagnostics, @@ -24,10 +27,6 @@ let cachedPath = ''; let cachedTypes = ''; let cachedRegistry = ''; -// CodeMirror normalizes line endings on ingest, so the service must hold LF text -// too or every position after line 1 of a CRLF file drifts -const normalizeEol = (s: string) => s.replace(/\r\n/g, '\n'); - // custom diagnostic codes, well outside the range TypeScript itself emits const PROMISE_CONDITION_CODE = 990001; const PROMISE_CONDITION_MESSAGE = @@ -47,6 +46,8 @@ async function build( initialContent: string, typesDts: string, registryDts: string, + resolveModule: ModuleResolver | null, + from: string, ): Promise { const [ts, vfs, cmts] = await Promise.all([ import('typescript').then((m) => m.default), @@ -62,10 +63,11 @@ async function build( checkJs: true, strict: false, noEmit: true, - // top-level await (the engine wraps rule files in an async function) is only - // allowed in a module; force module mode like the on-controller check - module: ts.ModuleKind.ESNext, + // module mode (TLA is only legal there), engine settings; bare specifiers -> MODULES_ROOT + module: ts.ModuleKind.Preserve, moduleDetection: ts.ModuleDetectionKind.Force, + allowImportingTsExtensions: true, + paths: { '*': [MODULES_ROOT + '/*'] }, }; const fsMap = new Map(); @@ -75,6 +77,9 @@ async function build( fsMap.set('/wb-rules.d.ts', typesDts); fsMap.set(path, normalizeEol(initialContent) || '\n'); + const imports = createImportSet(resolveModule, path, from); + for (const [modulePath, text] of await imports.prefetch(initialContent)) fsMap.set(modulePath, normalizeEol(text)); + // live-device registry, declaration-merged into WbControls (see registry.ts) const rootFiles = [path, '/wb-rules.d.ts']; if (registryDts) { @@ -226,15 +231,20 @@ async function build( return [...base, ...promiseAwaitDiagnostics(env.languageService.getProgram())]; }; + const refresher = lintRefresher(); + const refreshImports = (source: string) => imports.refresh(env, source); + return { extensions: [ // without the flag valtown's completion filter drops every ambient global // not on its standard-JS whitelist, i.e. the whole wb-rules API cmts.tsFacet.of({ env, path, keepLegacyLimitationForAutocompletionSymbols: false }), cmts.tsSync(), - tsDiagnosticsLinter(ts, env, path), + tsDiagnosticsLinter(ts, env, path, refresher), cmts.tsHover(), + importRefreshPlugin(refreshImports, refresher, resolveModule !== null), ], + refreshImports, completionSource: withCompletionDetails(cmts.tsAutocomplete(), env, path, ts), reseed: (content: string) => { // an empty file must still exist in the vfs @@ -281,6 +291,7 @@ export function loadTsEditorSupport( initialContent: string, controllerTypes?: string, registryDts = '', + resolveModule: ModuleResolver | null = null, // Editor.ResolveModule; null on old firmware ): Promise { const path = '/' + (fileName.replace(/^\/+/, '') || 'rule.ts'); // no controller types = a transient GetTypes failure on firmware that advertises @@ -298,13 +309,15 @@ export function loadTsEditorSupport( cachedPath = path; cachedTypes = typesDts; cachedRegistry = registryDts; - const building = build(path, initialContent, typesDts, registryDts); + const building = build(path, initialContent, typesDts, registryDts, resolveModule, fileName); cached = building; return building.catch((e) => { if (cached === building) cached = null; // a failed load must not poison TS support forever throw e; }); } + // imports added since the environment was built are fetched by the + // view's refresh plugin, which also re-lints when they land return cached.then((support) => { support.reseed(initialContent); return support; diff --git a/frontend/src/stores/rules/autocomplete/types.ts b/frontend/src/stores/rules/autocomplete/types.ts index 120f4d7c3..7044a7e48 100644 --- a/frontend/src/stores/rules/autocomplete/types.ts +++ b/frontend/src/stores/rules/autocomplete/types.ts @@ -1,6 +1,7 @@ import type { CompletionSource } from '@codemirror/autocomplete'; import type { Extension } from '@codemirror/state'; import type { EditorView, ViewUpdate } from '@codemirror/view'; +import type { VirtualTypeScriptEnvironment } from '@typescript/vfs'; import type ts from 'typescript'; import type { LocalTsDiag, TsCheckDiag } from '../types'; @@ -12,6 +13,9 @@ export interface TsEditorSupport { completionSource: CompletionSource; getDiagnostics: () => LocalTsDiag[]; reseed: (content: string) => void; + // fetch any imports of `source` not yet in the environment (see + // module-resolution.ts); resolves to whether anything was added + refreshImports: (source: string) => Promise; } export interface ControllerVerdict { @@ -48,3 +52,52 @@ export interface DeviceCells { // optional: a store without cells yet must not break editor loading cells?: Map; } + +// a module file resolved by the controller (Editor.ResolveModule), see +// module-resolution.ts +export interface ResolvedModule { + // absolute path of the module file on the controller - the `from` for the + // module's own imports + path: string; + content: string; +} + +// resolves `specifier` as written in `from` (a rule's virtual path or a +// module's absolute path); null when the controller cannot resolve it +export type ModuleResolver = (from: string, specifier: string) => Promise; + +// the modules fetched for one language-service environment +export interface ImportGraph { + // virtual FS path of the file under edit: never overwritten by a fetch + root: string; + // virtual FS path -> source, for every module fetched so far + files: Map; + // `from\0specifier` pairs already asked (including failed ones) + asked: Set; +} + +export interface PrefetchOptions { + maxFiles?: number; + maxDepth?: number; + // wall-clock bound for one prefetch run (ms), each resolver call raced + // against what is left of it; imports still unresolved when it elapses + // are left to the wildcard fallback + deadlineMs?: number; +} + +// a file whose imports are still to be scanned (prefetchImports) +export interface ImportQueueItem { + vfsPath: string; + from: string; + source: string; + depth: number; +} + +// the imports of one language-service environment, see createImportSet +export interface ImportSet { + // fetch the imports of `source` (transitively); the files collected so far + prefetch: (source: string) => Promise>; + // fetch imports of `source` not yet known and add them to `env`; whether + // anything was added + refresh: (env: VirtualTypeScriptEnvironment, source: string) => Promise; +} diff --git a/frontend/src/stores/rules/autocomplete/wb-rules.d.ts b/frontend/src/stores/rules/autocomplete/wb-rules.d.ts index 7f09993b8..849789dd0 100644 --- a/frontend/src/stores/rules/autocomplete/wb-rules.d.ts +++ b/frontend/src/stores/rules/autocomplete/wb-rules.d.ts @@ -871,5 +871,26 @@ declare module "*"; declare const global: typeof globalThis; +/** + * Metadata of the current ES module - a rule or module file that uses + * `import`/`export` (such files run as real ES modules: live bindings, + * native top-level await). Not available in classic (CommonJS-style) files, + * where `module` and `__filename` serve the same purpose. + */ +interface ImportMeta { + /** `file://` URL of this module file. */ + readonly url: string; + /** Absolute path of this module file (of the module itself, not of the importing rule file - that is `__filename`). */ + readonly filename: string; + /** Directory of this module file. */ + readonly dirname: string; + /** + * Storage shared by every instance of this module file: all the rule files + * importing it, and its reloads. The ES-module counterpart of + * `module.static`; a file reached both ways shares one storage. + */ + readonly static: Record; +} + // CommonJS-style module surface available in every rule file declare var exports: Record; diff --git a/frontend/src/stores/rules/index.ts b/frontend/src/stores/rules/index.ts index 3dbcacfe8..30e4f26b9 100644 --- a/frontend/src/stores/rules/index.ts +++ b/frontend/src/stores/rules/index.ts @@ -21,3 +21,4 @@ export { rulesStore, RulesStore }; +export { RULE_FILE_EXTENSION_RX, TS_RULE_FILE_EXTENSION_RX, ruleFileExtension } from './rule-file-extension'; diff --git a/frontend/src/stores/rules/rule-file-extension.test.ts b/frontend/src/stores/rules/rule-file-extension.test.ts new file mode 100644 index 000000000..b662f6100 --- /dev/null +++ b/frontend/src/stores/rules/rule-file-extension.test.ts @@ -0,0 +1,25 @@ +import { RULE_FILE_EXTENSION_RX, ruleFileExtension, TS_RULE_FILE_EXTENSION_RX } from './rule-file-extension'; + +// the explicit-format extensions the engine loads next to .js/.ts: .mjs/.mts +// (ES modules by name) and .cjs/.cts (classic scripts by name) +describe('rule file extensions', () => { + it('recognises every rule file extension and nothing else', () => { + for (const ok of ['a.js', 'a.ts', 'a.mjs', 'a.mts', 'a.cjs', 'a.cts', 'dir/x.mjs']) { + expect(RULE_FILE_EXTENSION_RX.test(ok)).toBe(true); + } + for (const no of ['a.json', 'a.jsx', 'a.d', 'ajs', 'a.js.disabled']) { + expect(RULE_FILE_EXTENSION_RX.test(no)).toBe(false); + } + }); + + it('tells TypeScript files apart, module or classic', () => { + expect(['a.ts', 'a.mts', 'a.cts'].every((n) => TS_RULE_FILE_EXTENSION_RX.test(n))).toBe(true); + expect(['a.js', 'a.mjs', 'a.cjs', 'a.tsx'].some((n) => TS_RULE_FILE_EXTENSION_RX.test(n))).toBe(false); + }); + + it('keeps a file\'s own extension through rename/copy, defaulting to .js', () => { + expect(ruleFileExtension('lights.mts')).toBe('.mts'); + expect(ruleFileExtension('legacy.cjs')).toBe('.cjs'); + expect(ruleFileExtension('plain')).toBe('.js'); + }); +}); diff --git a/frontend/src/stores/rules/rule-file-extension.ts b/frontend/src/stores/rules/rule-file-extension.ts new file mode 100644 index 000000000..6137bfc98 --- /dev/null +++ b/frontend/src/stores/rules/rule-file-extension.ts @@ -0,0 +1,10 @@ +// Rule files the engine loads: .js/.ts decide script vs ES module by their +// syntax, .mjs/.mts are always ES modules, .cjs/.cts always classic scripts. +export const RULE_FILE_EXTENSION_RX = /\.[mc]?[jt]s$/; +export const TS_RULE_FILE_EXTENSION_RX = /\.[mc]?ts$/; + +// the extension a file keeps through a rename or copy (.js for a name +// without one) +export function ruleFileExtension(name: string): string { + return RULE_FILE_EXTENSION_RX.exec(name)?.[0] ?? '.js'; +} diff --git a/frontend/src/stores/rules/rules-store.ts b/frontend/src/stores/rules/rules-store.ts index 57c40f075..a58bd6c7a 100644 --- a/frontend/src/stores/rules/rules-store.ts +++ b/frontend/src/stores/rules/rules-store.ts @@ -4,6 +4,7 @@ import { generateNextId } from '@/utils/id'; import { locationBelongsToRule, recordRuntimeErrorIn, restoreRuntimeErrorsIn, } from './autocomplete/runtime-error-parse'; +import { RULE_FILE_EXTENSION_RX, ruleFileExtension } from './rule-file-extension'; import type { Rule, RuleError, RuleLevel, RuleListItem, RuleLog, RuleRuntimeError, TsCheckDiag } from './types'; // the engine runs a (re)loaded file first and publishes /wbrules/updates/changed right @@ -135,7 +136,7 @@ export default class RulesStore { async rename(oldName: string, newName: string): Promise { // an extensionless new title keeps the file's language (foo.ts -> "bar" is not bar.js) - const extension = oldName.endsWith('.ts') ? '.ts' : '.js'; + const extension = ruleFileExtension(oldName); return editorProxy.Rename({ path: oldName, new_path: this.getValidRuleName(newName, extension) }) .then(async () => { await new Promise((resolve) => setTimeout(resolve, 1500)); @@ -144,7 +145,7 @@ export default class RulesStore { } async checkIsNameUnique(name: string): Promise { - const extension = this.rule?.initName?.endsWith('.ts') ? '.ts' : '.js'; + const extension = ruleFileExtension(this.rule?.initName ?? ''); const path = this.getValidRuleName(name, extension); const list = await this.getList(); if (list.some((rule) => rule.virtualPath === path)) { @@ -155,7 +156,7 @@ export default class RulesStore { } getValidRuleName(path: string, defaultExtension = '.js'): string { - return path.endsWith('.js') || path.endsWith('.ts') ? path : `${path}${defaultExtension}`; + return RULE_FILE_EXTENSION_RX.test(path) ? path : `${path}${defaultExtension}`; } async changeState(path: string, state: boolean): Promise { @@ -177,10 +178,10 @@ export default class RulesStore { async copyRule(path: string) { const copiedRule = await this.load(path); - const extension = copiedRule.name.endsWith('.ts') ? '.ts' : '.js'; + const extension = ruleFileExtension(copiedRule.name); copiedRule.name = generateNextId( - this.rules.map((rule) => rule.virtualPath.replace(/\.(js|ts)$/, '')), - copiedRule.name.replace(/\.(js|ts)$/, ''), + this.rules.map((rule) => rule.virtualPath.replace(RULE_FILE_EXTENSION_RX, '')), + copiedRule.name.replace(RULE_FILE_EXTENSION_RX, ''), ); const copiedRuleName = await this.save({ ...copiedRule, diff --git a/frontend/src/test/mocks/services.ts b/frontend/src/test/mocks/services.ts index 195af450a..c9cfa1364 100644 --- a/frontend/src/test/mocks/services.ts +++ b/frontend/src/test/mocks/services.ts @@ -41,6 +41,7 @@ export const editorProxyMock = { Remove: vi.fn(), Check: vi.fn(), GetTypes: vi.fn(), + ResolveModule: vi.fn(), hasMethod: vi.fn(async () => true), };