diff --git a/debian/changelog b/debian/changelog index d65691175..2aa53a366 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,20 @@ +wb-mqtt-homeui (2.246.0) stable; urgency=medium + + * 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, + seeded with the controller's installed wb-rules API declarations + (Editor.GetTypes RPC, vendored fallback for older firmware). + * Plain .js rules use the same language service (allowJs): completions + and hover reflect the running engine's API; no type-error noise. + * The controller's own background check verdict (Editor.Check RPC) is + rendered inline at the reported lines, de-duplicated against the + local check and suppressed while the buffer has unsaved edits. + * Requires wb-rules >= 2.47 for the TypeScript engine support; the + editor degrades gracefully on older firmware. + + -- Evgeny Boger Fri, 14 Aug 2026 22:00:00 +0000 + wb-mqtt-homeui (2.245.10) stable; urgency=medium * Fix create first user logic diff --git a/debian/control b/debian/control index 74aebaf58..180e7dbad 100644 --- a/debian/control +++ b/debian/control @@ -31,7 +31,7 @@ Depends: ${shlibs:Depends}, wb-homeui-backend (= ${binary:Version}) Recommends: wb-mqtt-logs (>= 1.2.0), wb-device-manager, - wb-rules (>= 2.37.0~~) + wb-rules (>= 2.47.0~quickjs3~~) Suggests: wb-mqtt-confed (>= 1.4.0), Breaks: wb-mqtt-confed (<< 1.0.3), wb-mqtt-db (<< 1.5), diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index e69ca7d0c..a4749ca8c 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -6,6 +6,11 @@ const getCustomConfig = (cfg) => { const customIgnores = [ 'src/custom.d.ts', 'src/components/json-editor/extensions/*', + // vendored wb-rules engine declarations (raw-imported for the TS + // language service, not app code) and the completion list generated + // from them + 'src/stores/rules/autocomplete/wb-rules.d.ts', + 'src/stores/rules/autocomplete/globals-generated.ts', ]; const { ignores, ...rest } = cfg.at(0); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7e9b8d457..744b54943 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@codemirror/lang-javascript": "6.2.5", "@codemirror/lang-json": "6.0.2", + "@codemirror/lint": "6.9.6", "@codemirror/state": "6.6.0", "@codemirror/view": "6.43.0", "@daypicker/react": "10.0.1", @@ -20,7 +21,9 @@ "@dnd-kit/utilities": "3.2.2", "@floating-ui/react": "0.27.19", "@rpldy/uploady": "1.13.0", + "@typescript/vfs": "1.6.4", "@uiw/react-codemirror": "4.25.10", + "@valtown/codemirror-ts": "2.3.1", "@wirenboard/json-editor": "2.5.3-wb19", "@xterm/addon-fit": "0.11.0", "@xterm/addon-web-links": "0.12.0", @@ -52,6 +55,7 @@ "react-select": "5.10.2", "react-sortablejs": "6.1.4", "sortablejs": "1.15.7", + "typescript": "6.0.3", "use-file-picker": "2.1.4", "xterm": "5.3.0" }, @@ -73,7 +77,6 @@ "globals": "17.6.0", "happy-dom": "20.9.0", "rimraf": "6.1.3", - "typescript": "6.0.3", "use-resize-observer": "9.1.0", "vite": "8.0.13", "vite-plugin-svgr": "5.2.0", @@ -2466,6 +2469,18 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@typescript/vfs": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz", + "integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3" + }, + "peerDependencies": { + "typescript": "*" + } + }, "node_modules/@uiw/codemirror-extensions-basic-setup": { "version": "4.25.10", "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.10.tgz", @@ -2519,6 +2534,21 @@ "react-dom": ">=17.0.0" } }, + "node_modules/@valtown/codemirror-ts": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@valtown/codemirror-ts/-/codemirror-ts-2.3.1.tgz", + "integrity": "sha512-v5XiI4WA+bUy0XDgkrqZksqBWgIUeyLZuC94Px/rhXBph8ASmVXaimlGDtt0vH/9t8aDdIZYdr59r9H3oKMFOg==", + "license": "ISC", + "engines": { + "node": "*" + }, + "peerDependencies": { + "@codemirror/autocomplete": "^6", + "@codemirror/lint": "^6", + "@codemirror/state": "^6", + "@codemirror/view": "^6" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", @@ -11493,7 +11523,6 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/frontend/package.json b/frontend/package.json index 5309cf205..04a49cf52 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,8 @@ "test:watch": "vitest", "lint": "npx eslint --cache --cache-location .eslintcache", "lint:fix": "npx eslint --fix", - "check:types": "tsc --noEmit" + "check:types": "tsc --noEmit", + "generate:completions": "node scripts/generate-wb-rules-completions.mjs" }, "repository": { "type": "git", @@ -25,6 +26,7 @@ "dependencies": { "@codemirror/lang-javascript": "6.2.5", "@codemirror/lang-json": "6.0.2", + "@codemirror/lint": "6.9.6", "@codemirror/state": "6.6.0", "@codemirror/view": "6.43.0", "@daypicker/react": "10.0.1", @@ -34,7 +36,9 @@ "@dnd-kit/utilities": "3.2.2", "@floating-ui/react": "0.27.19", "@rpldy/uploady": "1.13.0", + "@typescript/vfs": "1.6.4", "@uiw/react-codemirror": "4.25.10", + "@valtown/codemirror-ts": "2.3.1", "@wirenboard/json-editor": "2.5.3-wb19", "@xterm/addon-fit": "0.11.0", "@xterm/addon-web-links": "0.12.0", @@ -66,6 +70,7 @@ "react-select": "5.10.2", "react-sortablejs": "6.1.4", "sortablejs": "1.15.7", + "typescript": "6.0.3", "use-file-picker": "2.1.4", "xterm": "5.3.0" }, @@ -87,7 +92,6 @@ "globals": "17.6.0", "happy-dom": "20.9.0", "rimraf": "6.1.3", - "typescript": "6.0.3", "use-resize-observer": "9.1.0", "vite": "8.0.13", "vite-plugin-svgr": "5.2.0", diff --git a/frontend/scripts/generate-wb-rules-completions.mjs b/frontend/scripts/generate-wb-rules-completions.mjs new file mode 100644 index 000000000..b186ea4dd --- /dev/null +++ b/frontend/scripts/generate-wb-rules-completions.mjs @@ -0,0 +1,80 @@ +// Generates autocomplete/globals-generated.ts from autocomplete/wb-rules.d.ts. +// The .d.ts (synced from wb-rules types/wb-rules.d.ts) is the single source +// of truth for the builtin API; run `npm run generate:completions` after +// updating it. +import fs from 'node:fs'; +import path from 'node:path'; +import url from 'node:url'; +import ts from 'typescript'; + +// single-quoted string literal matching the repo eslint style, so +// regenerating never dirties the tree +const q = (s) => `'${String(s).replace(/\\/g, '\\\\').replace(/'/g, '\\\'')}'`; + +const here = path.dirname(url.fileURLToPath(import.meta.url)); +const dtsPath = path.join(here, '../src/stores/rules/autocomplete/wb-rules.d.ts'); +const outPath = path.join(here, '../src/stores/rules/autocomplete/globals-generated.ts'); + +const source = ts.createSourceFile('wb-rules.d.ts', fs.readFileSync(dtsPath, 'utf8'), ts.ScriptTarget.Latest); +const printer = ts.createPrinter({ removeComments: true }); + +const seen = new Set(); +const completions = []; + +const signatureOf = (node) => { + const text = printer.printNode(ts.EmitHint.Unspecified, node, source) + .replace(/^declare\s+/, '') + .replace(/\s+/g, ' ') + .trim(); + return text.length > 60 ? `${text.slice(0, 57)}...` : text; +}; + +const snippetFor = (name, params) => { + if (params.length === 0) return `${name}()`; + const args = params + .filter((p) => !p.questionToken && !p.dotDotDotToken) + .map((p, i) => `\${${i + 1}:${p.name.getText(source)}}`); + return `${name}(${args.join(', ')})`; +}; + +for (const stmt of source.statements) { + if (ts.isFunctionDeclaration(stmt) && stmt.name) { + const name = stmt.name.text; + if (seen.has(name)) continue; // keep the first overload only + seen.add(name); + completions.push({ + label: name, + type: 'function', + detail: signatureOf(stmt), + snippet: snippetFor(name, stmt.parameters), + }); + } else if (ts.isVariableStatement(stmt)) { + for (const decl of stmt.declarationList.declarations) { + const name = decl.name.getText(source); + if (seen.has(name)) continue; + seen.add(name); + completions.push({ + label: name, + type: 'variable', + detail: decl.type ? signatureOf(decl.type) : '', + }); + } + } +} + +const body = completions.map((c) => { + const detail = q(c.detail); + return c.snippet && c.snippet !== `${c.label}()` + ? ` snippetCompletion(${q(c.snippet)}, { label: ${q(c.label)}, type: '${c.type}', detail: ${detail} }),` + : ` { label: ${q(c.label)}, type: '${c.type}', detail: ${detail}${c.snippet ? `, apply: ${q(c.snippet)}` : ''} },`; +}).join('\n'); + +fs.writeFileSync(outPath, `// GENERATED from wb-rules.d.ts — do not edit by hand. +// Regenerate with: npm run generate:completions +import { snippetCompletion, type Completion } from '@codemirror/autocomplete'; + +export const wbRulesGlobals: Completion[] = [ +${body} +]; +`); +console.log(`generated ${completions.length} completions -> ${outPath}`); diff --git a/frontend/src/custom.d.ts b/frontend/src/custom.d.ts index 3e12bd241..50fadb144 100644 --- a/frontend/src/custom.d.ts +++ b/frontend/src/custom.d.ts @@ -23,3 +23,19 @@ declare const __LOGO__: string; declare const __LOGO_COMPACT__: string; declare const __APP_NAME__: string; declare const __APP_SHORT_NAME__: string; + +// raw-text imports (vite ?raw suffix) used by the TS language service +declare module '*?raw' { + const text: string; + export default text; +} + +// import.meta.glob is provided by vite; the project compiles with +// types: ["vitest/globals"] instead of vite/client, so declare the +// subset we use +interface ImportMeta { + glob( + pattern: string, + options: { query: string; import: string; eager: true } + ): Record; +} diff --git a/frontend/src/pages/rules/[rule]/edit-rule.test.tsx b/frontend/src/pages/rules/[rule]/edit-rule.test.tsx index 43b773c7f..80d08a3fd 100644 --- a/frontend/src/pages/rules/[rule]/edit-rule.test.tsx +++ b/frontend/src/pages/rules/[rule]/edit-rule.test.tsx @@ -18,6 +18,9 @@ const { rulesMock, navigateMock, paramsMock, setIsDirtyMock } = vi.hoisted(() => setRule: vi.fn(), setRuleName: vi.fn(), checkIsNameUnique: vi.fn(async () => true), + tsCheckDiags: [], + checkTsFile: vi.fn(async () => {}), + clearTsCheck: vi.fn(), }, navigateMock: vi.fn(), paramsMock: { id: 'test-rule.js' } as Record, diff --git a/frontend/src/pages/rules/[rule]/edit-rule.tsx b/frontend/src/pages/rules/[rule]/edit-rule.tsx index 06fa10e89..4d85acdca 100644 --- a/frontend/src/pages/rules/[rule]/edit-rule.tsx +++ b/frontend/src/pages/rules/[rule]/edit-rule.tsx @@ -7,10 +7,14 @@ import { Button } from '@/components/button'; import { CodeEditor } from '@/components/code-editor'; import { Tag } from '@/components/tag'; import { PageLayout } from '@/layouts/page'; +import { editorProxy } from '@/services'; import { authStore, UserRole } from '@/stores/auth'; import { devicesStore } from '@/stores/devices'; import { rulesStore } from '@/stores/rules'; import { getExtensions } from '@/stores/rules/autocomplete'; +import { controllerDiagnostics } from '@/stores/rules/autocomplete/controller-diagnostics'; +import { buildControlsRegistry } from '@/stores/rules/autocomplete/registry'; +import type { TsEditorSupport } from '@/stores/rules/autocomplete/types'; import { useAsyncAction } from '@/utils/async-action'; import { usePreventLeavePage } from '@/utils/prevent-page-leave'; import './styles.css'; @@ -24,6 +28,88 @@ const EditRulePage = observer(() => { const params = useParams(); const navigate = useNavigate(); const [isEditingTitle, setIsEditingTitle] = useState(!params['*']); + const ruleFileName = params['*'] || rule.name || ''; + const isTypeScript = ruleFileName.endsWith('.ts'); + const [tsSupport, setTsSupport] = useState(null); + // for an unsaved rule the title changes on every keystroke; the service + // is keyed on a stable placeholder path instead, so typing a name does + // not re-run the effect below (a GetTypes RPC + environment rebuild each) + const servicePath = params['*'] || (isTypeScript ? 'unsaved.ts' : 'unsaved.js'); + + useEffect(() => { + // the language service runs for .js rules too (allowJs): completions + // and hover reflect the controller's installed API via GetTypes + // instead of the build-time snapshot; checkJs stays off, so wild ES5 + // gets no type-error noise + if (isLoading) { + setTsSupport(null); + return undefined; + } + let alive = true; + // the language service (typescript + lib files, ~1 MB gzip) stays in + // a lazy chunk, loaded only once a rule editor opens. + // Types come from the controller (Editor.GetTypes) so the editor + // validates against the installed engine's API; the vendored copy is + // only the offline fallback. + // race the controller types against a short deadline: old firmware + // without Editor.GetTypes would otherwise stall TS support for the + // full 60s RPC timeout before the vendored fallback kicks in + const controllerTypes = Promise.race([ + editorProxy.GetTypes().then((r) => r?.content, () => undefined), + new Promise((resolve) => setTimeout(() => resolve(undefined), 3000)), + ]); + // snapshot the live device list as a WbControls registry so the typed + // string-ref APIs (getControl("dev/ctrl"), dev["dev/ctrl"]) validate + // against the controls that actually exist on this controller + const registryDts = buildControlsRegistry(devicesStore); + Promise.all([ + import('@/stores/rules/autocomplete/ts-language-service'), + controllerTypes, + ]) + .then(([m, typesDts]) => + m.loadTsEditorSupport(servicePath, rule.content, typesDts, registryDts)) + .then( + (support) => alive && setTsSupport(support), + () => {}, // editor still works without the language service + ); + return () => { + alive = false; + }; + // rule.content is deliberately not a dependency: it only seeds the + // language service; tsSync() tracks all further edits + }, [servicePath, isLoading]); + + useEffect(() => { + rulesStore.clearTsCheck(); + if (isTypeScript && params['*'] && !isLoading) { + rulesStore.checkTsFile(params['*']); + } + // cancel the poll loop when leaving the page + return () => rulesStore.clearTsCheck(); + }, [isTypeScript, params['*'], isLoading]); + + // rebuilt only when the language service (re)loads: a fresh extensions + // array per keystroke would reconfigure CodeMirror and re-run every + // lint source synchronously on each character typed + const editorExtensions = useMemo(() => [ + ...getExtensions(devicesStore, { + typescript: isTypeScript, + typeAwareSource: tsSupport?.completionSource, + }), + ...(tsSupport?.extensions ?? []), + // controller-side tsgo verdict, inline at the reported lines + ...(isTypeScript + ? [ + controllerDiagnostics( + () => ({ + diags: rulesStore.tsCheckDiags, + checkedContent: rulesStore.tsCheckedContent, + }), + () => tsSupport?.getDiagnostics() ?? [], + ), + ] + : []), + ], [isTypeScript, tsSupport]); const errors = useMemo(() => { if (pageLoadError) { @@ -60,8 +146,12 @@ const EditRulePage = observer(() => { await rulesStore.checkIsNameUnique(rule.name); } try { + const savedContent = rule.content; const savedRuleName = await rulesStore.save(rule); setIsDirty(false); + if (savedRuleName.endsWith('.ts')) { + rulesStore.checkTsFile(savedRuleName, savedContent); + } if (!params['*']) { const encoded = savedRuleName.split('/').map(encodeURIComponent).join('/'); return navigate(`/rules/${encoded}`, { replace: true }); @@ -105,7 +195,7 @@ const EditRulePage = observer(() => { text={rule.content} errorLines={rule.error?.errorLine ? [rule.error.errorLine] : null} autoFocus={!!params['*']} - extensions={getExtensions(devicesStore)} + extensions={editorExtensions} onChange={(value) => { setIsDirty(true); rulesStore.setRule(value); diff --git a/frontend/src/services/editor-proxy.ts b/frontend/src/services/editor-proxy.ts index c3526b30f..fe85a4783 100644 --- a/frontend/src/services/editor-proxy.ts +++ b/frontend/src/services/editor-proxy.ts @@ -1,4 +1,4 @@ -import type { RuleFetchData, RuleListItem, RuleSaveData } from '@/stores/rules/types'; +import type { RuleFetchData, RuleListItem, RuleSaveData, TsCheckResult } from '@/stores/rules/types'; import { createRpcProxy } from './rpc'; interface EditorProxyMethods { @@ -8,9 +8,11 @@ interface EditorProxyMethods { Save: (params: { path: string; content: string }) => Promise; Remove: (params: { path: string }) => Promise; Rename: (params: { path: string; new_path: string }) => Promise; + Check: (params: { path: string }) => Promise; + GetTypes: () => Promise<{ content: string }>; } export const editorProxy = createRpcProxy( 'wbrules/Editor', - ['ChangeState', 'List', 'Load', 'Save', 'Remove', 'Rename'], + ['ChangeState', 'List', 'Load', 'Save', 'Remove', 'Rename', 'Check', 'GetTypes'], ); diff --git a/frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts b/frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts new file mode 100644 index 000000000..6cfb6ad28 --- /dev/null +++ b/frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts @@ -0,0 +1,89 @@ +import { Text } from '@codemirror/state'; +import type { TsCheckDiag } from '../types'; +import { controllerDiagsForDoc, controllerDiagsToCm } from './controller-diagnostics'; + +describe('controllerDiagsToCm', () => { + const doc = Text.of(['const a = 1;', 'let b: number = 0;', 'b = \'oops\';']); + + it('anchors a diagnostic at the reported line and column, spanning to end of line', () => { + const diags: TsCheckDiag[] = [ + { line: 3, column: 1, severity: 'error', message: 'Type \'string\' is not assignable to type \'number\'.' }, + ]; + const [d] = controllerDiagsToCm(doc, diags); + expect(d.from).toBe(doc.line(3).from); + expect(d.to).toBe(doc.line(3).to); + expect(d.severity).toBe('error'); + expect(d.source).toBe('controller (tsgo)'); + }); + + it('drops controller entries the local language service already shows, keeps skew-only ones', () => { + const diags: TsCheckDiag[] = [ + { line: 3, column: 1, severity: 'error', message: 'same finding' }, + { line: 3, column: 1, severity: 'error', message: 'controller-only finding' }, + ]; + const local = [{ line: 3, message: 'same finding' }]; + const result = controllerDiagsToCm(doc, diags, local); + expect(result).toHaveLength(1); + expect(result[0].message).toBe('controller-only finding'); + }); + + it('de-duplicates by message prefix: the controller carries only the head line of chains', () => { + const diags: TsCheckDiag[] = [ + { line: 3, column: 1, severity: 'error', message: 'Argument of type \'X\' is not assignable.' }, + ]; + const local = [ + { line: 3, message: 'Argument of type \'X\' is not assignable. Types of property \'x\' are incompatible.' }, + ]; + expect(controllerDiagsToCm(doc, diags, local)).toHaveLength(0); + }); + + it('skips diagnostics belonging to another file (import/reference)', () => { + const diags: TsCheckDiag[] = [ + { file: 'helper.ts', line: 1, column: 1, severity: 'error', message: 'foreign' }, + ]; + expect(controllerDiagsToCm(doc, diags)).toHaveLength(0); + }); + + it('clamps out-of-range lines and columns instead of throwing', () => { + const diags: TsCheckDiag[] = [ + { line: 99, column: 1, severity: 'error', message: 'gone' }, + { line: 1, column: 500, severity: 'warning', message: 'far right' }, + ]; + const result = controllerDiagsToCm(doc, diags); + expect(result).toHaveLength(1); + expect(result[0].from).toBe(doc.line(1).to); + expect(result[0].severity).toBe('warning'); + }); +}); + +describe('controllerDiagsForDoc', () => { + const doc = Text.of(['let n: number = 0;', 'n = \'oops\';']); + const diags = [ + { line: 2, column: 1, severity: 'error' as const, message: 'stale finding' }, + ]; + + it('renders the verdict while the document matches the checked content', () => { + const verdict = { diags, checkedContent: doc.toString() }; + expect(controllerDiagsForDoc(doc, verdict)).toHaveLength(1); + }); + + it('suppresses the verdict once the user edits: a fixed line must not keep its old squiggle', () => { + const verdict = { diags, checkedContent: 'let n: number = 0;\nn = 5;' }; + expect(controllerDiagsForDoc(doc, verdict)).toHaveLength(0); + }); + + it('renders nothing when no check has completed yet', () => { + expect(controllerDiagsForDoc(doc, { diags, checkedContent: null })).toHaveLength(0); + }); +}); + +describe('controllerDiagsForDoc line endings', () => { + it('matches CRLF checked content against the LF-normalized editor document', () => { + // CodeMirror normalizes to \n on ingest; a rule saved with CRLF + // (scp from Windows) must still get its controller verdict rendered + const doc = Text.of(['let n: number = 0;', 'n = \'oops\';']); + const diags = [{ line: 2, column: 1, severity: 'error' as const, message: 'finding' }]; + const verdict = { diags, checkedContent: 'let n: number = 0;\r\nn = \'oops\';' }; + expect(controllerDiagsForDoc(doc, verdict)).toHaveLength(1); + }); +}); diff --git a/frontend/src/stores/rules/autocomplete/controller-diagnostics.ts b/frontend/src/stores/rules/autocomplete/controller-diagnostics.ts new file mode 100644 index 000000000..f71d57571 --- /dev/null +++ b/frontend/src/stores/rules/autocomplete/controller-diagnostics.ts @@ -0,0 +1,84 @@ +import { forceLinting, linter, type Diagnostic } from '@codemirror/lint'; +import type { Extension, Text } from '@codemirror/state'; +import { ViewPlugin } from '@codemirror/view'; +import { autorun } from 'mobx'; +import type { LocalTsDiag, TsCheckDiag } from '../types'; +import type { ControllerVerdict } from './types'; + +// Renders the controller-side tsgo verdict (Editor.Check RPC) inline in +// the editor: squiggles at the reported lines, merged with the local +// language service's own lint entries. The tooltip is labeled with the +// source so the two checks stay distinguishable. + +export function controllerDiagsToCm( + doc: Text, + diags: TsCheckDiag[], + localDiags: LocalTsDiag[] = [], +): Diagnostic[] { + // the local language service usually reports the same finding at the + // same line; showing both doubles every squiggle. Keep only the + // controller entries the editor does not already show (version/skew + // differences - the controller's unique value). The controller carries + // only the head line of chained messages while the local service + // flattens the whole chain, so match by prefix, not equality. + const localByLine = new Map(); + for (const l of localDiags) { + localByLine.set(l.line, [...(localByLine.get(l.line) ?? []), l.message]); + } + const result: Diagnostic[] = []; + for (const d of diags) { + if (d.file) continue; // belongs to another file; cannot anchor here + if (d.line < 1 || d.line > doc.lines) continue; + if ((localByLine.get(d.line) ?? []).some((m) => m.startsWith(d.message))) continue; + const line = doc.line(d.line); + const from = line.from + Math.min(Math.max(d.column - 1, 0), line.length); + result.push({ + from, + to: line.to, + severity: d.severity === 'error' ? 'error' : 'warning', + source: 'controller (tsgo)', + message: d.message, + }); + } + return result; +} + +// The verdict describes the last-saved file. Once the user edits, its +// line anchors and findings go stale - a fixed line must not keep its +// old squiggle - so diagnostics render only while the document still +// matches the checked content (the local language service covers the +// live state; saving triggers a fresh verdict). +export function controllerDiagsForDoc( + doc: Text, + verdict: ControllerVerdict, + localDiags: LocalTsDiag[] = [], +): Diagnostic[] { + // CodeMirror normalizes line endings on ingest while checkedContent is + // the raw stored file - a CRLF file must not permanently fail the match + // and silently disable the verdict + if (verdict.checkedContent === null + || verdict.checkedContent.replace(/\r\n/g, '\n') !== doc.toString()) { + return []; + } + return controllerDiagsToCm(doc, verdict.diags, localDiags); +} + +// getVerdict must be a mobx-observable read; new RPC data re-triggers +// linting through the autorun below. +export function controllerDiagnostics( + getVerdict: () => ControllerVerdict, + getLocalDiags?: () => LocalTsDiag[], +): Extension { + return [ + linter((view) => controllerDiagsForDoc(view.state.doc, getVerdict(), getLocalDiags?.() ?? [])), + ViewPlugin.define((view) => { + const stop = autorun(() => { + const v = getVerdict(); + void v.diags; + void v.checkedContent; + forceLinting(view); + }); + return { destroy: stop }; + }), + ]; +} diff --git a/frontend/src/stores/rules/autocomplete/enums.test.ts b/frontend/src/stores/rules/autocomplete/enums.test.ts new file mode 100644 index 000000000..e23412b72 --- /dev/null +++ b/frontend/src/stores/rules/autocomplete/enums.test.ts @@ -0,0 +1,49 @@ +// @vitest-environment happy-dom +// enums.ts imports the devices store, whose module graph (i18n, auth) reads +// localStorage/window at import time; happy-dom provides them. +import { CompletionContext } from '@codemirror/autocomplete'; +import { EditorState } from '@codemirror/state'; +import { getEnums } from './enums'; + +// minimal DevicesStore stand-in: one device with two controls, plus the +// flat topic list used by the global-reference completions +function fakeStore() { + const device = { getControls: () => ['temperature', 'status'] }; + return { + devices: new Map([['ts_demo', device]]), + topicsWithoutSystem: [ + { options: [{ value: 'ts_demo/temperature' }, { value: 'relay/k1' }] }, + ], + } as any; +} + +// mimic mergeSources over just the enum sources: first non-empty wins +async function enumsResult(doc: string) { + const sources = getEnums(fakeStore()); + const state = EditorState.create({ doc }); + const ctx = new CompletionContext(state, doc.length, true); + for (const s of sources) { + const r = await s(ctx); + if (r && r.options.length > 0) return r; + } + return null; +} + +describe('getControl completions', () => { + it('defers vdev.getControl(" to the type service instead of dumping the global list', async () => { + // a method call on a variable: TS knows the variable's device type and + // offers only that device's controls, so the live-list source stays out + const r = await enumsResult('const vdev = defineVirtualDevice("ts_demo", {});\nvdev.getControl("'); + expect(r).toBeNull(); + }); + + it('offers full "device/control" references for the global getControl(', async () => { + const r = await enumsResult('getControl("'); + expect(r?.options.map((o) => o.label)).toContain('ts_demo/temperature'); + }); + + it('offers a device\'s own controls for getDevice("X").getControl(', async () => { + const r = await enumsResult('getDevice("ts_demo").getControl("'); + expect(r?.options.map((o) => o.label).sort()).toEqual(['status', 'temperature']); + }); +}); diff --git a/frontend/src/stores/rules/autocomplete/enums.ts b/frontend/src/stores/rules/autocomplete/enums.ts index 9612ec475..87e12b72d 100644 --- a/frontend/src/stores/rules/autocomplete/enums.ts +++ b/frontend/src/stores/rules/autocomplete/enums.ts @@ -45,23 +45,39 @@ const makeGetDeviceSource = (devices: string[]): CompletionSource => { }; }; +// receiver forms handled here: +// getDevice("X").getControl(" -> device X's live controls (getDevice is +// not typed per-device, so TS can't help) +// getControl(" -> the global function: full "dev/ctrl" refs +// A method call on a variable (vdev.getControl(") is deliberately NOT +// handled: the TS language service knows the variable's device type and +// offers exactly that device's declared control names, so this source +// returns null and lets it answer instead of dumping the global list. +const GET_CONTROL_RE = + /(?:getDevice\(\s*(['"])([^'"]+)\1\)|([A-Za-z_$][\w$]*))?(\s*\.\s*)?getControl\(\s*(['"]?)([^'"]*)$/; + const makeGetControlSource = ( devices: Map, topics: string[] = [], ): CompletionSource => { return (context) => { - const before = context.matchBefore(/(?:getDevice\(\s*(['"])([^'"]+)\1\)\.)?getControl\(\s*(['"]?)([^'"]*)$/); + const before = context.matchBefore(GET_CONTROL_RE); if (!before) return null; - const m = before.text.match(/(?:getDevice\(\s*(['"])([^'"]+)\1\)\.)?getControl\(\s*(['"]?)([^'"]*)$/); + const m = before.text.match(GET_CONTROL_RE); if (!m) { return null; } const deviceId = m[2] || null; - const quote = m[3] || null; + const identReceiver = m[3] || null; + const dot = m[4] || null; + const quote = m[5] || null; + + // `something.getControl(` on a variable: defer to the TS service + if (identReceiver && dot) return null; - let controls = []; + let controls: string[] = []; if (!deviceId) { controls = topics; } else if (devices.get(deviceId)) { @@ -128,15 +144,18 @@ const makeTopicSource = (fnName: string, topics: string[]): CompletionSource => }; export const getEnums = (devicesStore: DevicesStore) => { - const devices = Array.from(devicesStore.devices.keys()); - const topics = devicesStore.topicsWithoutSystem.flatMap((g) => g.options.map((o) => o.value)); + // read the store when a completion is requested, not when the editor + // extensions are built: the extension array is memoized upstream, and + // devices/topics keep arriving over MQTT long after that + const topics = () => devicesStore.topicsWithoutSystem.flatMap((g) => g.options.map((o) => o.value)); + const live = (build: () => CompletionSource): CompletionSource => (context) => build()(context); return [ typeCompletionSource, - makeGetDeviceSource(devices), - makeGetControlSource(devicesStore.devices, topics), - makeDevTopicsSource(topics), - makeTopicSource('publish', topics), - makeTopicSource('trackMqtt', topics), + live(() => makeGetDeviceSource(Array.from(devicesStore.devices.keys()))), + live(() => makeGetControlSource(devicesStore.devices, topics())), + live(() => makeDevTopicsSource(topics())), + live(() => makeTopicSource('publish', topics())), + live(() => makeTopicSource('trackMqtt', topics())), ]; }; diff --git a/frontend/src/stores/rules/autocomplete/globals-generated.ts b/frontend/src/stores/rules/autocomplete/globals-generated.ts new file mode 100644 index 000000000..ae9086526 --- /dev/null +++ b/frontend/src/stores/rules/autocomplete/globals-generated.ts @@ -0,0 +1,44 @@ +// GENERATED from wb-rules.d.ts — do not edit by hand. +// Regenerate with: npm run generate:completions +import { snippetCompletion, type Completion } from '@codemirror/autocomplete'; + +export const wbRulesGlobals: Completion[] = [ + snippetCompletion('defineVirtualDevice(${1:name}, ${2:spec})', { label: 'defineVirtualDevice', type: 'function', detail: 'function defineVirtualDevice...' }), + snippetCompletion('getDevice(${1:id})', { label: 'getDevice', type: 'function', detail: 'function getDevice(id: string): VirtualDevice | undefined;' }), + snippetCompletion('getControl(${1:ref})', { label: 'getControl', type: 'function', detail: 'function getControl(...' }), + snippetCompletion('cron(${1:spec})', { label: 'cron', type: 'function', detail: 'function cron(spec: string): CronEntry;' }), + snippetCompletion('defineRule(${1:name}, ${2:spec})', { label: 'defineRule', type: 'function', detail: 'function defineRule(name: string, spec: RuleSpec): RuleId;' }), + snippetCompletion('defineAlias(${1:aliasName}, ${2:cellRef})', { label: 'defineAlias', type: 'function', detail: 'function defineAlias(aliasName: string, cellRef: string):...' }), + snippetCompletion('enableRule(${1:ruleId})', { label: 'enableRule', type: 'function', detail: 'function enableRule(ruleId: RuleId): void;' }), + snippetCompletion('disableRule(${1:ruleId})', { label: 'disableRule', type: 'function', detail: 'function disableRule(ruleId: RuleId): void;' }), + snippetCompletion('runRule(${1:ruleId})', { label: 'runRule', type: 'function', detail: 'function runRule(ruleId: RuleId): void;' }), + { label: 'runRules', type: 'function', detail: 'function runRules(): void;', apply: 'runRules()' }, + { label: 'dev', type: 'variable', detail: '{ [K in keyof WbControls | (string & {})]: K extends keyo...' }, + { label: 'log', type: 'variable', detail: 'LogFunction' }, + snippetCompletion('debug(${1:format})', { label: 'debug', type: 'function', detail: 'function debug(format: string, ...args: any[]): void;' }), + snippetCompletion('format(${1:format})', { label: 'format', type: 'function', detail: 'function format(format: string, ...args: any[]): string;' }), + snippetCompletion('publish(${1:topic}, ${2:payload})', { label: 'publish', type: 'function', detail: 'function publish(topic: string, payload: CellValue, qos?:...' }), + snippetCompletion('trackMqtt(${1:topic}, ${2:callback})', { label: 'trackMqtt', type: 'function', detail: 'function trackMqtt(topic: string, callback: (message: Mqt...' }), + snippetCompletion('nextMqtt(${1:topic})', { label: 'nextMqtt', type: 'function', detail: 'function nextMqtt(topic: string, timeoutMs?: number): Pro...' }), + { label: 'timers', type: 'variable', detail: 'Record' }, + snippetCompletion('startTimer(${1:name}, ${2:milliseconds})', { label: 'startTimer', type: 'function', detail: 'function startTimer(name: string, milliseconds: number): ...' }), + snippetCompletion('startTicker(${1:name}, ${2:milliseconds})', { label: 'startTicker', type: 'function', detail: 'function startTicker(name: string, milliseconds: number):...' }), + snippetCompletion('changed(${1:ctrl})', { label: 'changed', type: 'function', detail: 'function changed(ctrl: string,...' }), + snippetCompletion('sleep(${1:milliseconds})', { label: 'sleep', type: 'function', detail: 'function sleep(milliseconds: number): Promise;' }), + snippetCompletion('setTimeout(${1:callback}, ${2:milliseconds})', { label: 'setTimeout', type: 'function', detail: 'function setTimeout(callback: () => void, milliseconds: n...' }), + snippetCompletion('setInterval(${1:callback}, ${2:milliseconds})', { label: 'setInterval', type: 'function', detail: 'function setInterval(callback: () => void, milliseconds: ...' }), + snippetCompletion('clearTimeout(${1:id})', { label: 'clearTimeout', type: 'function', detail: 'function clearTimeout(id: number): void;' }), + snippetCompletion('clearInterval(${1:id})', { label: 'clearInterval', type: 'function', detail: 'function clearInterval(id: number): void;' }), + snippetCompletion('runShellCommand(${1:command})', { label: 'runShellCommand', type: 'function', detail: 'function runShellCommand(command: string, options?: Shell...' }), + snippetCompletion('spawn(${1:command}, ${2:args})', { label: 'spawn', type: 'function', detail: 'function spawn(command: string, args: string[], options?:...' }), + snippetCompletion('readConfig(${1:path})', { label: 'readConfig', type: 'function', detail: 'function readConfig(path: string, options?: ReadConfigOpt...' }), + snippetCompletion('PersistentStorage(${1:name})', { label: 'PersistentStorage', type: 'function', detail: 'function PersistentStorage ...' }), + snippetCompletion('StorableObject(${1:obj})', { label: 'StorableObject', type: 'function', detail: 'function StorableObject(obj: T): T;' }), + { label: 'Notify', type: 'variable', detail: 'NotifyApi' }, + { label: 'Alarms', type: 'variable', detail: 'AlarmsApi' }, + { label: '__filename', type: 'variable', detail: 'string' }, + { label: 'module', type: 'variable', detail: '{ readonly filename: string; readonly static: Record' }, +]; diff --git a/frontend/src/stores/rules/autocomplete/index.ts b/frontend/src/stores/rules/autocomplete/index.ts index d9fdb55b7..0379b0873 100644 --- a/frontend/src/stores/rules/autocomplete/index.ts +++ b/frontend/src/stores/rules/autocomplete/index.ts @@ -2,29 +2,83 @@ import { autocompletion, type CompletionSource } from '@codemirror/autocomplete' import { javascript, javascriptLanguage } from '@codemirror/lang-javascript'; import { type DevicesStore } from '@/stores/devices'; import { getEnums } from './enums'; +import { wbRulesGlobals } from './globals-generated'; import { methods } from './methods'; -import { snippetSource } from './snippets'; +import { snippets } from './snippets'; function mergeSources(sources: CompletionSource[]): CompletionSource { - return (context) => { + return async (context) => { for (const s of sources) { - const result = s(context); - if (result) return result; + const result = await s(context); + // an empty result must not shadow later sources: the TS language + // service answers with zero entries inside dev["..."] (index + // signature, no literal keys), where the device-list source has + // the real completions + if (result && result.options.length > 0) return result; } return null; }; } -export const getExtensions = (devicesStore: DevicesStore) => { +// snippets and generated globals are one namespace: offered together, +// snippet variants first (they insert richer templates), generated +// signatures for everything the snippet list does not cover +const snippetLabels = new Set(snippets.map((s) => s.label)); +const staticCompletions = [ + ...snippets, + ...wbRulesGlobals.filter((g) => !snippetLabels.has(g.label)), +]; + +const staticSource: CompletionSource = (context) => { + const word = context.matchBefore(/[A-Za-z_$][\w$]*/); + if (!word || (word.from === word.to && !context.explicit)) return null; + return { from: word.from, options: staticCompletions, validFor: /^[\w$]*$/ }; +}; + +// the language service returns plain type-aware entries (label + kind +// only); the static list still contributes its richer snippet templates +// (which replace the plain entry of the same label) and any generated +// signature the service did not surface. Member accesses (obj.foo) stay +// the service's alone - global snippets don't belong in property lists. +const withStaticExtras = (typeAware: CompletionSource): CompletionSource => async (context) => { + const result = await typeAware(context); + if (!result || result.options.length === 0) return null; + // member accesses (obj.foo) and string-literal completions (inside + // "..." - e.g. vdev.getControl(")) are the service's + // alone; global snippets/identifiers must not be spliced into them + const prev = context.state.sliceDoc(Math.max(0, result.from - 1), result.from); + if (prev === '.' || prev === '"' || prev === '\'') return result; + const tsLabels = new Set(result.options.map((o) => o.label)); + return { + ...result, + options: [ + ...result.options.filter((o) => !snippetLabels.has(o.label)), + ...snippets, + ...wbRulesGlobals.filter((g) => !snippetLabels.has(g.label) && !tsLabels.has(g.label)), + ], + }; +}; + +export const getExtensions = ( + devicesStore: DevicesStore, + options?: { typescript?: boolean; typeAwareSource?: CompletionSource }, +) => { const autocomplete = mergeSources([ + // device/topic string-argument contexts (dev["...], getDevice(...) + // answer from the live device list first - the language service + // returns non-empty identifier lists in the unquoted variants and + // would shadow them ...getEnums(devicesStore), + // the TS language service (when loaded) answers next with type-aware + // completions; static sources below are the no-service fallback + ...(options?.typeAwareSource ? [withStaticExtras(options.typeAwareSource)] : []), ...methods, - snippetSource, + staticSource, ]); return [ autocompletion(), - javascript({ jsx: false }), + javascript({ jsx: false, typescript: !!options?.typescript }), javascriptLanguage.data.of({ autocomplete, }), diff --git a/frontend/src/stores/rules/autocomplete/registry.test.ts b/frontend/src/stores/rules/autocomplete/registry.test.ts new file mode 100644 index 000000000..19f30bc78 --- /dev/null +++ b/frontend/src/stores/rules/autocomplete/registry.test.ts @@ -0,0 +1,38 @@ +import { buildControlsRegistry } from './registry'; + +const cell = (id: string, type: string) => ({ id, type, isSystem: id.startsWith('system__') }); +const store = (cells: ReturnType[]) => + ({ cells: new Map(cells.map((c) => [c.id, c])) }) as any; + +describe('buildControlsRegistry', () => { + it('emits a WbControls interface mapping each device/control to its type', () => { + const dts = buildControlsRegistry(store([ + cell('climate/temperature', 'temperature'), + cell('living/light', 'switch'), + ])); + expect(dts).toContain('interface WbControls {'); + expect(dts).toContain('"climate/temperature": "temperature";'); + expect(dts).toContain('"living/light": "switch";'); + }); + + it('skips system controls and cells whose type is not yet known', () => { + const dts = buildControlsRegistry(store([ + cell('system__networking/ip', 'text'), + cell('sensor/pending', 'incomplete'), + cell('sensor/temp', 'temperature'), + ])); + expect(dts).not.toContain('system__'); + expect(dts).not.toContain('pending'); + expect(dts).toContain('"sensor/temp": "temperature";'); + }); + + it('escapes special characters in ids/types safely', () => { + const dts = buildControlsRegistry(store([cell('a"b/c', 'value')])); + expect(dts).toContain('"a\\"b/c": "value";'); + }); + + it('returns an empty string when there is nothing to declare', () => { + expect(buildControlsRegistry(store([]))).toBe(''); + expect(buildControlsRegistry(store([cell('system__x/y', 'value')]))).toBe(''); + }); +}); diff --git a/frontend/src/stores/rules/autocomplete/registry.ts b/frontend/src/stores/rules/autocomplete/registry.ts new file mode 100644 index 000000000..e34f6a62c --- /dev/null +++ b/frontend/src/stores/rules/autocomplete/registry.ts @@ -0,0 +1,39 @@ +// Structural view of what this needs from the devices store, so a pure +// string builder doesn't drag in the whole store module (and its i18n / +// localStorage init) at import time. +interface CellLike { + id: string; + type: string; + isSystem: boolean; +} +interface DeviceCells { + // optional: a store that hasn't populated its cells yet must not make + // this throw and break editor loading + cells?: Map; +} + +// Builds a `WbControls` declaration from the controller's live device list. +// +// It declaration-merges into the empty `WbControls` interface shipped in +// wb-rules.d.ts, turning on type safety for the stringly-referenced APIs +// (`getControl("device/control")` and `dev["device/control"]`) against the +// controls that actually exist on this controller - with their real types, +// so e.g. writing a string to a numeric control is flagged. References not +// in the list stay loose, exactly as when the registry is empty. +// +// Returns an empty string when there is nothing to declare, so callers can +// skip adding the extra file entirely. +export function buildControlsRegistry(devicesStore: DeviceCells): string { + if (!devicesStore.cells) return ''; + const entries: string[] = []; + for (const cell of devicesStore.cells.values()) { + // skip the engine's own system controls and cells whose type is not + // known yet (meta not received) - an unknown type would just map to + // `any` in the .d.ts anyway + if (cell.isSystem || !cell.type || cell.type === 'incomplete') continue; + // JSON.stringify quotes and escapes both the "device/control" key and + // the type value safely for a .d.ts string literal + entries.push(` ${JSON.stringify(cell.id)}: ${JSON.stringify(cell.type)};`); + } + return entries.length ? `interface WbControls {\n${entries.join('\n')}\n}\n` : ''; +} diff --git a/frontend/src/stores/rules/autocomplete/snippets.ts b/frontend/src/stores/rules/autocomplete/snippets.ts index f75198578..66016fc7a 100644 --- a/frontend/src/stores/rules/autocomplete/snippets.ts +++ b/frontend/src/stores/rules/autocomplete/snippets.ts @@ -2,7 +2,7 @@ import { type CompletionSource, snippetCompletion } from '@codemirror/autocomplete'; -const snippets = [ +export const snippets = [ snippetCompletion( 'log("${1:string}");', { label: 'log', type: 'function', detail: '(fmt, ...args)' }, diff --git a/frontend/src/stores/rules/autocomplete/ts-language-service.test.ts b/frontend/src/stores/rules/autocomplete/ts-language-service.test.ts new file mode 100644 index 000000000..9a08c5126 --- /dev/null +++ b/frontend/src/stores/rules/autocomplete/ts-language-service.test.ts @@ -0,0 +1,100 @@ +import { loadTsEditorSupport } from './ts-language-service'; + +describe('ts-language-service', () => { + it('builds editor support with extensions and a completion source seeded with wb-rules types', async () => { + const support = await loadTsEditorSupport('demo.ts', 'const n: number = 1;\n'); + expect(support.extensions.length).toBeGreaterThanOrEqual(4); + expect(typeof support.completionSource).toBe('function'); + // 30s: the first build pays the language-service cold start (parsing + // every lib.*.d.ts), which exceeds the 5s default on loaded CI hosts + }, 30000); + + it('builds support for plain .js rule files too (allowJs completions/hover)', async () => { + const support = await loadTsEditorSupport('legacy.js', 'var n = 1;\n'); + expect(support.extensions.length).toBeGreaterThanOrEqual(4); + expect(typeof support.completionSource).toBe('function'); + }, 30000); + + it('reuses the environment for the same file and rebuilds for another file', async () => { + const first = loadTsEditorSupport('demo.ts', ''); + const again = loadTsEditorSupport('demo.ts', ''); + expect(again).toBe(first); + const other = loadTsEditorSupport('other.ts', ''); + expect(other).not.toBe(first); + }); +}); + +describe('typed wb-rules API surface', () => { + // the promise-native idiom must check clean: changed() defaults to any + it('accepts awaited changed() values in arithmetic without complaints', async () => { + const content = [ + 'async function scenario() {', + ' let value = await changed("ts_demo/temperature");', + ' log(`got ${value}`);', + ' dev["ts_demo/new_temperature"] = value + 1;', + '}', + 'scenario();', + '', + ].join('\n'); + const support = await loadTsEditorSupport('changed-flow.ts', content); + expect(support.getDiagnostics()).toEqual([]); + }, 30000); + + it('rejects options illegal for the control type and rule names as rule ids', async () => { + const content = [ + 'defineVirtualDevice("d", {', + ' cells: { sw: { type: "switch", value: false, min: 0 } },', + '});', + 'disableRule("named-rule");', + '', + ].join('\n'); + const support = await loadTsEditorSupport('typed-errors.ts', content); + const diags = support.getDiagnostics(); + expect(diags.some((d) => d.line === 2 && d.message.includes('\'min\''))).toBe(true); + expect(diags.some((d) => d.line === 4)).toBe(true); + }, 30000); +}); + +describe('live-device registry typing', () => { + const registry = 'interface WbControls { "climate/temperature": "temperature"; "living/lamp": "switch"; }\n'; + + it('flags wrong-typed writes to registered string references', async () => { + const content = [ + 'getControl("climate/temperature").setValue("nope");', // line 1 + 'dev["climate/temperature"] = "nope";', // line 2 + 'dev["living/lamp"] = 5;', // line 3 + ].join('\n'); + const support = await loadTsEditorSupport('registry-typed.ts', content, undefined, registry); + const diags = support.getDiagnostics(); + expect(diags.some((d) => d.line === 1)).toBe(true); + expect(diags.some((d) => d.line === 2)).toBe(true); + expect(diags.some((d) => d.line === 3)).toBe(true); + }, 30000); + + it('leaves references not in the registry loose', async () => { + const content = [ + 'dev["unknown/ctrl"] = "anything";', + 'const c = getControl("unknown/ctrl");', + 'const n = dev["climate/temperature"] + 1;', // registered numeric, fine in arithmetic + ].join('\n'); + const support = await loadTsEditorSupport('registry-loose.ts', content, undefined, registry); + expect(support.getDiagnostics()).toEqual([]); + }, 30000); +}); + +describe('type-aware completion surface', () => { + // regression: valtown's whitelist filter used to hide all ambient globals + it('offers the wb-rules API for an identifier prefix', async () => { + const { CompletionContext } = await import('@codemirror/autocomplete'); + const { EditorState } = await import('@codemirror/state'); + const content = 'var motion = 1;\ndefi'; + const support = await loadTsEditorSupport('probe.ts', content); + const state = EditorState.create({ doc: content, extensions: support.extensions }); + const result = await support.completionSource( + new CompletionContext(state, state.doc.length, false), + ); + const labels = (result?.options ?? []).map((o) => o.label); + expect(labels).toContain('defineRule'); + expect(labels).toContain('defineVirtualDevice'); + }, 30000); +}); diff --git a/frontend/src/stores/rules/autocomplete/ts-language-service.ts b/frontend/src/stores/rules/autocomplete/ts-language-service.ts new file mode 100644 index 000000000..0862e0148 --- /dev/null +++ b/frontend/src/stores/rules/autocomplete/ts-language-service.ts @@ -0,0 +1,129 @@ +import type { TsEditorSupport } from './types'; +import wbRulesDts from './wb-rules.d.ts?raw'; + +// Browser-side TypeScript language service for .ts rule files: live type +// checking (squiggles while you type), type-aware completions and hover +// type info, seeded with the wb-rules builtin declarations. +// +// Everything heavy (the typescript package and its lib.*.d.ts files) is +// imported dynamically from here, and this module itself is imported +// dynamically by the edit page, so .js-only users never download it. + +// the same set the engine-side check uses: --lib esnext, no DOM globals +const libFiles = import.meta.glob('/node_modules/typescript/lib/lib.es*.d.ts', { + query: '?raw', + import: 'default', + eager: true, +}) as Record; +const decoratorLibs = import.meta.glob('/node_modules/typescript/lib/lib.decorators*.d.ts', { + query: '?raw', + import: 'default', + eager: true, +}) as Record; + +let cached: Promise | null = null; +let cachedPath = ''; +let cachedContent = ''; +let cachedTypes = ''; +let cachedRegistry = ''; + +async function build( + path: string, + initialContent: string, + typesDts: string, + registryDts: string, +): Promise { + const [ts, vfs, cmts] = await Promise.all([ + import('typescript').then((m) => m.default), + import('@typescript/vfs'), + import('@valtown/codemirror-ts'), + ]); + + const compilerOptions = { + target: ts.ScriptTarget.ESNext, + lib: ['lib.esnext.d.ts'], + allowJs: true, + strict: false, + noEmit: true, + }; + + const fsMap = new Map(); + for (const [modulePath, text] of Object.entries({ ...libFiles, ...decoratorLibs })) { + fsMap.set('/' + modulePath.split('/').pop(), text); + } + fsMap.set('/wb-rules.d.ts', typesDts); + fsMap.set(path, initialContent || '\n'); + + // the live-device registry (declaration-merges into WbControls) types the + // stringly-referenced getControl()/dev[] APIs; only added when non-empty + const rootFiles = [path, '/wb-rules.d.ts']; + if (registryDts) { + fsMap.set('/wb-controls.d.ts', registryDts); + rootFiles.push('/wb-controls.d.ts'); + } + + const system = vfs.createSystem(fsMap); + const env = vfs.createVirtualTypeScriptEnvironment(system, rootFiles, ts, compilerOptions); + + return { + extensions: [ + // without the flag, valtown's completion filter drops every ambient + // global (sortText "15") not on its hardcoded standard-JS whitelist - + // i.e. the entire wb-rules API from wb-rules.d.ts + cmts.tsFacet.of({ env, path, keepLegacyLimitationForAutocompletionSymbols: false }), + cmts.tsSync(), + cmts.tsLinter(), + cmts.tsHover(), + ], + completionSource: cmts.tsAutocomplete(), + // current local verdict, used to de-duplicate the controller's + // diagnostics against what the editor already shows + getDiagnostics: () => { + const sourceFile = env.getSourceFile(path); + if (!sourceFile) return []; + const all = [ + ...env.languageService.getSyntacticDiagnostics(path), + ...env.languageService.getSemanticDiagnostics(path), + ]; + return all + .filter((d) => d.start !== undefined) + .map((d) => ({ + line: sourceFile.getLineAndCharacterOfPosition(d.start).line + 1, + message: ts.flattenDiagnosticMessageText(d.messageText, ' '), + })); + }, + }; +} + +// One shared environment: rule files are edited one at a time, and the +// language service survives page switches (path changes recreate it). +export function loadTsEditorSupport( + fileName: string, + initialContent: string, + controllerTypes?: string, + registryDts = '', +): Promise { + const path = '/' + (fileName.replace(/^\/+/, '') || 'rule.ts'); + const typesDts = controllerTypes || wbRulesDts; + // the content check matters on reopen: tsSync only tracks in-editor + // edits, so a file changed elsewhere must reseed the environment. The + // registry is a snapshot taken when the editor opens (like the + // controller types), so a change in it also reseeds. + if ( + !cached || + cachedPath !== path || + cachedContent !== initialContent || + cachedTypes !== typesDts || + cachedRegistry !== registryDts + ) { + cachedPath = path; + cachedContent = initialContent; + cachedTypes = typesDts; + cachedRegistry = registryDts; + cached = build(path, initialContent, typesDts, registryDts).catch((e) => { + cached = null; // a failed load must not poison TS support forever + throw e; + }); + } + return cached; +} diff --git a/frontend/src/stores/rules/autocomplete/types.ts b/frontend/src/stores/rules/autocomplete/types.ts new file mode 100644 index 000000000..2702e285e --- /dev/null +++ b/frontend/src/stores/rules/autocomplete/types.ts @@ -0,0 +1,15 @@ +import type { CompletionSource } from '@codemirror/autocomplete'; +import type { Extension } from '@codemirror/state'; +import type { LocalTsDiag, TsCheckDiag } from '../types'; + +export interface TsEditorSupport { + extensions: Extension[]; + completionSource: CompletionSource; + getDiagnostics: () => LocalTsDiag[]; +} + +export interface ControllerVerdict { + diags: TsCheckDiag[]; + // the editor content the verdict was computed for; null = unknown + checkedContent: string | null; +} diff --git a/frontend/src/stores/rules/autocomplete/wb-rules.d.ts b/frontend/src/stores/rules/autocomplete/wb-rules.d.ts new file mode 100644 index 000000000..71d82fc56 --- /dev/null +++ b/frontend/src/stores/rules/autocomplete/wb-rules.d.ts @@ -0,0 +1,778 @@ +// Type declarations for the wb-rules scripting API. +// +// Consumed in two places: +// - the engine's background TypeScript check (tsgo --noEmit) includes this +// file so rule scripts see the builtins as typed globals; +// - the homeui rules editor loads it to provide typed completions. +// +// The API itself is defined by scripts/lib.js, modules/wb-notify.js, +// modules/wb-alarms.js and the engine's DefineFunctions. Everything here is +// grounded in that code: option fields the engine rejects are typed away, +// per-control-type option sets match fillControlArgs exactly, and the +// rule-management functions take the numeric rule id defineRule returns. +// +// Design notes (techniques adapted from the public-domain wb-mirta/core +// `@mirta/globals` package): a single TypeMappings interface derives the +// control-type union and per-type value types; ControlOptions is a +// discriminated union so illegal option/type combinations fail to compile; +// rule ids are a branded number so arbitrary numbers (or rule names) are +// rejected by enableRule/disableRule/runRule. + +// --------------------------------------------------------------------------- +// Utility types +// --------------------------------------------------------------------------- + +/** Flattens an intersection so editor hovers show a single object literal. */ +declare type WbExpand = { [K in keyof T]: T[K] } & {}; + +/** Like Partial, but at least one of the properties must be present. */ +declare type WbAtLeastOne }> = Partial & U[keyof U]; + +/** + * Nominal (branded) alias of a primitive type. Purely a compile-time + * marker: values are plain primitives at runtime. + */ +declare type WbBranded = TValue & { readonly __wbBrand: TBrand }; + +/** Localized text: language code ("en", "ru", ...) to translation. */ +declare type LocalizedText = Record; + +/** A human-readable title: a plain string ("en") or per-language texts. */ +declare type Title = string | LocalizedText; + +// --------------------------------------------------------------------------- +// Controls: the type map everything else derives from +// --------------------------------------------------------------------------- + +/** + * Maps every control (cell) type to the JavaScript type its value has when + * read through `dev`, a rule's `newValue`, or a control object's getValue(). + * + * Derived from the Wiren Board MQTT conventions; extend here and every + * dependent type (CellType, ControlOptions, typed controls) follows. + */ +interface TypeMappings { + /** Boolean switch (writable by default). */ + switch: boolean; + /** Write-only switch: command out, no state readback. */ + "wo-switch": boolean; + /** Alarm indicator. */ + alarm: boolean; + /** Stateless push button; the only type that needs no initial `value`. */ + pushbutton: boolean; + + /** Arbitrary text. */ + text: string; + /** Color in "R;G;B" form, e.g. "255;127;0". */ + rgb: string; + + /** Generic numeric value. */ + value: number; + /** Integer slider between min and max (writable by default). */ + range: number; + /** Unix timestamp, seconds. */ + unixtime: number; + /** Temperature, °C. */ + temperature: number; + /** Relative humidity, %. */ + rel_humidity: number; + /** Atmospheric pressure, mbar. */ + atmospheric_pressure: number; + /** Rainfall rate, mm/h. */ + rainfall: number; + /** Wind speed, m/s. */ + wind_speed: number; + /** Power, W. */ + power: number; + /** Energy, kWh. */ + power_consumption: number; + /** Voltage, V. */ + voltage: number; + /** Water flow, m³/h. */ + water_flow: number; + /** Water volume, m³. */ + water_consumption: number; + /** Resistance, Ohm. */ + resistance: number; + /** Gas concentration, ppm. */ + concentration: number; + /** Pressure, bar. */ + pressure: number; + /** Illuminance, lux. */ + lux: number; + /** Sound level, dB. */ + sound_level: number; + /** Heat power, Gcal/h. */ + heat_power: number; + /** Heat energy, Gcal. */ + heat_energy: number; + /** Current, A. */ + current: number; +} + +/** Union of all control (cell) types. */ +declare type CellType = keyof TypeMappings; + +/** Any control value. */ +declare type CellValue = TypeMappings[CellType]; + +/** Value type of a specific control type: CellValueOf<"switch"> is boolean. */ +declare type CellValueOf = TypeMappings[T]; + +// --------------------------------------------------------------------------- +// Control options (defineVirtualDevice cells) +// --------------------------------------------------------------------------- + +/** Options every control type accepts. */ +interface WbControlOptionsBase { + /** Control type; decides the value type and which other options are legal. */ + type: TType; + /** Title shown in the UI (plain string = English, or per-language map). */ + title?: Title; + /** Longer description shown in the UI. */ + description?: string; + /** + * Forbid writes from the UI and rules. Defaults: switch, pushbutton, + * range and rgb are writable; every other type is read-only. + */ + readonly?: boolean; + /** Position among the device's controls (integer, >= 0). */ + order?: number; + /** + * Do not create the MQTT control until a value is first assigned + * (e.g. `dev["device/control"] = value`). + */ + lazyInit?: boolean; + /** + * Reset to `value` on every engine start instead of restoring the last + * retained value. + */ + forceDefault?: boolean; + /** @deprecated The engine rejects this flag - use `readonly` instead. */ + writeable?: never; +} + +/** + * The initial value. Required for every type except pushbutton + * (a pushbutton is stateless). + */ +type __WbControlValue = TType extends "pushbutton" + ? { value?: TypeMappings[TType] } + : { value: TypeMappings[TType] }; + +/** + * Per-type extra options, matching what the engine actually reads: + * units only on "value"; precision on "value" and "range"; enum titles on + * "value" and "text"; min/max on "value" and "range". + * + * Options a type does not support are declared as `?: never` instead of + * being omitted: providing one then fails real assignability, so the error + * fires even where object-literal freshness checks do not reach (e.g. + * through generic parameter inference in defineVirtualDevice). + */ +type __WbControlExtras = TType extends "value" + ? { + /** Unit shown next to the value (e.g. "W", "m³/h"). */ + units?: string; + /** Number of decimal places shown. */ + precision?: number; + /** + * Titles for the allowed values. Note: each title must be a + * per-language map - the engine silently drops plain strings. + */ + enum?: Record; + /** Smallest accepted value. */ + min?: number; + /** Largest accepted value. */ + max?: number; + } + : TType extends "range" + ? { + /** Number of decimal places shown. */ + precision?: number; + /** Smallest accepted value (default 0). */ + min?: number; + /** Largest accepted value (default 255). */ + max?: number; + units?: never; + enum?: never; + } + : TType extends "text" + ? { + /** + * Titles for the allowed values. Note: each title must be a + * per-language map - the engine silently drops plain strings. + */ + enum?: Record; + units?: never; + precision?: never; + min?: never; + max?: never; + } + : { + units?: never; + precision?: never; + enum?: never; + min?: never; + max?: never; + }; + +/** + * A control declaration for defineVirtualDevice()/addControl(). + * + * A discriminated union over `type`: options illegal for the chosen type do + * not compile, e.g. `{ type: "switch", min: 0 }` is an error because only + * "value" and "range" controls have `min`. + */ +declare type ControlOptions = { + [K in CellType]: WbExpand< + WbControlOptionsBase & __WbControlValue & __WbControlExtras + >; +}[CellType]; + +/** Options of one specific control type: ControlOptionsOfType<"range">. */ +declare type ControlOptionsOfType = Extract; + +declare type SwitchControlOptions = ControlOptionsOfType<"switch">; +declare type PushbuttonControlOptions = ControlOptionsOfType<"pushbutton">; +declare type AlarmControlOptions = ControlOptionsOfType<"alarm">; +declare type ValueControlOptions = ControlOptionsOfType<"value">; +declare type RangeControlOptions = ControlOptionsOfType<"range">; +declare type TextControlOptions = ControlOptionsOfType<"text">; +declare type RgbControlOptions = ControlOptionsOfType<"rgb">; + +/** @deprecated Old name; use ControlOptions. */ +declare type CellSpec = ControlOptions; + +// --------------------------------------------------------------------------- +// Virtual devices +// --------------------------------------------------------------------------- + +/** The controls of a device declaration, by control name. */ +declare type ControlsSpec = Record; + +/** Device declaration: a title plus controls under `cells` or `controls`. */ +declare type VirtualDeviceSpec = + | { title?: Title; cells: ControlsSpec } + | { title?: Title; controls: ControlsSpec }; + +/** + * A control of a virtual device, as returned by getControl(). + * + * The type parameter tracks the control's declared type, so getValue() on a + * control obtained from a typed device returns boolean/number/string as + * declared instead of the full union. + */ +interface VirtualDeviceControl { + getId(): string; + getValue(): TypeMappings[TType]; + setValue(value: TypeMappings[TType] | { value: TypeMappings[TType]; notify?: boolean }): void; + /** Error state: a non-empty string marks the control as failed in the UI. */ + getError(): string; + setError(error: string): void; + getType(): TType; + setType(type: CellType): void; + getDescription(): string; + setDescription(description: string): void; + /** Title in the given language ("en" when omitted). */ + getTitle(lang?: string): string; + setTitle(title: Title): void; + getReadonly(): boolean; + setReadonly(readonly: boolean): void; + getMax(): number; + setMax(max: number): void; + getMin(): number; + setMin(min: number): void; + getPrecision(): number; + setPrecision(precision: number): void; + getUnits(): string; + setUnits(units: string): void; + getOrder(): number; + setOrder(order: number): void; + /** Value titles; each title is a per-language map. */ + setEnumTitles(titles: Record): void; +} + +/** Cells record of a device spec (whether declared as `cells` or `controls`). */ +type __WbCellsOf = S extends { cells: infer C extends ControlsSpec } + ? C + : S extends { controls: infer C extends ControlsSpec } + ? C + : ControlsSpec; + +/** + * A virtual device. When obtained from defineVirtualDevice(), getControl() + * with a literal control name returns a control typed by that control's + * declared type. + */ +interface VirtualDevice { + getId(): string; + /** Full "device/control" reference of the named control. */ + getCellId(cellName: string): string; + addControl(name: string, spec: ControlOptions): void; + removeControl(name: string): void; + getControl(name: K): VirtualDeviceControl; + getControl(name: string): VirtualDeviceControl; + isControlExists(name: string): boolean; + controlsList(): VirtualDeviceControl[]; + isVirtual(): boolean; + /** Error state: a non-empty string marks the whole device as failed. */ + getError(): string; + setError(error: string): void; +} + +/** + * Creates a virtual device backed by MQTT. + * + * The returned device is typed by the declaration: + * + * ```ts + * const dv = defineVirtualDevice("climate", { + * title: "Climate", + * cells: { + * temperature: { type: "temperature", value: 0 }, + * enabled: { type: "switch", value: false }, + * }, + * }); + * const t = dv.getControl("temperature").getValue(); // number + * ``` + */ +declare function defineVirtualDevice( + name: string, + spec: S +): VirtualDevice<__WbCellsOf>; + +/** The device with the given id, or undefined if there is no such device. */ +declare function getDevice(id: string): VirtualDevice | undefined; + +/** + * Registry of known "device/control" references to their control type, + * used to type the stringly-referenced APIs (`getControl("dev/ctrl")` and + * `dev["dev/ctrl"]`). + * + * Empty by default, so those APIs stay loose (`any`/untyped) wherever the + * registry is not populated - notably the engine's on-controller type + * check, which must not error on references it cannot know about. + * + * Populate it by declaration merging to get real type safety. The homeui + * rules editor does this automatically from the controller's live device + * list; you can also do it by hand for your own devices: + * + * ```ts + * declare global { + * interface WbControls { + * "climate/temperature": "temperature"; // -> number + * "living/light": "switch"; // -> boolean + * } + * } + * ``` + * + * With that in scope, `getControl("climate/temperature").setValue("x")` + * and `dev["climate/temperature"] = "x"` are compile errors, while any + * reference not listed stays loose. + */ +interface WbControls {} + +/** + * The control at "device/control". For a reference listed in WbControls the + * result is typed by that control's type; any other reference returns the + * loose control-or-undefined. + */ +declare function getControl( + ref: K +): VirtualDeviceControl; +declare function getControl(ref: string): VirtualDeviceControl | undefined; + +// --------------------------------------------------------------------------- +// Rules +// --------------------------------------------------------------------------- + +/** + * Identifier of a rule, returned by defineRule(). + * + * A branded number: enableRule/disableRule/runRule accept only a value that + * came from defineRule, so passing a rule name or an arbitrary number is a + * compile-time error (and would fail at runtime). + */ +declare type RuleId = WbBranded; + +interface CronEntry { + readonly spec: string; +} + +/** + * A cron schedule for a rule's `when`, e.g. cron("@hourly") or + * cron("0 0 9 * * MON-FRI"). See the robfig/cron expression format. + */ +declare function cron(spec: string): CronEntry; + +type RuleCondition = () => unknown; + +interface RuleSpec { + /** + * Fire on control changes: a "device/control" reference, an alias name, + * a condition function whose return value is watched, or an array of + * those. + */ + whenChanged?: string | RuleCondition | Array; + /** Fire whenever the condition is true, or on a cron() schedule. */ + when?: RuleCondition | CronEntry; + /** Fire once each time the condition switches from false to true. */ + asSoonAs?: RuleCondition; + _cron?: string; + /** + * The rule body. For whenChanged rules the arguments are the new value + * and the "device", "control" pair that caused the trigger; for other + * kinds they are undefined. May be async: rejections are reported to the + * rule engine log. + */ + then: (newValue?: any, devName?: string, cellName?: string) => void | Promise; + readonly?: boolean; +} + +/** + * Defines a rule and returns its id. + * + * ```ts + * const nightLight = defineRule("night-light", { + * whenChanged: "motion/detected", + * then: (v) => { dev["light/on"] = !!v; }, + * }); + * disableRule(nightLight); + * ``` + */ +declare function defineRule(name: string, spec: RuleSpec): RuleId; +declare function defineRule(spec: RuleSpec): RuleId; + +/** Makes `aliasName` usable in place of "device/control" references. */ +declare function defineAlias(aliasName: string, cellRef: string): void; + +/** Re-enables a rule disabled with disableRule(). */ +declare function enableRule(ruleId: RuleId): void; +/** Disables a rule: it stops reacting to events until enableRule(). */ +declare function disableRule(ruleId: RuleId): void; +/** + * Runs a rule's `then` immediately, with no trigger context (newValue and + * the device/control arguments are undefined). + */ +declare function runRule(ruleId: RuleId): void; +declare function runRules(): void; + +// --------------------------------------------------------------------------- +// Device access +// --------------------------------------------------------------------------- + +/** + * Device/control access proxy. + * + * Values: `dev["device/control"]`, `dev["device"]["control"]` or + * `dev.device.control` reads the current value; assignment writes it. + * + * Metadata: append `#` to the control name to read or write control + * metadata, e.g. `dev["device/control#error"] = "sensor offline"` or + * `const t = dev["device/control#type"]`. + * + * A `"device/control"` reference listed in WbControls is typed by that + * control's type on both read and write (`dev["climate/temperature"]` is a + * number; assigning a string is an error). Every other key - unlisted + * references, the nested `dev["device"]["control"]` and `dev.device.control` + * forms, and the `#meta` suffix - stays loose (`any`). The shipped registry + * is empty, so without population everything is `any`, exactly as before. + */ +declare const dev: { + [K in keyof WbControls | (string & {})]: K extends keyof WbControls + ? WbControls[K] extends CellType + ? TypeMappings[WbControls[K]] + : any + : any; +}; + +// --------------------------------------------------------------------------- +// Logging +// --------------------------------------------------------------------------- + +interface LogFunction { + /** Logs a message; `{}` placeholders are replaced by the arguments. */ + (format: string, ...args: any[]): void; + /** Logs an arbitrary value. */ + (value: unknown): void; + debug(format: string, ...args: any[]): void; + debug(value: unknown): void; + info(format: string, ...args: any[]): void; + info(value: unknown): void; + warning(format: string, ...args: any[]): void; + warning(value: unknown): void; + error(format: string, ...args: any[]): void; + error(value: unknown): void; +} +/** Engine log; log(...) is the same as log.info(...). */ +declare const log: LogFunction; +/** Logs only when rule debugging is enabled. */ +declare function debug(format: string, ...args: any[]): void; +/** Replaces `{}` placeholders in the format string with the arguments. */ +declare function format(format: string, ...args: any[]): string; + +// --------------------------------------------------------------------------- +// MQTT +// --------------------------------------------------------------------------- + +/** + * Publishes a raw MQTT message. Do not use this to change device controls - + * assign through `dev` instead. + */ +declare function publish(topic: string, payload: CellValue, qos?: 0 | 1 | 2, retain?: boolean): void; + +interface MqttMessage { + topic: string; + value: string; + retained: boolean; + qos: number; +} +/** Subscribes to an MQTT topic ("#" and "+" wildcards are allowed). */ +declare function trackMqtt(topic: string, callback: (message: MqttMessage) => void): void; +/** + * Resolves with the next live (non-retained) MQTT message on the topic. + * With timeoutMs set, rejects if no message arrives in time. + */ +declare function nextMqtt(topic: string, timeoutMs?: number): Promise; + +// --------------------------------------------------------------------------- +// Timers +// --------------------------------------------------------------------------- + +interface Timer { + readonly firing: boolean; + stop(): void; +} +/** Named timers started with startTimer()/startTicker(). */ +declare const timers: Record; +/** One-shot named timer; watch it with `when: () => timers.name.firing`. */ +declare function startTimer(name: string, milliseconds: number): void; +/** Periodic named timer. */ +declare function startTicker(name: string, milliseconds: number): void; + +/** + * Resolves with the control's new value on its next change - the same + * semantics (triggers, value conversion) as a rule's whenChanged. + * With timeoutMs set, rejects if nothing changes in time. + * + * The result type defaults to `any`; pass a type argument to pin it: + * `const t = await changed("climate/temperature")`. + */ +declare function changed(ctrl: string, timeoutMs?: number): Promise; +/** Promise-returning pause: await sleep(1000). The engine is not blocked. */ +declare function sleep(milliseconds: number): Promise; +declare function setTimeout(callback: () => void, milliseconds: number): number; +declare function setInterval(callback: () => void, milliseconds: number): number; +declare function clearTimeout(id: number): void; +declare function clearInterval(id: number): void; + +// --------------------------------------------------------------------------- +// Processes +// --------------------------------------------------------------------------- + +type ExitCallback = (exitCode: number, capturedOutput?: string, capturedErrorOutput?: string) => void; + +interface ShellCommandOptions { + /** Capture stdout and deliver it in the result / exit callback. */ + captureOutput?: boolean; + /** Capture stderr instead of passing it through to the engine's stderr. */ + captureErrorOutput?: boolean; + /** Text to feed to the process on stdin. */ + input?: string; + exitCallback?: ExitCallback; +} +interface SpawnResult { + /** Process exit code; a nonzero exit resolves the promise, it does not reject. */ + exitCode: number; + capturedOutput: string | null; + capturedErrorOutput?: string; +} +/** Resolves on process exit; rejects only when the process cannot start. */ +declare function runShellCommand( + command: string, + options?: ShellCommandOptions | ExitCallback +): Promise; +/** Resolves on process exit; rejects only when the process cannot start. */ +declare function spawn( + command: string, + args: string[], + options?: ShellCommandOptions | ExitCallback +): Promise; + +// --------------------------------------------------------------------------- +// Configuration and storage +// --------------------------------------------------------------------------- + +interface ReadConfigOptions { + /** Log an error when the file is missing (default true). */ + logErrorOnNoFile?: boolean; +} +/** Parses a JSON configuration file (comments allowed). */ +declare function readConfig(path: string, options?: ReadConfigOptions): any; + +interface PersistentStorageOptions { + /** Share the storage between all rule files instead of per-file. */ + global?: boolean; +} +/** + * A persistent key-value storage that survives engine restarts. + * Give it a shape for typed access: + * `const s = PersistentStorage<{ count: number }>("stats", { global: true })`. + */ +declare function PersistentStorage = Record>( + name: string, + options?: PersistentStorageOptions +): T; +/** + * Wraps an object so property changes propagate back to the + * PersistentStorage slot it is stored in. + */ +declare function StorableObject(obj: T): T; + +// --------------------------------------------------------------------------- +// Notifications (modules/wb-notify.js, available as the Notify global) +// --------------------------------------------------------------------------- + +/** Called when the notification has been handed off; error is null on success. */ +type NotifyCallback = (error: Error | null) => void; + +interface WebhookOptions { + url: string; + /** HTTP method; default POST when a body is present, GET otherwise. */ + method?: string; + /** Request body; objects are JSON-encoded. */ + body?: string | object; + /** Content-Type header; inferred from the body when omitted. */ + contentType?: string; + headers?: Record; +} + +interface TelegramMessageOptions { + /** Telegram parse_mode, e.g. "MarkdownV2" or "HTML". */ + parseMode?: string; + disableWebPagePreview?: boolean; + disableNotification?: boolean; +} + +interface NotifyApi { + /** Sends an email through the local sendmail. */ + sendEmail(to: string, subject: string, text: string, callback?: NotifyCallback): void; + /** Sends an SMS via ModemManager (or gammu); `command` overrides the tool. */ + sendSMS(to: string, text: string, command?: string, callback?: NotifyCallback): void; + sendSMS(to: string, text: string, callback: NotifyCallback): void; + /** Performs an HTTP request (curl) with the given options. */ + sendWebhook(options: WebhookOptions, callback?: NotifyCallback): void; + /** Sends a message via a Telegram bot. */ + sendTelegramMessage( + token: string, + chatId: string, + text: string, + options?: TelegramMessageOptions, + callback?: NotifyCallback + ): void; + sendTelegramMessage(token: string, chatId: string, text: string, callback: NotifyCallback): void; + /** Uppercases/validates an HTTP method name, defaulting appropriately. */ + normalizeWebhookMethod(method?: string): string; +} +/** Notification channels: email, SMS, webhooks, Telegram. */ +declare const Notify: NotifyApi; + +// --------------------------------------------------------------------------- +// Alarms (modules/wb-alarms.js, available as the Alarms global) +// --------------------------------------------------------------------------- + +declare type AlarmRecipient = + | { type: "email"; to: string; subject?: string } + | { type: "sms"; to: string; command?: string } + | { type: "telegram"; token: string; chatId: string } + | { type: "vk"; token: string; peerId: string; apiVersion?: string } + | { type: "max"; token: string; chatId: string } + | { type: "matrix"; homeserver: string; accessToken: string; roomId: string; msgType?: string } + | { type: "wechat"; key: string } + | { + type: "webhook"; + url: string; + method?: string; + contentType?: string; + headers?: Record; + /** Body template; `{}` is replaced with the alarm message. */ + bodyTemplate?: string; + }; + +interface AlarmBase { + /** Alarm cell name; derived from the watched cell when omitted. */ + name?: string; + /** The watched control, as "device/control". */ + cell: string; + /** Message sent when the alarm activates; `{}` is replaced by the value. */ + alarmMessage?: string; + /** Message sent when the alarm deactivates; `{}` is replaced by the value. */ + noAlarmMessage?: string; + /** Repeat interval for reminders about a still-active alarm, seconds. */ + interval?: number; + /** Maximum number of messages sent per activation. */ + maxCount?: number; + /** Require the out-of-range state to persist this long, ms. */ + alarmDelayMs?: number; + /** Require the back-to-normal state to persist this long, ms. */ + noAlarmDelayMs?: number; +} + +/** + * One alarm: watches a cell and alerts either when its value differs from + * `expectedValue`, or when it leaves the [minValue, maxValue] range (at + * least one bound required). + */ +declare type AlarmSpec = AlarmBase & + ({ expectedValue: CellValue } | WbAtLeastOne<{ minValue: number; maxValue: number }>); + +interface AlarmsConfig { + /** Virtual device created for the alarm cells and log. */ + deviceName: string; + deviceTitle?: Title; + recipients: AlarmRecipient[]; + alarms: AlarmSpec[]; +} + +interface AlarmsApi { + /** Loads alarms from a JSON config file path or an inline config object. */ + load(config: string | AlarmsConfig): void; +} +/** Threshold alarms with notification fan-out (see AlarmsConfig). */ +declare const Alarms: AlarmsApi; + +// --------------------------------------------------------------------------- +// String formatting (lib.js augments String.prototype) +// --------------------------------------------------------------------------- + +interface String { + /** Replaces `{}` placeholders with the arguments, like format(). */ + format(...args: any[]): string; + /** + * Like format(), but placeholders may contain expressions that are + * EVALUATED as code. Never use with untrusted input. + */ + xformat(...args: any[]): string; +} + +// --------------------------------------------------------------------------- +// Module system +// --------------------------------------------------------------------------- + +/** Absolute path of the current rule file. */ +declare const __filename: string; + +/** Per-file module object (rule files are CommonJS-like scenarios). */ +declare const module: { + readonly filename: string; + /** Storage shared between reloads of this file. */ + readonly static: Record; +}; + +declare function require(id: string): any; + +declare const global: typeof globalThis; + +// CommonJS-style module surface available in every rule file +declare var exports: Record; diff --git a/frontend/src/stores/rules/rules-store-typescript.test.ts b/frontend/src/stores/rules/rules-store-typescript.test.ts new file mode 100644 index 000000000..a69b78f36 --- /dev/null +++ b/frontend/src/stores/rules/rules-store-typescript.test.ts @@ -0,0 +1,42 @@ +// TypeScript rule-file support in the rules store (new engine feature). +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@/services', () => import('@/test/mocks/services')); +vi.mock('@/utils/id', () => import('@/test/mocks/utils-id')); + +const { default: RulesStore } = await import('./rules-store'); + +describe('rules store TypeScript support', () => { + const store = new RulesStore(); + + it('keeps .ts names as-is', () => { + expect(store.getValidRuleName('heating.ts')).toBe('heating.ts'); + }); + + it('keeps .js names as-is', () => { + expect(store.getValidRuleName('heating.js')).toBe('heating.js'); + }); + + it('defaults extensionless names to .js', () => { + expect(store.getValidRuleName('heating')).toBe('heating.js'); + }); +}); + +const { editorProxyMock } = await import('@/test/mocks/services'); + +describe('rename/save pre-check extension', () => { + + it('checks the .ts path a rename of a .ts rule will target', async () => { + const store = new RulesStore(); + store.rule.initName = 'heating.ts'; + editorProxyMock.List.mockResolvedValue([{ virtualPath: 'bar.ts' }]); + await expect(store.checkIsNameUnique('bar')).rejects.toThrow('file-exists'); + }); + + it('does not flag a same-named .js file when renaming a .ts rule', async () => { + const store = new RulesStore(); + store.rule.initName = 'heating.ts'; + editorProxyMock.List.mockResolvedValue([{ virtualPath: 'bar.js' }]); + await expect(store.checkIsNameUnique('bar')).resolves.toBe(true); + }); +}); diff --git a/frontend/src/stores/rules/rules-store.ts b/frontend/src/stores/rules/rules-store.ts index 8f7f2bf37..cc430c805 100644 --- a/frontend/src/stores/rules/rules-store.ts +++ b/frontend/src/stores/rules/rules-store.ts @@ -1,7 +1,7 @@ import { makeAutoObservable, runInAction } from 'mobx'; import { editorProxy, mqttClient } from '@/services'; import { generateNextId } from '@/utils/id'; -import type { Rule, RuleError, RuleLevel, RuleListItem, RuleLog } from './types'; +import type { Rule, RuleError, RuleLevel, RuleListItem, RuleLog, TsCheckDiag } from './types'; export default class RulesStore { public rule?: Rule = { @@ -13,6 +13,9 @@ export default class RulesStore { public isRuleDebugEnabled = false; public logs: RuleLog[] = []; public logLevelFilter = 'all'; + public tsCheckDiags: TsCheckDiag[] = []; + public tsCheckedContent: string | null = null; + private _tsCheckToken = 0; constructor() { makeAutoObservable(this); @@ -92,15 +95,21 @@ export default class RulesStore { } async rename(oldName: string, newName: string): Promise { - return editorProxy.Rename({ path: oldName, new_path: this.getValidRuleName(newName) }) + // an extensionless new title keeps the file's language: renaming + // foo.ts to "bar" must not silently turn it into bar.js + const extension = oldName.endsWith('.ts') ? '.ts' : '.js'; + return editorProxy.Rename({ path: oldName, new_path: this.getValidRuleName(newName, extension) }) .then(async () => { await new Promise((resolve) => setTimeout(resolve, 1500)); - return this.getValidRuleName(newName); + return this.getValidRuleName(newName, extension); }); } async checkIsNameUnique(name: string): Promise { - const path = this.getValidRuleName(name); + // test the same path the upcoming save/rename will target: a rename + // keeps the old file's extension, a fresh save defaults to .js + const extension = this.rule?.initName?.endsWith('.ts') ? '.ts' : '.js'; + const path = this.getValidRuleName(name, extension); const list = await this.getList(); if (list.some((rule) => rule.virtualPath === path)) { throw new Error('file-exists'); @@ -109,8 +118,8 @@ export default class RulesStore { return true; } - getValidRuleName(path: string): string { - return path.endsWith('.js') ? path : `${path}.js`; + getValidRuleName(path: string, defaultExtension = '.js'): string { + return path.endsWith('.js') || path.endsWith('.ts') ? path : `${path}${defaultExtension}`; } async changeState(path: string, state: boolean): Promise { @@ -132,11 +141,15 @@ export default class RulesStore { async copyRule(path: string) { const copiedRule = await this.load(path); + const extension = copiedRule.name.endsWith('.ts') ? '.ts' : '.js'; copiedRule.name = generateNextId( - this.rules.map((rule) => rule.virtualPath.replace(/\.js$/, '')), - copiedRule.name.replace(/\.js$/, ''), + this.rules.map((rule) => rule.virtualPath.replace(/\.(js|ts)$/, '')), + copiedRule.name.replace(/\.(js|ts)$/, ''), ); - const copiedRuleName = await this.save({ ...copiedRule, initName: this.getValidRuleName(copiedRule.name) }); + const copiedRuleName = await this.save({ + ...copiedRule, + initName: this.getValidRuleName(copiedRule.name + extension), + }); await new Promise((resolve) => setTimeout(resolve, 2000)); await this.changeState(copiedRuleName, false); } @@ -195,6 +208,45 @@ export default class RulesStore { mqttClient.unsubscribe('/wbrules/log/+'); } + // The controller re-checks .ts rules with the same tsgo it runs them + // with (Editor.Check RPC) - the authoritative verdict, pulled on file + // open and after each save, shown next to the editor's own live check. + async checkTsFile(fileName: string, contentOverride?: string) { + // the verdict describes the saved file; capture the matching editor + // content so stale diagnostics are suppressed once the user edits + // (callers pass the exact content they saved when they have it) + const checkedContent = contentOverride ?? this.rule?.content ?? ''; + this._tsCheckToken += 1; + const token = this._tsCheckToken; + try { + // the controller answers 'pending' while its background check for + // a freshly loaded/saved file is still running - poll briefly + for (let attempt = 0; attempt < 15; attempt++) { + const result = await editorProxy.Check({ path: fileName }); + if (token !== this._tsCheckToken) return; // superseded by a newer check + if (result?.status !== 'pending') { + runInAction(() => { + this.tsCheckDiags = result?.status === 'ready' ? result.diags : []; + this.tsCheckedContent = checkedContent; + }); + return; + } + await new Promise((resolve) => setTimeout(resolve, 700)); + } + if (token !== this._tsCheckToken) return; // a newer check owns the state + runInAction(() => this.clearTsCheck()); + } catch { + if (token !== this._tsCheckToken) return; + runInAction(() => this.clearTsCheck()); + } + } + + clearTsCheck() { + this._tsCheckToken += 1; // cancels any in-flight poll loop + this.tsCheckDiags = []; + this.tsCheckedContent = null; + } + clearLogs() { this.logs = []; } diff --git a/frontend/src/stores/rules/types.ts b/frontend/src/stores/rules/types.ts index 22c89fbc6..49c04b616 100644 --- a/frontend/src/stores/rules/types.ts +++ b/frontend/src/stores/rules/types.ts @@ -45,3 +45,30 @@ export interface RuleLog { payload: string; time: number; } + +// one diagnostic from the editor's local TypeScript language service, +// used to de-duplicate the controller's findings +export interface LocalTsDiag { + line: number; + message: string; +} + +export type TsCheckStatus = 'ready' | 'pending' | 'not-ts' | 'unsupported'; + +// reply of the Editor.Check RPC: the controller-side tsgo verdict; +// diags are valid only for 'ready', poll again on 'pending' +export interface TsCheckResult { + status: TsCheckStatus; + diags: TsCheckDiag[]; +} + +// one diagnostic from the controller-side tsgo check (Editor.Check RPC) +export interface TsCheckDiag { + // set only for diagnostics from another file (import/reference); + // such entries must not be anchored in the checked file + file?: string; + line: number; + column: number; + severity: 'error' | 'warning'; + message: string; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index c19a88931..55e06910e 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -25,6 +25,9 @@ "types": ["vitest/globals"] }, "include": ["app/scripts", "src"], - "exclude": ["vite.config.ts"], + "exclude": [ + "vite.config.ts", + "src/stores/rules/autocomplete/wb-rules.d.ts" + ], "references": [{ "path": "./tsconfig.node.json" }] }