diff --git a/.gitignore b/.gitignore index 553fff4..8387eb1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,49 @@ +``` +# Dependencies node_modules/ + +# Build artifacts dist/ -src-tauri/target/ -src-tauri/gen/ -src-tauri/.cargo/ +build/ +target/ + +# Logs *.log -# local reference copies of third-party sources — not ours to ship -Contents/ -numi-master/ +# Environment +.env +.env.local +*.env.* -# one-off debugging artifacts -scripts/smoke-* -test_perf.ts +# Editors +.vscode/ +.idea/ -# AI coding agent scratch/notes directories -.jules/ +# Python cache (if any Python files exist in project) +__pycache__/ +*.pyc +*.pyo +*.pyd -# updater signing key — private, never publish (see .tauri-signing/README.txt) -.tauri-signing/ +# Coverage reports +coverage/ +htmlcov/ +.coverage + +# Temporary files +*.tmp +*.swp +*.swo + +# OS specific +.DS_Store +Thumbs.db + +# Testing +.nyc_output/ +coverage/ +junit.xml + +# Prettier and ESLint cache +.node_modules/.cache/ +``` \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..17d0291 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "bracketSpacing": true, + "arrowParens": "always" +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..b985cd3 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,31 @@ +import eslint from "@eslint/js"; +import tseslint from "typescript-eslint"; +import eslintConfigPrettier from "eslint-config-prettier"; + +export default tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + eslintConfigPrettier, + { + languageOptions: { + parserOptions: { + project: "./tsconfig.json", + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/consistent-type-imports": "error", + "no-console": "off", + curly: ["error", "all"], + eqeqeq: ["error", "always"], + "prefer-const": "warn", + }, + }, + { + ignores: ["dist/", "node_modules/", "*.d.ts"], + } +); diff --git a/package-lock.json b/package-lock.json index b12d8ff..a62cc13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,9 +26,16 @@ "quickjs-emscripten-core": "^0.32.0" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@tauri-apps/cli": "^2.5.0", "@types/node": "^25.9.2", + "@typescript-eslint/eslint-plugin": "^8.67.0", + "@typescript-eslint/parser": "^8.67.0", + "eslint": "^10.8.1", + "eslint-config-prettier": "^10.1.8", + "prettier": "^3.9.6", "typescript": "^5.7.0", + "typescript-eslint": "^8.67.0", "vite": "^6.0.0", "vitest": "^3.0.0" } @@ -534,6 +541,187 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jitl/quickjs-ffi-types": { "version": "0.32.0", "resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz", @@ -678,9 +866,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -695,9 +880,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -712,9 +894,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -729,9 +908,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -746,9 +922,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -763,9 +936,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -780,9 +950,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -797,9 +964,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -814,9 +978,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -831,9 +992,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -848,9 +1006,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -865,9 +1020,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -882,9 +1034,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1074,9 +1223,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1094,9 +1240,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1114,9 +1257,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1134,9 +1274,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1154,9 +1291,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1289,6 +1423,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1296,6 +1437,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.9.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz", @@ -1306,6 +1454,239 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.67.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@vitest/expect": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", @@ -1421,6 +1802,46 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1431,6 +1852,29 @@ "node": ">=12" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1474,6 +1918,21 @@ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1508,6 +1967,13 @@ "node": ">=6" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1557,6 +2023,216 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.8.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", + "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1567,6 +2243,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -1577,6 +2263,27 @@ "node": ">=12.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1595,6 +2302,57 @@ } } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1610,6 +2368,69 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -1617,6 +2438,67 @@ "dev": true, "license": "MIT" }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -1634,6 +2516,22 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1660,6 +2558,83 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1726,6 +2701,42 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/quickjs-emscripten-core": { "version": "0.32.0", "resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz", @@ -1780,6 +2791,42 @@ "fsevents": "~2.3.2" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1891,6 +2938,32 @@ "node": ">=14.0.0" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1905,6 +2978,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", + "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici-types": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", @@ -1912,6 +3009,16 @@ "dev": true, "license": "MIT" }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", @@ -2089,6 +3196,22 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2105,6 +3228,29 @@ "engines": { "node": ">=8" } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index f12d50c..60aa043 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,11 @@ "test": "vitest run", "test:watch": "vitest", "icons": "node scripts/gen-icon.ts", - "tauri": "tauri" + "tauri": "tauri", + "lint": "eslint src --ext .ts,.tsx", + "lint:fix": "eslint src --ext .ts,.tsx --fix", + "format": "prettier --write \"src/**/*.{ts,tsx}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx}\"" }, "dependencies": { "@codemirror/autocomplete": "^6.18.0", @@ -32,9 +36,16 @@ "quickjs-emscripten-core": "^0.32.0" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@tauri-apps/cli": "^2.5.0", "@types/node": "^25.9.2", + "@typescript-eslint/eslint-plugin": "^8.67.0", + "@typescript-eslint/parser": "^8.67.0", + "eslint": "^10.8.1", + "eslint-config-prettier": "^10.1.8", + "prettier": "^3.9.6", "typescript": "^5.7.0", + "typescript-eslint": "^8.67.0", "vite": "^6.0.0", "vitest": "^3.0.0" } diff --git a/src/engine/datetime.ts b/src/engine/datetime.ts index 078d4f4..52a92ee 100644 --- a/src/engine/datetime.ts +++ b/src/engine/datetime.ts @@ -1,13 +1,13 @@ // Date helpers and a city → IANA timezone map for "time in Tokyo". -import { DateOrder } from "./types"; +import type { DateOrder } from "./types"; /** Does the OS locale write the day or the month first? (31.12 vs 12/31) */ export function detectDateOrder(locale?: string): DateOrder { const parts = new Intl.DateTimeFormat(locale, { day: "numeric", month: "numeric", year: "numeric" }) .formatToParts(new Date(2000, 11, 31)); for (const p of parts) { - if (p.type === "day") return "dmy"; - if (p.type === "month") return "mdy"; + if (p.type === "day") {return "dmy";} + if (p.type === "month") {return "mdy";} } return "dmy"; } @@ -79,10 +79,10 @@ const Z: Record = { /** Resolve a sequence of words to an IANA timezone. Tries longest run first. */ export function resolveZone(words: string[]): string | null { const lower = words.map((w) => w.toLowerCase().replace(/[-.]/g, " ").trim()); - if (lower[0] === "local") return Intl.DateTimeFormat().resolvedOptions().timeZone; + if (lower[0] === "local") {return Intl.DateTimeFormat().resolvedOptions().timeZone;} for (let len = Math.min(lower.length, 3); len >= 1; len--) { const key = lower.slice(0, len).join(" "); - if (Z[key]) return Z[key]; + if (Z[key]) {return Z[key];} } // direct IANA id like Europe/Berlin typed by hand const joined = words.join("/"); diff --git a/src/engine/engine.test.ts b/src/engine/engine.test.ts index 29f15e1..0197e58 100644 --- a/src/engine/engine.test.ts +++ b/src/engine/engine.test.ts @@ -366,13 +366,13 @@ describe("cross-sheet references", () => { function resolver(sheet: string, key: string): XRefResolution { const s = sheet.toLowerCase(); if (s === "budget") { - if (key === "total") return { ok: true, value: qty(150) }; - if (key === "rent") return { ok: true, value: qty(500, eng.reg.unitsById.get("meter")!) }; - if (key === "last") return { ok: true, value: qty(7) }; + if (key === "total") {return { ok: true, value: qty(150) };} + if (key === "rent") {return { ok: true, value: qty(500, eng.reg.unitsById.get("meter")) };} + if (key === "last") {return { ok: true, value: qty(7) };} return { ok: false, reason: `no variable "${key}" in "${sheet}"` }; } if (s === "trip to lisbon") { - if (key === "food") return { ok: true, value: qty(25) }; + if (key === "food") {return { ok: true, value: qty(25) };} return { ok: false, reason: `no variable "${key}" in "${sheet}"` }; } return { ok: false, reason: `sheet "${sheet}" not found` }; diff --git a/src/engine/evaluator.test.ts b/src/engine/evaluator.test.ts index 53fbfe6..a838eb1 100644 --- a/src/engine/evaluator.test.ts +++ b/src/engine/evaluator.test.ts @@ -6,7 +6,7 @@ import { buildRegistry } from "./registry"; const reg = buildRegistry(); const getUnit = (id: string) => { const u = reg.unitsById.get(id); - if (!u) throw new Error(`unit ${id} not found`); + if (!u) {throw new Error(`unit ${id} not found`);} return u; }; diff --git a/src/engine/evaluator.ts b/src/engine/evaluator.ts index 848ed72..e2cca60 100644 --- a/src/engine/evaluator.ts +++ b/src/engine/evaluator.ts @@ -1,8 +1,9 @@ // Walks the AST with the document context (variables, line results for // sum/avg/prev) and produces unit-aware values. -import { Decimal, EvalError, Quantity, Unit, Value, XRefError, qty, pct } from "./types"; -import { Node, ConvTarget } from "./parser"; -import { Registry } from "./registry"; +import type { Quantity, Unit, Value} from "./types"; +import { Decimal, EvalError, XRefError, qty, pct } from "./types"; +import type { Node, ConvTarget } from "./parser"; +import type { Registry } from "./registry"; import { resolveZone, startOfToday, addToDate, isCalendarUnit } from "./datetime"; export interface LineCtx { @@ -75,15 +76,15 @@ export function evaluate(node: Node, ctx: EvalCtx): Value { // ---------- helpers function asScalar(v: Value): Decimal { - if (v.kind === "quantity") return v.value; - if (v.kind === "percent") return v.value.div(100); + if (v.kind === "quantity") {return v.value;} + if (v.kind === "percent") {return v.value.div(100);} throw new EvalError("expected a number"); } export function attachUnit(v: Value, unit: Unit): Value { if (v.kind === "quantity") { - if (v.unit && v.unit.dimension === unit.dimension) return convertQ(v, unit); - if (v.unit) throw new EvalError("unit mismatch"); + if (v.unit && v.unit.dimension === unit.dimension) {return convertQ(v, unit);} + if (v.unit) {throw new EvalError("unit mismatch");} return { ...v, unit, repr: "decimal" }; } throw new EvalError("cannot attach unit"); @@ -91,29 +92,29 @@ export function attachUnit(v: Value, unit: Unit): Value { /** value of q in dimension-base units */ export function toBase(q: Quantity): Decimal { - if (!q.unit) return q.value; + if (!q.unit) {return q.value;} if (q.unit.reciprocal) { - if (q.value.isZero()) throw new EvalError("division by zero"); + if (q.value.isZero()) {throw new EvalError("division by zero");} return q.unit.ratio.div(q.value); // mpg -> L/100km } let b = q.value.mul(q.unit.ratio); - if (q.unit.offset) b = b.add(q.unit.offset); + if (q.unit.offset) {b = b.add(q.unit.offset);} return b; } export function fromBase(base: Decimal, unit: Unit): Decimal { if (unit.reciprocal) { - if (base.isZero()) throw new EvalError("division by zero"); + if (base.isZero()) {throw new EvalError("division by zero");} return unit.ratio.div(base); } let v = base; - if (unit.offset) v = v.sub(unit.offset); + if (unit.offset) {v = v.sub(unit.offset);} return v.div(unit.ratio); } export function convertQ(q: Quantity, unit: Unit): Quantity { - if (!q.unit) return { ...q, unit }; - if (q.unit.dimension !== unit.dimension) throw new EvalError("dimension mismatch"); + if (!q.unit) {return { ...q, unit };} + if (q.unit.dimension !== unit.dimension) {throw new EvalError("dimension mismatch");} return qty(fromBase(toBase(q), unit), unit); } @@ -123,7 +124,7 @@ function addQ(a: Quantity, b: Quantity): Quantity { function numericAdd(a: Quantity, b: Quantity, sign: 1 | -1): Quantity { if (a.unit && b.unit) { - if (a.unit.dimension !== b.unit.dimension) throw new EvalError("dimension mismatch"); + if (a.unit.dimension !== b.unit.dimension) {throw new EvalError("dimension mismatch");} if (a.unit.offset || b.unit.offset) { // temperatures: operate in the left unit's scale const bInA = convertQ(b, a.unit); @@ -151,11 +152,11 @@ function evalPercentArith(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", switch (op) { case "plus": return qty(q.value.mul(new Decimal(1).add(p.div(100))), q.unit); case "minus": - if (l.kind === "percent") throw new EvalError("percent minus number"); + if (l.kind === "percent") {throw new EvalError("percent minus number");} return qty(q.value.mul(new Decimal(1).sub(p.div(100))), q.unit); case "mul": return qty(q.value.mul(p).div(100), q.unit); case "div": - if (l.kind === "percent") throw new EvalError("percent div number"); + if (l.kind === "percent") {throw new EvalError("percent div number");} return qty(q.value.div(p.div(100)), q.unit); case "pow": // which side the percent is on matters: 50% ^ 2 = 0.25, 2 ^ 50% = √2 @@ -176,28 +177,28 @@ function evalQuantityArith(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", const db = b.unit.dimension; const baseProduct = toBase(a).mul(toBase(b)); let targetId: string | null = null; - if (da === "length" && db === "length") targetId = `sq:${a.unit.id}`; - else if (da === "length" && db === "area") targetId = `cb:${a.unit.id}`; - else if (da === "area" && db === "length") targetId = `cb:${b.unit.id}`; + if (da === "length" && db === "length") {targetId = `sq:${a.unit.id}`;} + else if (da === "length" && db === "area") {targetId = `cb:${a.unit.id}`;} + else if (da === "area" && db === "length") {targetId = `cb:${b.unit.id}`;} if (targetId) { const u = ctx.reg.unitsById.get(targetId); - if (u) return qty(baseProduct.div(u.ratio), u); + if (u) {return qty(baseProduct.div(u.ratio), u);} } // same dimension: "2 kg * 500 g" scales in the left unit, not 2*500 - if (da === db) return qty(a.value.mul(convertQ(b, a.unit).value), a.unit); + if (da === db) {return qty(a.value.mul(convertQ(b, a.unit).value), a.unit);} return qty(a.value.mul(b.value), a.unit); } return qty(a.value.mul(b.value), a.unit ?? b.unit ?? null); } case "div": { - if (b.value.isZero()) throw new EvalError("division by zero"); + if (b.value.isZero()) {throw new EvalError("division by zero");} if (a.unit && b.unit && a.unit.dimension === b.unit.dimension) { return qty(toBase(a).div(toBase(b))); } return qty(a.value.div(b.value), a.unit ?? null); } case "mod": { - if (b.value.isZero()) throw new EvalError("mod zero"); + if (b.value.isZero()) {throw new EvalError("mod zero");} return qty(a.value.mod(b.value), a.unit ?? null); } case "pow": { @@ -205,7 +206,7 @@ function evalQuantityArith(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", if (a.unit?.dimension === "length" && !b.unit) { const id = b.value.eq(2) ? `sq:${a.unit.id}` : b.value.eq(3) ? `cb:${a.unit.id}` : null; const u = id ? ctx.reg.unitsById.get(id) : null; - if (u) return qty(result, u); + if (u) {return qty(result, u);} } return qty(result, b.unit ? null : a.unit && b.value.eq(1) ? a.unit : null); } @@ -225,11 +226,11 @@ function evalBin(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", l: Value, } function evalBit(op: string, l: Value, r: Value): Value { - if (l.kind !== "quantity" || r.kind !== "quantity") throw new EvalError("bitwise needs integers"); - if (!l.value.isFinite() || !r.value.isFinite()) throw new EvalError("bitwise needs finite integers"); + if (l.kind !== "quantity" || r.kind !== "quantity") {throw new EvalError("bitwise needs integers");} + if (!l.value.isFinite() || !r.value.isFinite()) {throw new EvalError("bitwise needs finite integers");} const a = BigInt(l.value.toDecimalPlaces(0).toFixed(0)); const b = BigInt(r.value.toDecimalPlaces(0).toFixed(0)); - if ((op === "shl" || op === "shr") && (b < 0n || b > 256n)) throw new EvalError("bad shift"); + if ((op === "shl" || op === "shr") && (b < 0n || b > 256n)) {throw new EvalError("bad shift");} let out: bigint; switch (op) { case "band": out = a & b; break; @@ -246,7 +247,7 @@ function evalBit(op: string, l: Value, r: Value): Value { function evalPctOp(op: string, l: Value, r: Value): Value { const rq = r.kind === "quantity" ? r : null; const rv = r.kind === "percent" ? r.value.div(100) : rq ? rq.value : null; - if (rv === null) throw new EvalError("bad operand"); + if (rv === null) {throw new EvalError("bad operand");} const unit = rq?.unit ?? null; if (op === "of" || op === "off" || op === "on") { @@ -256,12 +257,12 @@ function evalPctOp(op: string, l: Value, r: Value): Value { : op === "off" ? new Decimal(1).sub(p.div(100)) : new Decimal(1).add(p.div(100)); // a percent of a percent stays a percent: 50% of 50% = 25% - if (r.kind === "percent") return pct(r.value.mul(factor)); + if (r.kind === "percent") {return pct(r.value.mul(factor));} return qty(rv.mul(factor), unit); } if (op === "as_pct_of" || op === "as_pct_off" || op === "as_pct_on") { const lv = l.kind === "quantity" ? l.value : asScalar(l); - if (rv.isZero()) throw new EvalError("division by zero"); + if (rv.isZero()) {throw new EvalError("division by zero");} switch (op) { case "as_pct_of": return pct(lv.div(rv).mul(100)); case "as_pct_off": return pct(new Decimal(1).sub(lv.div(rv)).mul(100)); @@ -274,7 +275,7 @@ function evalPctOp(op: string, l: Value, r: Value): Value { case "of_what_is": return qty(rv.div(p.div(100)), unit); case "off_what_is": { const d = new Decimal(1).sub(p.div(100)); - if (d.isZero()) throw new EvalError("division by zero"); + if (d.isZero()) {throw new EvalError("division by zero");} return qty(rv.div(d), unit); } case "on_what_is": return qty(rv.div(new Decimal(1).add(p.div(100))), unit); @@ -285,9 +286,9 @@ function evalPctOp(op: string, l: Value, r: Value): Value { function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value { switch (target.type) { case "unit": { - if (v.kind === "percent") return pct(v.value); // "x in %" — keep - if (v.kind !== "quantity") throw new EvalError("cannot convert date to unit"); - if (!v.unit) return qty(v.value, target.unit); + if (v.kind === "percent") {return pct(v.value);} // "x in %" — keep + if (v.kind !== "quantity") {throw new EvalError("cannot convert date to unit");} + if (!v.unit) {return qty(v.value, target.unit);} return convertQ(v, target.unit); } case "currency": { @@ -295,36 +296,36 @@ function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value { if (target.onDate) { const dateRates = ctx.historicalRates?.get(target.onDate); const rate = dateRates?.get(target.code); - if (rate === undefined) throw new EvalError(`no historical rate for ${target.code} on ${target.onDate}`); + if (rate === undefined) {throw new EvalError(`no historical rate for ${target.code} on ${target.onDate}`);} unit = ctx.reg.makeCurrencyUnitFromRate(target.code, new Decimal(rate)); } else { unit = ctx.reg.makeCurrencyUnit(target.code); - if (!unit) throw new EvalError(`no rate for ${target.code}`); + if (!unit) {throw new EvalError(`no rate for ${target.code}`);} } - if (v.kind !== "quantity") throw new EvalError("cannot convert to currency"); - if (!v.unit) return qty(v.value, unit); + if (v.kind !== "quantity") {throw new EvalError("cannot convert to currency");} + if (!v.unit) {return qty(v.value, unit);} return convertQ(v, unit); } case "repr": { - if (v.kind !== "quantity") throw new EvalError("bad repr conversion"); + if (v.kind !== "quantity") {throw new EvalError("bad repr conversion");} return { ...v, repr: target.repr }; } case "scale": { - if (v.kind !== "quantity") throw new EvalError("bad scale conversion"); + if (v.kind !== "quantity") {throw new EvalError("bad scale conversion");} return qty(v.value.div(target.mult), v.unit); // formatter shows plain number } case "tz": { const zone = resolveZone(target.words); - if (!zone) throw new EvalError("unknown timezone"); - if (v.kind !== "date") throw new EvalError("timezone needs a time"); + if (!zone) {throw new EvalError("unknown timezone");} + if (v.kind !== "date") {throw new EvalError("timezone needs a time");} return { ...v, timeZone: zone }; } case "unix": { - if (v.kind === "date") return qty(new Decimal(v.ms).div(1000).floor(), null, "plain"); + if (v.kind === "date") {return qty(new Decimal(v.ms).div(1000).floor(), null, "plain");} throw new EvalError("unix needs a date"); } case "todate": { - if (v.kind !== "quantity" || v.unit) throw new EvalError("date needs a plain number"); + if (v.kind !== "quantity" || v.unit) {throw new EvalError("date needs a plain number");} // heuristics: > 1e12 means milliseconds, else seconds const ms = v.value.gt(1e12) ? v.value.toNumber() : v.value.mul(1000).toNumber(); return { kind: "date", ms, hasTime: true }; @@ -345,7 +346,7 @@ function evalDateWord(word: string): Value { function evalDateArith(op: string, l: Value, r: Value): Value { if (l.kind === "date" && r.kind === "date") { - if (op !== "minus") throw new EvalError("dates only subtract"); + if (op !== "minus") {throw new EvalError("dates only subtract");} // midnight-to-midnight differences count calendar days (robust across DST) const days = !l.hasTime && !r.hasTime ? new Decimal(Math.round((l.ms - r.ms) / 86400_000)) @@ -357,8 +358,8 @@ function evalDateArith(op: string, l: Value, r: Value): Value { if (span.kind !== "quantity" || span.unit?.dimension !== "time") { throw new EvalError("date arithmetic needs a time span"); } - if (op !== "plus" && op !== "minus") throw new EvalError("bad date op"); - if (op === "minus" && l.kind !== "date") throw new EvalError("cannot subtract date from number"); + if (op !== "plus" && op !== "minus") {throw new EvalError("bad date op");} + if (op === "minus" && l.kind !== "date") {throw new EvalError("cannot subtract date from number");} const amount = span.value.toNumber() * (op === "minus" ? -1 : 1); let ms: number; let hasTime = date.hasTime; @@ -368,7 +369,7 @@ function evalDateArith(op: string, l: Value, r: Value): Value { const seconds = span.value.mul(span.unit.ratio); ms = date.ms + seconds.mul(1000).toNumber() * (op === "minus" ? -1 : 1); // "today + 2 hours" must show the time part to be visible at all - if (!seconds.mod(86400).isZero()) hasTime = true; + if (!seconds.mod(86400).isZero()) {hasTime = true;} } return { ...date, ms, hasTime }; } @@ -390,7 +391,7 @@ function evalGoalSeek(lhsNode: Node, rhsNode: Node, ctx: EvalCtx): Value { const slope = f1.minus(f0); if (!slope.isZero()) { const root = f0.neg().div(slope); - if (evalF(root).abs().lt(new Decimal("1e-9"))) return qty(root); + if (evalF(root).abs().lt(new Decimal("1e-9"))) {return qty(root);} } // Bisection fallback for nonlinear cases @@ -398,10 +399,10 @@ function evalGoalSeek(lhsNode: Node, rhsNode: Node, ctx: EvalCtx): Value { let hi = new Decimal(1e9); let flo = evalF(lo); let fhi = evalF(hi); - if (flo.mul(fhi).gt(0)) throw new EvalError("no solution"); + if (flo.mul(fhi).gt(0)) {throw new EvalError("no solution");} for (let i = 0; i < 100; i++) { const mid = lo.plus(hi).div(2); - if (hi.minus(lo).abs().lt(new Decimal("1e-10"))) return qty(mid); + if (hi.minus(lo).abs().lt(new Decimal("1e-10"))) {return qty(mid);} const fm = evalF(mid); if (flo.mul(fm).lte(0)) { hi = mid; fhi = fm; } else { lo = mid; flo = fm; } @@ -414,7 +415,7 @@ const AGG_FUNCS: Record Value> = { chart: (block) => { const refDim = block[0].unit?.dimension ?? null; const compatible = block.filter((q) => (q.unit?.dimension ?? null) === refDim); - if (compatible.length < 2) throw new EvalError("need at least 2 values for chart"); + if (compatible.length < 2) {throw new EvalError("need at least 2 values for chart");} return { kind: "chart", points: compatible.map((q) => toBase(q)), @@ -462,7 +463,7 @@ function getPrevResult(ctx: EvalCtx): Value { const { lineValues, index } = ctx.line; for (let i = index - 1; i >= 0; i--) { const v = lineValues[i]; - if (v) return v; + if (v) {return v;} } throw new EvalError("no previous result"); } @@ -472,33 +473,33 @@ function getAggBlock(ctx: EvalCtx): Quantity[] { const block: Quantity[] = []; for (let i = index - 1; i >= 0; i--) { if (lineKinds[i] === "empty" || lineKinds[i] === "header") { - if (block.length > 0) break; - if (lineKinds[i] === "header") break; + if (block.length > 0) {break;} + if (lineKinds[i] === "header") {break;} continue; // skip empties directly above until the block starts } const v = lineValues[i]; - if (v?.kind === "quantity") block.push(v); + if (v?.kind === "quantity") {block.push(v);} } - if (block.length === 0) throw new EvalError("nothing to aggregate"); + if (block.length === 0) {throw new EvalError("nothing to aggregate");} block.reverse(); return block; } function evalAgg(name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart", ctx: EvalCtx): Value { - if (name === "prev") return getPrevResult(ctx); + if (name === "prev") {return getPrevResult(ctx);} const block = getAggBlock(ctx); const func = AGG_FUNCS[name]; - if (!func) throw new EvalError(`unknown aggregate function ${name}`); + if (!func) {throw new EvalError(`unknown aggregate function ${name}`);} return func(block); } function evalCall(name: string, args: Value[], ctx: EvalCtx): Value { const custom = ctx.reg.customFuncs.get(name); - if (custom) return custom(args); - if (name === "random") return evalRandom(args, ctx); // random() is valid with no args + if (custom) {return custom(args);} + if (name === "random") {return evalRandom(args, ctx);} // random() is valid with no args const x = args[0]; - if (!x) throw new EvalError(`${name} needs an argument`); + if (!x) {throw new EvalError(`${name} needs an argument`);} // trig works on angles: degrees convert to radians const angleArg = (): Decimal => { @@ -513,7 +514,7 @@ function evalCall(name: string, args: Value[], ctx: EvalCtx): Value { case "sqrt": { if (x.kind === "quantity" && x.unit?.dimension === "area" && x.unit.id.startsWith("sq:")) { const base = ctx.reg.unitsById.get(x.unit.id.slice(3)); - if (base) return qty(x.value.sqrt(), base); + if (base) {return qty(x.value.sqrt(), base);} } return qty(n().sqrt()); } @@ -538,11 +539,11 @@ function evalCall(name: string, args: Value[], ctx: EvalCtx): Value { case "floor": return x.kind === "quantity" ? { ...x, value: x.value.floor() } : qty(n().floor()); case "fact": case "factorial": return qty(factorial(n())); case "until": { - if (x.kind !== "date") throw new EvalError("until needs a date"); + if (x.kind !== "date") {throw new EvalError("until needs a date");} return evalDateArith("minus", x, { kind: "date", ms: startOfToday(), hasTime: false }); } case "since": { - if (x.kind !== "date") throw new EvalError("since needs a date"); + if (x.kind !== "date") {throw new EvalError("since needs a date");} return evalDateArith("minus", { kind: "date", ms: startOfToday(), hasTime: false }, x); } default: throw new EvalError(`unknown function ${name}`); @@ -552,9 +553,9 @@ function evalCall(name: string, args: Value[], ctx: EvalCtx): Value { function evalRandom(args: Value[], ctx: EvalCtx): Value { const seed = strHash(ctx.line.lineText) ^ (ctx.line.index * 0x9e3779b9); const rand = mulberry32(seed); - if (args.length === 0) return qty(new Decimal(rand)); + if (args.length === 0) {return qty(new Decimal(rand));} const toNum = (v: Value) => v.kind === "quantity" ? v.value.toNumber() : 0; - const [lo, hi] = args.length === 1 ? [0, toNum(args[0]!)] : [toNum(args[0]!), toNum(args[1]!)]; + const [lo, hi] = args.length === 1 ? [0, toNum(args[0])] : [toNum(args[0]), toNum(args[1])]; const isInt = Number.isInteger(lo) && Number.isInteger(hi); const raw = isInt ? Math.floor(rand * (hi - lo + 1)) + lo : rand * (hi - lo) + lo; return qty(new Decimal(raw)); @@ -576,9 +577,9 @@ function mulberry32(seed: number): number { } function factorial(n: Decimal): Decimal { - if (!n.isInteger() || n.isNegative() || n.gt(300)) throw new EvalError("bad factorial"); + if (!n.isInteger() || n.isNegative() || n.gt(300)) {throw new EvalError("bad factorial");} let acc = new Decimal(1); - for (let i = 2; i <= n.toNumber(); i++) acc = acc.mul(i); + for (let i = 2; i <= n.toNumber(); i++) {acc = acc.mul(i);} return acc; } @@ -594,7 +595,7 @@ function evaluateConst(node: { k: "const", name: "pi" | "e" | "half" | "onehalf" function evaluateRef(node: Node, ctx: EvalCtx): Value { if (node.k === "var") { const v = ctx.vars.get(node.name); - if (!v) throw new EvalError(`unknown variable ${node.name}`); + if (!v) {throw new EvalError(`unknown variable ${node.name}`);} return v; } if (node.k === "xref") { @@ -606,7 +607,7 @@ function evaluateRef(node: Node, ctx: EvalCtx): Value { } if (node.k === "unknown") { const xv = ctx.vars.get("__x__"); - if (!xv) throw new EvalError("? used outside goal seek"); + if (!xv) {throw new EvalError("? used outside goal seek");} return xv; } throw new EvalError("invalid ref node"); @@ -615,14 +616,14 @@ function evaluateRef(node: Node, ctx: EvalCtx): Value { function evaluateMath(node: Node, ctx: EvalCtx): Value { if (node.k === "neg") { const v = evaluate(node.x, ctx); - if (v.kind === "quantity") return { ...v, value: v.value.neg() }; - if (v.kind === "percent") return pct(v.value.neg()); + if (v.kind === "quantity") {return { ...v, value: v.value.neg() };} + if (v.kind === "percent") {return pct(v.value.neg());} throw new EvalError("cannot negate a date"); } if (node.k === "pct") { const v = evaluate(node.x, ctx); - if (v.kind === "quantity" && !v.unit) return pct(v.value); - if (v.kind === "percent") return v; + if (v.kind === "quantity" && !v.unit) {return pct(v.value);} + if (v.kind === "percent") {return v;} throw new EvalError("% needs a plain number"); } if (node.k === "fact") { @@ -631,8 +632,8 @@ function evaluateMath(node: Node, ctx: EvalCtx): Value { } if (node.k === "scale") { const v = evaluate(node.x, ctx); - if (v.kind === "quantity") return { ...v, value: v.value.mul(node.mult) }; - if (v.kind === "percent") return pct(v.value.mul(node.mult)); + if (v.kind === "quantity") {return { ...v, value: v.value.mul(node.mult) };} + if (v.kind === "percent") {return pct(v.value.mul(node.mult));} throw new EvalError("cannot scale a date"); } throw new EvalError("invalid math node"); @@ -645,7 +646,7 @@ function evaluateUnitMod(node: Node, ctx: EvalCtx): Value { } if (node.k === "curr") { const unit = ctx.reg.makeCurrencyUnit(node.code); - if (!unit) throw new EvalError(`no rate for ${node.code}`); + if (!unit) {throw new EvalError(`no rate for ${node.code}`);} const v = evaluate(node.x, ctx); return attachUnit(v, unit); } @@ -672,6 +673,6 @@ function evaluateSeq(node: { k: "seq", items: Node[] }, ctx: EvalCtx): Value { acc = v; } } - if (!acc) throw new EvalError("empty expression"); + if (!acc) {throw new EvalError("empty expression");} return acc; } diff --git a/src/engine/formatter.test.ts b/src/engine/formatter.test.ts index 612a120..ec3b6da 100644 --- a/src/engine/formatter.test.ts +++ b/src/engine/formatter.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { formatNumber, formatValue } from "./formatter"; -import { Decimal, defaultSettings, qty, pct, DateVal, ChartValue, EngineSettings } from "./types"; +import type { DateVal, ChartValue, EngineSettings } from "./types"; +import { Decimal, defaultSettings, qty, pct } from "./types"; describe("formatNumber", () => { it("formats standard numbers", () => { diff --git a/src/engine/formatter.ts b/src/engine/formatter.ts index b6a4cf0..6faab8c 100644 --- a/src/engine/formatter.ts +++ b/src/engine/formatter.ts @@ -1,6 +1,7 @@ // Turns values into display strings: unit/currency formats plus // precision and grouping settings. -import { Decimal, DateVal, EngineSettings, Value } from "./types"; +import type { DateVal, EngineSettings, Value } from "./types"; +import { Decimal } from "./types"; // Intl.DateTimeFormat construction is relatively expensive and every line with // a date rebuilds one on each render — cache by locale+options instead. @@ -28,10 +29,10 @@ function formatDateFixed(v: DateVal, fmt: "iso" | "dmy" | "mdy"): string { } function group(intPart: string, sep: string): string { - if (!sep) return intPart; + if (!sep) {return intPart;} const neg = intPart.startsWith("-"); const digits = neg ? intPart.slice(1) : intPart; - if (digits.length < 4) return intPart; + if (digits.length < 4) {return intPart;} const grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, sep); return (neg ? "-" : "") + grouped; } @@ -44,14 +45,14 @@ export function formatNumber(v: Decimal, s: EngineSettings): string { rounded = v.toSignificantDigits(Math.max(s.precision, 1)); } const str = rounded.toString(); - if (str.includes("e") || str.includes("E")) return str; + if (str.includes("e") || str.includes("E")) {return str;} const [int, frac] = str.split("."); const intG = group(int, s.groupSeparator); return frac !== undefined ? intG + s.decimalSeparator + frac : intG; } export function formatValue(v: Value, s: EngineSettings): string { - if (v.kind === "chart") return ""; + if (v.kind === "chart") {return "";} if (v.kind === "percent") { return formatNumber(v.value, s) + "%"; @@ -62,7 +63,7 @@ export function formatValue(v: Value, s: EngineSettings): string { if (v.timeOnly) { return dtf(undefined, { ...opts, hour: "2-digit", minute: "2-digit", hour12: false }).format(v.ms); } - if (s.dateFormat !== "system") return formatDateFixed(v, s.dateFormat); + if (s.dateFormat !== "system") {return formatDateFixed(v, s.dateFormat);} if (v.hasTime) { return dtf(undefined, { ...opts, hour: "2-digit", minute: "2-digit", hour12: false, @@ -96,7 +97,7 @@ export function formatValue(v: Value, s: EngineSettings): string { } const num = formatNumber(v.value, s); - if (!v.unit) return num; + if (!v.unit) {return num;} return withUnit(num, v.unit.format); } @@ -112,27 +113,27 @@ function withUnit(num: string, f: string): string { function formatFraction(v: Decimal): string { const neg = v.isNegative(); const x = v.abs().toNumber(); - if (!Number.isFinite(x)) return v.toString(); + if (!Number.isFinite(x)) {return v.toString();} let h1 = 1, h0 = 0, k1 = 0, k0 = 1, b = x; for (let i = 0; i < 40; i++) { const a = Math.floor(b); [h1, h0] = [a * h1 + h0, h1]; [k1, k0] = [a * k1 + k0, k1]; - if (k1 > 10000) break; - if (Math.abs(x - h1 / k1) < 1e-12) break; + if (k1 > 10000) {break;} + if (Math.abs(x - h1 / k1) < 1e-12) {break;} const frac = b - a; - if (frac < 1e-12) break; + if (frac < 1e-12) {break;} b = 1 / frac; } const sign = neg ? "-" : ""; - if (k1 === 1) return sign + String(h1); + if (k1 === 1) {return sign + String(h1);} const whole = Math.floor(h1 / k1); const rem = h1 - whole * k1; return whole > 0 ? `${sign}${whole} ${rem}/${k1}` : `${sign}${rem}/${k1}`; } function formatRoman(v: Decimal): string | null { - if (!v.isInteger() || v.lt(1) || v.gt(3999)) return null; + if (!v.isInteger() || v.lt(1) || v.gt(3999)) {return null;} let n = v.toNumber(); const table: Array<[number, string]> = [ [1000, "M"], [900, "CM"], [500, "D"], [400, "CD"], [100, "C"], [90, "XC"], diff --git a/src/engine/index.ts b/src/engine/index.ts index 2cc981e..0a56647 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -1,17 +1,21 @@ // Public facade: evaluates whole documents line by line and exposes the // extension API (numi.addUnit / addFunction / setVariable). -import { DateOrder, Decimal, EngineSettings, EvalError, Quantity, Unit, Value, XRefError, defaultSettings, qty } from "./types"; -import { Registry, buildRegistry, Completion } from "./registry"; -import { tokenize, Token } from "./tokenizer"; +import type { DateOrder, EngineSettings, Quantity, Unit, Value} from "./types"; +import { Decimal, EvalError, XRefError, defaultSettings, qty } from "./types"; +import type { Registry, Completion } from "./registry"; +import { buildRegistry } from "./registry"; +import type { Token } from "./tokenizer"; +import { tokenize } from "./tokenizer"; import { parseLine } from "./parser"; -import { evaluate, EvalCtx, XRefResolver, toBase, fromBase } from "./evaluator"; +import type { EvalCtx, XRefResolver} from "./evaluator"; +import { evaluate, toBase, fromBase } from "./evaluator"; import { formatValue } from "./formatter"; import { detectDateOrder } from "./datetime"; import snapshot from "./rates-snapshot.json"; import { CRYPTO } from "./extraunits"; const cryptoSnapshot: Record = {}; -for (const c of CRYPTO) cryptoSnapshot[c.code] = 1 / c.snapshotUsd; +for (const c of CRYPTO) {cryptoSnapshot[c.code] = 1 / c.snapshotUsd;} // "//" only starts a comment at line start or after whitespace, so URLs like // "see https://example.com" aren't cut off. @@ -74,7 +78,7 @@ export class SumEngine { setHistoricalRates(date: string, rates: Record): void { const m = new Map(); - for (const [k, v] of Object.entries(rates)) m.set(k.toUpperCase(), v); + for (const [k, v] of Object.entries(rates)) {m.set(k.toUpperCase(), v);} this.historicalRates.set(date, m); } @@ -84,7 +88,7 @@ export class SumEngine { updateSettings(patch: Partial): void { this.settings = { ...this.settings, ...patch }; - if (patch.dateFormat) this.dateOrder = resolveDateOrder(this.settings.dateFormat); + if (patch.dateFormat) {this.dateOrder = resolveDateOrder(this.settings.dateFormat);} } completions(): Completion[] { @@ -148,8 +152,8 @@ export class SumEngine { } value = null; } - if (value?.kind === "quantity" && !value.value.isFinite()) value = null; - if (value && parsed.assign) vars.set(parsed.assign, value); + if (value?.kind === "quantity" && !value.value.isFinite()) {value = null;} + if (value && parsed.assign) {vars.set(parsed.assign, value);} } lineValues.push(value); @@ -182,7 +186,7 @@ export class SumEngine { const qs = results .map((r) => r.value) .filter((v): v is Quantity => v !== null && v.kind === "quantity"); - if (qs.length < 2) return null; + if (qs.length < 2) {return null;} const anchor = qs.find((q) => q.unit) ?? qs[0]; let acc = new Decimal(0); let count = 0; @@ -195,7 +199,7 @@ export class SumEngine { count++; } } - if (count < 2) return null; + if (count < 2) {return null;} const value = anchor.unit ? fromBase(acc, anchor.unit) : acc; return qty(value, anchor.unit ?? null); } @@ -214,7 +218,7 @@ export class SumEngine { addUnit(spec: ExtensionUnitSpec): void { const base = this.reg.unitsById.get(spec.baseUnitId) ?? this.currencyBase(spec.baseUnitId); - if (!base) throw new Error(`numi.addUnit: unknown baseUnitId ${spec.baseUnitId}`); + if (!base) {throw new Error(`numi.addUnit: unknown baseUnitId ${spec.baseUnitId}`);} const unit: Unit = { id: spec.id, dimension: base.dimension, @@ -225,7 +229,7 @@ export class SumEngine { this.reg.unitsById.set(unit.id, unit); for (const p of spec.phrases.split(",")) { const phrase = p.trim(); - if (phrase) this.reg.addPhrase(phrase, { t: "unit", unit }, { caseSensitive: false }); + if (phrase) {this.reg.addPhrase(phrase, { t: "unit", unit }, { caseSensitive: false });} } this.reg.completions.push({ label: spec.id, type: "unit", detail: "extension" }); } @@ -238,7 +242,7 @@ export class SumEngine { }); for (const p of (spec.phrases || spec.id).split(",")) { const phrase = p.trim(); - if (phrase) this.reg.addPhrase(phrase, { t: "func", name }, { caseSensitive: false }); + if (phrase) {this.reg.addPhrase(phrase, { t: "func", name }, { caseSensitive: false });} } this.reg.completions.push({ label: name, type: "function", detail: "extension" }); } @@ -248,7 +252,7 @@ export class SumEngine { } private toValue(v: number | ExtensionValue): Value { - if (typeof v === "number") return qty(v); + if (typeof v === "number") {return qty(v);} const unit = v.unitId ? this.reg.unitsById.get(v.unitId) ?? this.currencyBase(v.unitId) ?? null : null; @@ -259,8 +263,8 @@ export class SumEngine { if (v.kind === "quantity") { return { double: v.value.toNumber(), ...(v.unit ? { unitId: v.unit.id } : {}) }; } - if (v.kind === "percent") return { double: v.value.div(100).toNumber() }; - if (v.kind === "date") return { double: v.ms }; + if (v.kind === "percent") {return { double: v.value.div(100).toNumber() };} + if (v.kind === "date") {return { double: v.ms };} return { double: 0 }; // chart — not representable as ExtensionValue } } diff --git a/src/engine/lexer.ts b/src/engine/lexer.ts index f65f79d..3876c0a 100644 --- a/src/engine/lexer.ts +++ b/src/engine/lexer.ts @@ -1,6 +1,7 @@ // Splits a line into raw lexemes (numbers, words, symbols). Shared by the // tokenizer and the phrase registry so both segment text the same way. -import { DateOrder, Decimal, NumeralRepr } from "./types"; +import type { DateOrder, NumeralRepr } from "./types"; +import { Decimal } from "./types"; export interface Lex { type: "num" | "word" | "sym" | "date" | "xref"; diff --git a/src/engine/parser.ts b/src/engine/parser.ts index 5287aef..c9ad92b 100644 --- a/src/engine/parser.ts +++ b/src/engine/parser.ts @@ -1,9 +1,9 @@ // Noise-tolerant Pratt parser. Unknown words are dropped (free text around // math is allowed), except right after a conversion word — those may name // a timezone ("time in New York"). -import { Decimal, NumeralRepr, Unit } from "./types"; -import { Token } from "./tokenizer"; -import { PctOp, DateWord, BitOp } from "./registry"; +import type { Decimal, NumeralRepr, Unit } from "./types"; +import type { Token } from "./tokenizer"; +import type { PctOp, DateWord, BitOp } from "./registry"; export type ConvTarget = | { type: "unit"; unit: Unit } @@ -48,7 +48,7 @@ export function parseLine(tokens: Token[], knownVars: Set, line: string) let assign: string | undefined; // a defined variable shadows whatever its name tokenized as (unit "m", agg "sum"...) let toks: Token[] = tokens.map((tk) => { - if (tk.t === "word" || tk.t === "num" || tk.t === "junk") return tk; + if (tk.t === "word" || tk.t === "num" || tk.t === "junk") {return tk;} const raw = line.slice(tk.start, tk.end); return knownVars.has(raw) ? { t: "word", raw, start: tk.start, end: tk.end } : tk; }); @@ -67,9 +67,9 @@ export function parseLine(tokens: Token[], knownVars: Set, line: string) const filtered: Token[] = []; let afterConv = false; for (const tk of toks) { - if (tk.t === "junk") continue; + if (tk.t === "junk") {continue;} if (tk.t === "word") { - if (knownVars.has(tk.raw) || afterConv) filtered.push(tk); + if (knownVars.has(tk.raw) || afterConv) {filtered.push(tk);} continue; } // a bare "/" inside a timezone name ("Europe/Berlin") must not break the @@ -89,7 +89,7 @@ export function parseLine(tokens: Token[], knownVars: Set, line: string) if (assignIdx >= 0) { const lhs = new Parser(filtered.slice(0, assignIdx), knownVars).parseSeq(); const rhs = new Parser(filtered.slice(assignIdx + 1), knownVars).parseSeq(); - if (lhs && rhs) return { expr: { k: "goalseek", lhs, rhs } }; + if (lhs && rhs) {return { expr: { k: "goalseek", lhs, rhs } };} } } @@ -173,10 +173,10 @@ class Parser { } const before = this.i; const node = this.parseExpr(PREC.BIT); - if (node) items.push(node); - if (this.i === before) this.i++; // skip unparsable token + if (node) {items.push(node);} + if (this.i === before) {this.i++;} // skip unparsable token } - if (items.length === 0) return null; + if (items.length === 0) {return null;} return mk(); } @@ -185,61 +185,61 @@ class Parser { */ private parseExpr(minPrec: number = 0): Node | null { let tk = this.peek(); - if (!tk) return null; + if (!tk) {return null;} let lhs: Node | null = null; if (tk.t === "op" && tk.op === "minus") { this.i++; const x = this.parseExpr(PREC.UNARY); - if (!x) return null; + if (!x) {return null;} lhs = { k: "neg", x }; } else if (tk.t === "op" && tk.op === "plus") { this.i++; lhs = this.parseExpr(PREC.UNARY); - if (!lhs) return null; + if (!lhs) {return null;} } else { lhs = this.parsePrimary(); } - if (!lhs) return null; + if (!lhs) {return null;} for (;;) { tk = this.peek(); - if (!tk) break; + if (!tk) {break;} if (tk.t === "percent") { - if (PREC.POSTFIX < minPrec) break; + if (PREC.POSTFIX < minPrec) {break;} this.i++; lhs = { k: "pct", x: lhs }; continue; } if (tk.t === "bang") { - if (PREC.POSTFIX < minPrec) break; + if (PREC.POSTFIX < minPrec) {break;} this.i++; lhs = { k: "fact", x: lhs }; continue; } if (tk.t === "unit") { - if (PREC.POSTFIX < minPrec) break; + if (PREC.POSTFIX < minPrec) {break;} this.i++; lhs = { k: "unit", x: lhs, unit: tk.unit }; continue; } if (tk.t === "currency") { - if (PREC.POSTFIX < minPrec) break; + if (PREC.POSTFIX < minPrec) {break;} this.i++; lhs = { k: "curr", x: lhs, code: tk.code }; continue; } if (tk.t === "scale") { - if (PREC.POSTFIX < minPrec) break; + if (PREC.POSTFIX < minPrec) {break;} this.i++; lhs = { k: "scale", x: lhs, mult: tk.mult, label: "" }; continue; } if (tk.t === "op" && tk.op === "pow") { - if (PREC.POW < minPrec) break; + if (PREC.POW < minPrec) {break;} const save = this.i; this.i++; const r = this.parseExpr(PREC.POW); @@ -248,7 +248,7 @@ class Parser { continue; } if (tk.t === "op" && (tk.op === "mul" || tk.op === "div" || tk.op === "mod")) { - if (PREC.MUL < minPrec) break; + if (PREC.MUL < minPrec) {break;} const save = this.i; this.i++; const r = this.parseExpr(PREC.MUL + 1); @@ -261,7 +261,7 @@ class Parser { this.i++; continue; } - if (PREC.MUL < minPrec) break; + if (PREC.MUL < minPrec) {break;} const save = this.i; this.i++; const r = this.parseExpr(PREC.MUL + 1); @@ -270,7 +270,7 @@ class Parser { continue; } if (tk.t === "lparen" || tk.t === "const" || tk.t === "func" || (tk.t === "word" && this.vars.has(tk.raw))) { - if (PREC.MUL < minPrec) break; + if (PREC.MUL < minPrec) {break;} const save = this.i; const r = this.parseExpr(PREC.MUL + 1); if (!r) { this.i = save; break; } @@ -278,7 +278,7 @@ class Parser { continue; } if (tk.t === "op" && (tk.op === "plus" || tk.op === "minus")) { - if (PREC.ADD < minPrec) break; + if (PREC.ADD < minPrec) {break;} const save = this.i; this.i++; const r = this.parseExpr(PREC.ADD + 1); @@ -287,7 +287,7 @@ class Parser { continue; } if (tk.t === "bitop") { - if (PREC.BIT < minPrec) break; + if (PREC.BIT < minPrec) {break;} const save = this.i; this.i++; const r = this.parseExpr(PREC.BIT + 1); @@ -296,7 +296,7 @@ class Parser { continue; } if (tk.t === "conv") { - if (PREC.CONV < minPrec) break; + if (PREC.CONV < minPrec) {break;} const save = this.i; this.i++; const target = this.parseTarget(); @@ -305,7 +305,7 @@ class Parser { continue; } if (tk.t === "pctop" && (tk.op.startsWith("as_pct") || tk.op.endsWith("what_is"))) { - if (PREC.CONV < minPrec) break; + if (PREC.CONV < minPrec) {break;} const save = this.i; this.i++; const r = this.parseExpr(PREC.ADD); @@ -320,9 +320,9 @@ class Parser { } /** If the current token is `pctop(on)` + `datelit`, consume them and attach `onDate`. */ private tryHistoricalDate(target: ConvTarget): ConvTarget { - if (target.type !== "currency") return target; + if (target.type !== "currency") {return target;} const nx = this.peek(); - if (nx?.t !== "pctop" || nx.op !== "on") return target; + if (nx?.t !== "pctop" || nx.op !== "on") {return target;} const save = this.i; this.i++; const dk = this.peek(); @@ -338,7 +338,7 @@ class Parser { private parseTarget(): ConvTarget | null { const tk = this.peek(); - if (!tk) return null; + if (!tk) {return null;} if (tk.t === "unit") { this.i++; return { type: "unit", unit: tk.unit }; @@ -378,14 +378,14 @@ class Parser { } break; } - if (words.length > 0) return { type: "tz", words }; + if (words.length > 0) {return { type: "tz", words };} } return null; } private parsePrimary(): Node | null { const tk = this.peek(); - if (!tk) return null; + if (!tk) {return null;} switch (tk.t) { case "num": this.i++; @@ -419,7 +419,7 @@ class Parser { const args: Node[] = []; for (;;) { const arg = this.parseExpr(PREC.CONV); - if (arg) args.push(arg); + if (arg) {args.push(arg);} const nx = this.peek(); if (nx?.t === "semicolon") { this.i++; @@ -429,7 +429,7 @@ class Parser { this.i++; break; } - if (!nx) break; + if (!nx) {break;} if (!arg) { this.i++; // skip stray token inside call } @@ -438,14 +438,14 @@ class Parser { } // `sqrt 16` / `square root of 16` — the "of" is decorative const nx = this.peek(); - if (nx?.t === "pctop" && nx.op === "of") this.i++; + if (nx?.t === "pctop" && nx.op === "of") {this.i++;} const arg = this.parseExpr(PREC.UNARY); return arg ? { k: "call", name, args: [arg] } : null; } case "lparen": { this.i++; const inner = this.parseExpr(PREC.CONV); - if (this.peek()?.t === "rparen") this.i++; + if (this.peek()?.t === "rparen") {this.i++;} return inner; } case "currency": { diff --git a/src/engine/registry.test.ts b/src/engine/registry.test.ts index 324e297..26f1e3c 100644 --- a/src/engine/registry.test.ts +++ b/src/engine/registry.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { buildRegistry, Registry } from "./registry"; +import type { Registry } from "./registry"; +import { buildRegistry } from "./registry"; import { lexLine } from "./lexer"; import { Decimal } from "./types"; @@ -16,12 +17,12 @@ describe("registry", () => { const plusPayload = reg.match(lexLine("plus"), ["plus"], 0); expect(plusPayload).not.toBeNull(); expect(plusPayload?.payload.t).toBe("op"); - if (plusPayload?.payload.t === "op") expect(plusPayload.payload.op).toBe("plus"); + if (plusPayload?.payload.t === "op") {expect(plusPayload.payload.op).toBe("plus");} const piPayload = reg.match(lexLine("pi"), ["pi"], 0); expect(piPayload).not.toBeNull(); expect(piPayload?.payload.t).toBe("const"); - if (piPayload?.payload.t === "const") expect(piPayload.payload.name).toBe("pi"); + if (piPayload?.payload.t === "const") {expect(piPayload.payload.name).toBe("pi");} }); it("registers built-in functions", () => { @@ -90,14 +91,14 @@ describe("registry", () => { const log2Match = reg.match(lex2, lex2.map(l => l.raw), 0); expect(log2Match).not.toBeNull(); expect(log2Match?.payload.t).toBe("func"); - if (log2Match?.payload.t === "func") expect(log2Match.payload.name).toBe("log2"); + if (log2Match?.payload.t === "func") {expect(log2Match.payload.name).toBe("log2");} const lexSpace2 = lexLine("log 2"); const logSpace2Match = reg.match(lexSpace2, lexSpace2.map(l => l.raw), 0); // "log 2" should match "log", length 1, not "log2" expect(logSpace2Match).not.toBeNull(); expect(logSpace2Match?.length).toBe(1); - if (logSpace2Match?.payload.t === "func") expect(logSpace2Match.payload.name).toBe("log"); + if (logSpace2Match?.payload.t === "func") {expect(logSpace2Match.payload.name).toBe("log");} }); it("returns null for no match", () => { diff --git a/src/engine/registry.ts b/src/engine/registry.ts index 5ea0efa..dce663a 100644 --- a/src/engine/registry.ts +++ b/src/engine/registry.ts @@ -2,8 +2,10 @@ // currencies, word-operators, functions, scales, date words) to a payload // for the tokenizer. Longest phrase wins; case-sensitive entries (symbols // like "m"/"M", "kB") are tried before the case-insensitive ones. -import { Decimal, Dimension, NumeralRepr, Unit, Value } from "./types"; -import { Lex, lexLine } from "./lexer"; +import type { Dimension, NumeralRepr, Unit, Value } from "./types"; +import { Decimal } from "./types"; +import type { Lex} from "./lexer"; +import { lexLine } from "./lexer"; import * as vocab from "./vocab"; import { UNIT_DATA, SI_PREFIXES, DATA_SI_PREFIXES, IEC_PREFIXES, SCALE_DATA } from "./unitdata"; import { EXTRA_UNITS, CRYPTO } from "./extraunits"; @@ -75,7 +77,7 @@ export class Registry { addPhrase(phrase: string, payload: Payload, opts: { caseSensitive?: boolean } = {}): void { const lexs = lexLine(phrase); - if (lexs.length === 0) return; + if (lexs.length === 0) {return;} const lexemes = lexs.map((l) => l.raw); const caseSensitive = opts.caseSensitive ?? lexemes.every((l) => l.length <= 2); const wordOrNum = (t: Lex["type"]) => t === "word" || t === "num"; @@ -104,9 +106,9 @@ export class Registry { */ match(lexes: Lex[], lowers: string[], i: number): { payload: Payload; length: number } | null { const list = this.byFirst.get(lowers[i]); - if (!list) return null; + if (!list) {return null;} for (const e of list) { - if (i + e.lexemes.length > lexes.length) continue; + if (i + e.lexemes.length > lexes.length) {continue;} let ok = true; for (let k = 0; k < e.lexemes.length; k++) { if (e.caseSensitive ? lexes[i + k].raw !== e.lexemes[k] : lowers[i + k] !== e.lower[k]) { @@ -118,7 +120,7 @@ export class Registry { break; } } - if (ok) return { payload: e.payload, length: e.lexemes.length }; + if (ok) {return { payload: e.payload, length: e.lexemes.length };} } return null; } @@ -127,14 +129,14 @@ export class Registry { this.rates.clear(); for (const [code, v] of Object.entries(rates)) { const d = new Decimal(v); - if (d.isFinite() && d.gt(0)) this.rates.set(code.toUpperCase(), d); + if (d.isFinite() && d.gt(0)) {this.rates.set(code.toUpperCase(), d);} } this.rates.set("USD", new Decimal(1)); } makeCurrencyUnit(code: string): Unit | null { const rate = this.rates.get(code); - if (!rate) return null; + if (!rate) {return null;} return this.makeCurrencyUnitFromRate(code, rate); } @@ -154,7 +156,7 @@ export class Registry { function addUnitPhrases(reg: Registry, phrases: string[], unit: Unit, opts: { caseSensitive?: boolean } = {}): void { reg.unitsById.set(unit.id, unit); - for (const p of phrases) reg.addPhrase(p, { t: "unit", unit }, opts); + for (const p of phrases) {reg.addPhrase(p, { t: "unit", unit }, opts);} } interface LengthPhrase { phrase: string; unit: Unit; caseSensitive?: boolean } @@ -180,27 +182,27 @@ function registerCoreVocab(reg: Registry): void { ["divide", { t: "op", op: "div" }], ]; for (const [key, payload] of ops) { - for (const v of vocab.variants("Operations", `${key}.variants`)) reg.addPhrase(v, payload, { caseSensitive: false }); + for (const v of vocab.variants("Operations", `${key}.variants`)) {reg.addPhrase(v, payload, { caseSensitive: false });} } - for (const v of vocab.variants("Operations", "conversion.variants")) reg.addPhrase(v, { t: "conv" }, { caseSensitive: false }); - for (const v of vocab.variants("Operations", "assignment.variants")) reg.addPhrase(v, { t: "assign" }, { caseSensitive: false }); - for (const v of vocab.variants("Operations", "sum.variants")) reg.addPhrase(v, { t: "agg", name: "sum" }, { caseSensitive: false }); - for (const v of vocab.variants("Operations", "avg.variants")) reg.addPhrase(v, { t: "agg", name: "avg" }, { caseSensitive: false }); - for (const v of vocab.variants("Operations", "prev.variants")) reg.addPhrase(v, { t: "agg", name: "prev" }, { caseSensitive: false }); - for (const v of vocab.variants("Operations", "count.variants")) reg.addPhrase(v, { t: "agg", name: "count" }, { caseSensitive: false }); - for (const v of vocab.variants("Operations", "min.variants")) reg.addPhrase(v, { t: "agg", name: "min" }, { caseSensitive: false }); - for (const v of vocab.variants("Operations", "max.variants")) reg.addPhrase(v, { t: "agg", name: "max" }, { caseSensitive: false }); - for (const v of vocab.variants("Operations", "product.variants")) reg.addPhrase(v, { t: "agg", name: "product" }, { caseSensitive: false }); - for (const v of vocab.variants("Operations", "chart.variants")) reg.addPhrase(v, { t: "agg", name: "chart" }, { caseSensitive: false }); - for (const v of vocab.variants("Operations", "sqrt.variants")) reg.addPhrase(v, { t: "func", name: "sqrt" }, { caseSensitive: false }); - for (const v of vocab.variants("Operations", "cbrt.variants")) reg.addPhrase(v, { t: "func", name: "cbrt" }, { caseSensitive: false }); - for (const v of vocab.variants("Operations", "abs.variants")) reg.addPhrase(v, { t: "func", name: "abs" }, { caseSensitive: false }); - for (const v of vocab.variants("Operations", "ln.variants")) reg.addPhrase(v, { t: "func", name: "ln" }, { caseSensitive: false }); - for (const v of vocab.variants("Operations", "log.variants")) reg.addPhrase(v, { t: "func", name: "log" }, { caseSensitive: false }); - for (const v of vocab.variants("Operations", "round.variants")) reg.addPhrase(v, { t: "func", name: "round" }, { caseSensitive: false }); - for (const v of vocab.variants("Operations", "random.variants")) reg.addPhrase(v, { t: "func", name: "random" }, { caseSensitive: false }); - for (const v of vocab.variants("Operations", "until.variants")) reg.addPhrase(v, { t: "func", name: "until" }, { caseSensitive: false }); - for (const v of vocab.variants("Operations", "since.variants")) reg.addPhrase(v, { t: "func", name: "since" }, { caseSensitive: false }); + for (const v of vocab.variants("Operations", "conversion.variants")) {reg.addPhrase(v, { t: "conv" }, { caseSensitive: false });} + for (const v of vocab.variants("Operations", "assignment.variants")) {reg.addPhrase(v, { t: "assign" }, { caseSensitive: false });} + for (const v of vocab.variants("Operations", "sum.variants")) {reg.addPhrase(v, { t: "agg", name: "sum" }, { caseSensitive: false });} + for (const v of vocab.variants("Operations", "avg.variants")) {reg.addPhrase(v, { t: "agg", name: "avg" }, { caseSensitive: false });} + for (const v of vocab.variants("Operations", "prev.variants")) {reg.addPhrase(v, { t: "agg", name: "prev" }, { caseSensitive: false });} + for (const v of vocab.variants("Operations", "count.variants")) {reg.addPhrase(v, { t: "agg", name: "count" }, { caseSensitive: false });} + for (const v of vocab.variants("Operations", "min.variants")) {reg.addPhrase(v, { t: "agg", name: "min" }, { caseSensitive: false });} + for (const v of vocab.variants("Operations", "max.variants")) {reg.addPhrase(v, { t: "agg", name: "max" }, { caseSensitive: false });} + for (const v of vocab.variants("Operations", "product.variants")) {reg.addPhrase(v, { t: "agg", name: "product" }, { caseSensitive: false });} + for (const v of vocab.variants("Operations", "chart.variants")) {reg.addPhrase(v, { t: "agg", name: "chart" }, { caseSensitive: false });} + for (const v of vocab.variants("Operations", "sqrt.variants")) {reg.addPhrase(v, { t: "func", name: "sqrt" }, { caseSensitive: false });} + for (const v of vocab.variants("Operations", "cbrt.variants")) {reg.addPhrase(v, { t: "func", name: "cbrt" }, { caseSensitive: false });} + for (const v of vocab.variants("Operations", "abs.variants")) {reg.addPhrase(v, { t: "func", name: "abs" }, { caseSensitive: false });} + for (const v of vocab.variants("Operations", "ln.variants")) {reg.addPhrase(v, { t: "func", name: "ln" }, { caseSensitive: false });} + for (const v of vocab.variants("Operations", "log.variants")) {reg.addPhrase(v, { t: "func", name: "log" }, { caseSensitive: false });} + for (const v of vocab.variants("Operations", "round.variants")) {reg.addPhrase(v, { t: "func", name: "round" }, { caseSensitive: false });} + for (const v of vocab.variants("Operations", "random.variants")) {reg.addPhrase(v, { t: "func", name: "random" }, { caseSensitive: false });} + for (const v of vocab.variants("Operations", "until.variants")) {reg.addPhrase(v, { t: "func", name: "until" }, { caseSensitive: false });} + for (const v of vocab.variants("Operations", "since.variants")) {reg.addPhrase(v, { t: "func", name: "since" }, { caseSensitive: false });} reg.addPhrase("mod", { t: "op", op: "mod" }, { caseSensitive: false }); reg.addPhrase("modulo", { t: "op", op: "mod" }, { caseSensitive: false }); @@ -224,30 +226,30 @@ function registerCoreVocab(reg: Registry): void { reg.addPhrase(v, { t: "repr", repr: "roman" }, { caseSensitive: false }); } - for (const v of vocab.variants("Percent", "percent.variants")) reg.addPhrase(v, { t: "percent" }); + for (const v of vocab.variants("Percent", "percent.variants")) {reg.addPhrase(v, { t: "percent" });} const pctOps: Array<[string, PctOp]> = [ ["percent_of", "of"], ["percent_off", "off"], ["percent_on", "on"], ["as_a_percent_of", "as_pct_of"], ["as_a_percent_off", "as_pct_off"], ["as_a_percent_on", "as_pct_on"], ["percent_of_what_is", "of_what_is"], ["percent_off_what_is", "off_what_is"], ["percent_on_what_is", "on_what_is"], ]; for (const [key, op] of pctOps) { - for (const v of vocab.variants("Percent", `${key}.variants`)) reg.addPhrase(v, { t: "pctop", op }, { caseSensitive: false }); + for (const v of vocab.variants("Percent", `${key}.variants`)) {reg.addPhrase(v, { t: "pctop", op }, { caseSensitive: false });} } const dateWords: Array<[string, DateWord]> = [ ["today", "today"], ["tomorrow", "tomorrow"], ["yesterday", "yesterday"], ]; for (const [key, word] of dateWords) { - for (const v of vocab.variants("Dates", `${key}.variants`)) reg.addPhrase(v, { t: "date", word }, { caseSensitive: false }); + for (const v of vocab.variants("Dates", `${key}.variants`)) {reg.addPhrase(v, { t: "date", word }, { caseSensitive: false });} } - for (const v of vocab.variants("Dates", "current_time.variants")) reg.addPhrase(v, { t: "date", word: v.toLowerCase() === "now" || v.toLowerCase() === "сейчас" ? "now" : "time" }, { caseSensitive: false }); - for (const v of vocab.variants("Dates", "local_time.variants")) reg.addPhrase(v, { t: "date", word: "local" }, { caseSensitive: false }); + for (const v of vocab.variants("Dates", "current_time.variants")) {reg.addPhrase(v, { t: "date", word: v.toLowerCase() === "now" || v.toLowerCase() === "сейчас" ? "now" : "time" }, { caseSensitive: false });} + for (const v of vocab.variants("Dates", "local_time.variants")) {reg.addPhrase(v, { t: "date", word: "local" }, { caseSensitive: false });} const reprs: Array<[string, NumeralRepr]> = [ ["hex", "hex"], ["binary", "binary"], ["octal", "octal"], ["decimal", "decimal"], ["scientific", "scientific"], ]; for (const [key, repr] of reprs) { - for (const v of vocab.variants("Numbers", `${key}.variants`)) reg.addPhrase(v, { t: "repr", repr }, { caseSensitive: false }); + for (const v of vocab.variants("Numbers", `${key}.variants`)) {reg.addPhrase(v, { t: "repr", repr }, { caseSensitive: false });} } for (const [id, mult] of Object.entries(SCALE_DATA)) { @@ -267,7 +269,7 @@ function registerCoreVocab(reg: Registry): void { reg.addPhrase("полтора", { t: "const", name: "onehalf" }, { caseSensitive: false }); reg.addPhrase("полторы", { t: "const", name: "onehalf" }, { caseSensitive: false }); - for (const f of BUILTIN_FUNCS) reg.addPhrase(f, { t: "func", name: f }, { caseSensitive: false }); + for (const f of BUILTIN_FUNCS) {reg.addPhrase(f, { t: "func", name: f }, { caseSensitive: false });} } // UNIT_DATA + SI/IEC prefixes; returns the length-dimension phrases the @@ -291,7 +293,7 @@ function registerUnits(reg: Registry): LengthPhrase[] { }; addUnitPhrases(reg, variants, unit); if (d.dimension === "length") { - for (const v of variants) lengthPhrases.push({ phrase: v, unit }); + for (const v of variants) {lengthPhrases.push({ phrase: v, unit });} } if (d.prefixes) { @@ -320,34 +322,34 @@ function registerUnits(reg: Registry): LengthPhrase[] { csPhrases.push(`${pSym}bit`); } else { // symbol compositions in every locale: "km" and "км" - for (const ps of pSymsAll) for (const us of symbolsAll) csPhrases.push(ps + us); + for (const ps of pSymsAll) {for (const us of symbolsAll) {csPhrases.push(ps + us);}} } - for (const pw of pWords) for (const wv of wordVariants) phrases.push(pw + wv); + for (const pw of pWords) {for (const wv of wordVariants) {phrases.push(pw + wv);}} if (d.id === "byte" && pSym) { // lenient: KB/kb/Kb all mean kilobyte phrases.push((pSym + "b").toLowerCase()); } reg.unitsById.set(pUnit.id, pUnit); - for (const ph of csPhrases) reg.addPhrase(ph, { t: "unit", unit: pUnit }, { caseSensitive: true }); - for (const ph of phrases) reg.addPhrase(ph, { t: "unit", unit: pUnit }, { caseSensitive: false }); + for (const ph of csPhrases) {reg.addPhrase(ph, { t: "unit", unit: pUnit }, { caseSensitive: true });} + for (const ph of phrases) {reg.addPhrase(ph, { t: "unit", unit: pUnit }, { caseSensitive: false });} // collect unambiguous lowercase forms: "KM"/"Km" should still mean km, // while "mm"/"Mm" stay strict (milli vs mega) for (const ph of csPhrases) { const lower = ph.toLowerCase(); const prev = lenientSym.get(lower); - if (prev === undefined) lenientSym.set(lower, pUnit); - else if (prev && prev.id !== pUnit.id) lenientSym.set(lower, null); + if (prev === undefined) {lenientSym.set(lower, pUnit);} + else if (prev && prev.id !== pUnit.id) {lenientSym.set(lower, null);} } if (d.dimension === "length") { - for (const ph of csPhrases) lengthPhrases.push({ phrase: ph, unit: pUnit, caseSensitive: true }); - for (const pw of pWords) for (const wv of wordVariants) lengthPhrases.push({ phrase: pw + wv, unit: pUnit, caseSensitive: false }); + for (const ph of csPhrases) {lengthPhrases.push({ phrase: ph, unit: pUnit, caseSensitive: true });} + for (const pw of pWords) {for (const wv of wordVariants) {lengthPhrases.push({ phrase: pw + wv, unit: pUnit, caseSensitive: false });}} } } } } for (const [lower, unit] of lenientSym) { - if (unit) reg.addPhrase(lower, { t: "unit", unit }, { caseSensitive: false }); + if (unit) {reg.addPhrase(lower, { t: "unit", unit }, { caseSensitive: false });} } return lengthPhrases; @@ -400,10 +402,10 @@ function registerCurrencies(reg: Registry): void { for (const code of codes) { reg.currencyCodes.add(code); const fmt = vocab.entryEn("Currency", `${code}.format`); - if (fmt) reg.currencyFormat.set(code, fmt.replace("%@", "{}")); + if (fmt) {reg.currencyFormat.set(code, fmt.replace("%@", "{}"));} const variants = vocab.variants("Currency", `${code}.variants`); - if (!variants.some((v) => v.toUpperCase() === code)) variants.push(code); - for (const v of variants) reg.addPhrase(v, { t: "currency", code }, { caseSensitive: false }); + if (!variants.some((v) => v.toUpperCase() === code)) {variants.push(code);} + for (const v of variants) {reg.addPhrase(v, { t: "currency", code }, { caseSensitive: false });} } } @@ -420,12 +422,12 @@ function registerExtraUnits(reg: Registry): void { reg.unitsById.set(unit.id, unit); for (const p of d.phrases.split(",")) { const ph = p.trim(); - if (ph) reg.addPhrase(ph, { t: "unit", unit }, { caseSensitive: false }); + if (ph) {reg.addPhrase(ph, { t: "unit", unit }, { caseSensitive: false });} } if (d.symbols) { for (const s of d.symbols.split(",")) { const ph = s.trim(); - if (ph) reg.addPhrase(ph, { t: "unit", unit }, { caseSensitive: true }); + if (ph) {reg.addPhrase(ph, { t: "unit", unit }, { caseSensitive: true });} } } } @@ -441,7 +443,7 @@ function registerCrypto(reg: Registry): void { reg.addPhrase(c.code, { t: "currency", code: c.code }, { caseSensitive: CRYPTO_STRICT_CODES.has(c.code) }); for (const p of c.phrases.split(",")) { const ph = p.trim(); - if (ph) reg.addPhrase(ph, { t: "currency", code: c.code }, { caseSensitive: false }); + if (ph) {reg.addPhrase(ph, { t: "currency", code: c.code }, { caseSensitive: false });} } } } @@ -450,18 +452,18 @@ function registerCompletions(reg: Registry): void { const seen = new Set(); const addCompletion = (label: string, type: Completion["type"], detail?: string) => { const k = `${type}:${label.toLowerCase()}`; - if (seen.has(k) || label.length < 2 || !/^[\p{L}]/u.test(label)) return; + if (seen.has(k) || label.length < 2 || !/^[\p{L}]/u.test(label)) {return;} seen.add(k); reg.completions.push({ label, type, detail }); }; for (const d of UNIT_DATA) { - for (const v of vocab.variants(d.category, `${d.id}.variants`)) addCompletion(v, "unit", d.dimension); + for (const v of vocab.variants(d.category, `${d.id}.variants`)) {addCompletion(v, "unit", d.dimension);} } for (const d of EXTRA_UNITS) { - for (const v of d.phrases.split(",")) addCompletion(v.trim(), "unit", d.dimension); + for (const v of d.phrases.split(",")) {addCompletion(v.trim(), "unit", d.dimension);} } - for (const code of reg.currencyCodes) addCompletion(code, "currency"); - for (const f of BUILTIN_FUNCS) addCompletion(f, "function"); + for (const code of reg.currencyCodes) {addCompletion(code, "currency");} + for (const f of BUILTIN_FUNCS) {addCompletion(f, "function");} for (const k of ["sum", "total", "average", "avg", "prev", "today", "tomorrow", "yesterday", "time", "now", "hex", "binary"]) { addCompletion(k, "keyword"); } diff --git a/src/engine/tokenizer.test.ts b/src/engine/tokenizer.test.ts index e518545..716f2e3 100644 --- a/src/engine/tokenizer.test.ts +++ b/src/engine/tokenizer.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeAll } from "vitest"; import { tokenize } from "./tokenizer"; -import { buildRegistry, Registry } from "./registry"; +import type { Registry } from "./registry"; +import { buildRegistry } from "./registry"; describe("tokenizer", () => { let reg: Registry; diff --git a/src/engine/tokenizer.ts b/src/engine/tokenizer.ts index a2e1391..01fda24 100644 --- a/src/engine/tokenizer.ts +++ b/src/engine/tokenizer.ts @@ -1,7 +1,7 @@ // Lexes a line and resolves phrases through the registry into semantic tokens. -import { DateOrder, Decimal, NumeralRepr, Unit } from "./types"; +import type { DateOrder, Decimal, NumeralRepr, Unit } from "./types"; import { lexLine } from "./lexer"; -import { Registry, PctOp, DateWord, BitOp } from "./registry"; +import type { Registry, PctOp, DateWord, BitOp } from "./registry"; export type Token = | { t: "num"; v: Decimal; repr: NumeralRepr; start: number; end: number } @@ -125,15 +125,15 @@ export function tokenize(line: string, reg: Registry, dateOrder: DateOrder = "dm */ function disambiguateIn(tokens: Token[], reg: Registry, line: string): Token[] { const inch = reg.unitsById.get("inch"); - if (!inch) return tokens; + if (!inch) {return tokens;} return tokens.map((tk, i) => { - if (tk.t !== "conv" || line.slice(tk.start, tk.end).toLowerCase() !== "in") return tk; + if (tk.t !== "conv" || line.slice(tk.start, tk.end).toLowerCase() !== "in") {return tk;} const prev = tokens[i - 1]; const next = tokens[i + 1]; const prevIsNum = prev?.t === "num"; const nextEnds = !next || next.t === "conv" || next.t === "op" || next.t === "rparen"; if (prevIsNum && nextEnds) { - return { t: "unit", unit: inch, start: tk.start, end: tk.end } as Token; + return { t: "unit", unit: inch, start: tk.start, end: tk.end }; } return tk; }); diff --git a/src/engine/unitdata.ts b/src/engine/unitdata.ts index 7b6692e..f7612b6 100644 --- a/src/engine/unitdata.ts +++ b/src/engine/unitdata.ts @@ -1,7 +1,8 @@ // Conversion ratios. Base units per dimension: length=meter, area=m², // volume=m³, weight=gram, time=second, angle=radian, data=bit, // temperature=kelvin (ratio+offset). -import { Decimal, Dimension } from "./types"; +import type { Dimension } from "./types"; +import { Decimal } from "./types"; export interface UnitData { id: string; diff --git a/src/engine/vocab.ts b/src/engine/vocab.ts index 32604b4..1a196a0 100644 --- a/src/engine/vocab.ts +++ b/src/engine/vocab.ts @@ -12,10 +12,10 @@ export function variants(category: string, key: string): string[] { const out: string[] = []; for (const loc of locales) { const raw = loc[category]?.[key]; - if (!raw) continue; + if (!raw) {continue;} for (const v of raw.split(",")) { const t = v.trim(); - if (t && !out.includes(t)) out.push(t); + if (t && !out.includes(t)) {out.push(t);} } } return out; @@ -25,7 +25,7 @@ export function variants(category: string, key: string): string[] { export function entry(category: string, key: string): string | undefined { for (const loc of locales) { const raw = loc[category]?.[key]; - if (raw !== undefined) return raw; + if (raw !== undefined) {return raw;} } return undefined; } @@ -40,7 +40,7 @@ export function entriesAll(category: string, key: string): string[] { const out: string[] = []; for (const loc of locales) { const raw = loc[category]?.[key]; - if (raw && !out.includes(raw)) out.push(raw); + if (raw && !out.includes(raw)) {out.push(raw);} } return out; } @@ -50,9 +50,9 @@ export function idsOf(category: string): string[] { const ids = new Set(); for (const loc of locales) { const cat = loc[category]; - if (!cat) continue; + if (!cat) {continue;} for (const k of Object.keys(cat)) { - if (k.endsWith(".variants")) ids.add(k.slice(0, -".variants".length)); + if (k.endsWith(".variants")) {ids.add(k.slice(0, -".variants".length));} } } return [...ids]; @@ -63,9 +63,9 @@ export function idsWithSuffix(category: string, suffix: string): string[] { const ids = new Set(); for (const loc of locales) { const cat = loc[category]; - if (!cat) continue; + if (!cat) {continue;} for (const k of Object.keys(cat)) { - if (k.endsWith(suffix)) ids.add(k.slice(0, -suffix.length)); + if (k.endsWith(suffix)) {ids.add(k.slice(0, -suffix.length));} } } return [...ids]; diff --git a/src/extensions.test.ts b/src/extensions.test.ts index 104eda9..ddcba20 100644 --- a/src/extensions.test.ts +++ b/src/extensions.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { makeApi, runExtensions } from "./extensions"; -import { SumEngine } from "./engine"; +import type { SumEngine } from "./engine"; describe("extensions", () => { let mockEngine: any; diff --git a/src/extensions.ts b/src/extensions.ts index 0722e68..9bcbfbb 100644 --- a/src/extensions.ts +++ b/src/extensions.ts @@ -1,12 +1,13 @@ // JS extensions runtime: numi.setVariable, numi.addUnit, numi.addFunction. // Scripts run inside a QuickJS WASM sandbox — no DOM, no Tauri IPC, no host globals. -import { - newQuickJSWASMModuleFromVariant, +import type { QuickJSContext, - QuickJSHandle, + QuickJSHandle} from "quickjs-emscripten-core"; +import { + newQuickJSWASMModuleFromVariant } from "quickjs-emscripten-core"; import releaseSyncVariant from "@jitl/quickjs-singlefile-browser-release-sync"; -import { SumEngine, ExtensionUnitSpec, ExtensionValue } from "./engine"; +import type { SumEngine, ExtensionUnitSpec, ExtensionValue } from "./engine"; interface ExtensionApi { setVariable(name: string, value: number | ExtensionValue): void; @@ -40,7 +41,7 @@ const now = () => typeof performance !== "undefined" ? performance.now() : Date.now(); function toExtValue(v: unknown, what: string): ExtensionValue | number { - if (typeof v === "number" && Number.isFinite(v)) return v; + if (typeof v === "number" && Number.isFinite(v)) {return v;} if (v && typeof v === "object") { const o = v as { double?: unknown; unitId?: unknown }; if (typeof o.double === "number" && Number.isFinite(o.double)) { @@ -53,7 +54,7 @@ function toExtValue(v: unknown, what: string): ExtensionValue | number { } function message(e: unknown): string { - if (e instanceof Error) return e.message; + if (e instanceof Error) {return e.message;} if ( e && typeof e === "object" && @@ -71,7 +72,7 @@ export async function runExtensions( engine: SumEngine, scripts: { name: string; code: string }[], ): Promise { - if (scripts.length === 0) return; + if (scripts.length === 0) {return;} try { await load(engine, scripts); } catch (e) { @@ -141,7 +142,7 @@ function exposeApi( define("setVariable", (nameHandle, valueHandle) => { const name = vm.dump(nameHandle); if (typeof name !== "string" || !name) - throw new Error("expects a variable name"); + {throw new Error("expects a variable name");} api.setVariable(name, toExtValue(vm.dump(valueHandle), "value")); }); @@ -167,9 +168,9 @@ function exposeApi( define("addFunction", (specHandle, fnHandle) => { const spec = vm.dump(specHandle); if (!spec || typeof spec.id !== "string") - throw new Error("expects { id, phrases }"); + {throw new Error("expects { id, phrases }");} if (!fnHandle || vm.typeof(fnHandle) !== "function") - throw new Error("expects a function"); + {throw new Error("expects a function");} // outlives this callback, so it needs its own reference; the context is never disposed const guestFn = fnHandle.dup(); const id = spec.id; diff --git a/src/main.ts b/src/main.ts index 6102e50..a4dbcb5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,8 +2,9 @@ import "./ui/app.css"; import { SumEngine } from "./engine"; import { SumEditor } from "./ui/editor"; -import { - AppData, DocMeta, SettingsData, defaultSettingsData, +import type { + AppData, DocMeta, SettingsData} from "./storage"; +import { defaultSettingsData, loadAppData, saveAppData, flushAppData, onAppQuit, loadSettings, saveSettings, fetchRates, fetchMarketData, fetchHistoricalRatesBatch, loadExtensionScripts, openExtensionsFolder, isTauri, getLaunchFile, onOpenFile, onFileDrop, @@ -17,7 +18,8 @@ import { checkForUpdate } from "./updater"; import { Workspace } from "./workspace"; import { EN, RU } from "./engine/vocab-data"; import { exportSheetImage } from "./ui/export"; -import { initSearch, SearchController } from "./ui/search"; +import type { SearchController } from "./ui/search"; +import { initSearch } from "./ui/search"; import pkg from "../package.json"; function welcomeText(lang: string): string { @@ -53,9 +55,9 @@ function titleFromContent(text: string): string { let start = 0; while (start < text.length) { let end = text.indexOf("\n", start); - if (end === -1) end = text.length; + if (end === -1) {end = text.length;} const s = text.slice(start, end).trim(); - if (s.length > 0) return s.replace(/^#\s*/, "").slice(0, 30); + if (s.length > 0) {return s.replace(/^#\s*/, "").slice(0, 30);} start = end + 1; } return t("untitled"); @@ -63,12 +65,12 @@ function titleFromContent(text: string): string { function syncTitleField(): void { const field = $("#doc-title"); - if (document.activeElement !== field) field.value = activeDoc()?.title ?? ""; + if (document.activeElement !== field) {field.value = activeDoc()?.title ?? "";} } function pinDoc(id: string): void { const doc = data.docs.find((d) => d.id === id); - if (!doc) return; + if (!doc) {return;} doc.pinned = !doc.pinned; if (doc.pinned) { // move to end of pinned group @@ -88,10 +90,10 @@ function pinDoc(id: string): void { /** drag id -> drop-target id; no-op across the pinned/unpinned boundary */ function reorderDoc(srcId: string, targetId: string): void { - if (srcId === targetId) return; + if (srcId === targetId) {return;} const src = data.docs.find((d) => d.id === srcId); const target = data.docs.find((d) => d.id === targetId); - if (!src || !target || !!src.pinned !== !!target.pinned) return; + if (!src || !target || !!src.pinned !== !!target.pinned) {return;} data.docs = data.docs.filter((d) => d.id !== srcId); const targetIdx = data.docs.findIndex((d) => d.id === targetId); data.docs.splice(targetIdx, 0, src); @@ -108,12 +110,12 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement, d let dragging = false; let suppressClick = false; el.addEventListener("mousedown", (e) => { - if ((e.target as HTMLElement).closest("button")) return; + if ((e.target as HTMLElement).closest("button")) {return;} const startY = e.clientY; let cachedItems: { el: HTMLElement, top: number, bottom: number }[] | null = null; const onMove = (ev: MouseEvent) => { if (!dragging) { - if (Math.abs(ev.clientY - startY) < 4) return; + if (Math.abs(ev.clientY - startY) < 4) {return;} dragging = true; suppressClick = true; el.classList.add("dragging"); @@ -137,7 +139,7 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement, d } if (over) { const overDoc = data.docs.find((d) => d.id === over.dataset.docId); - if (overDoc && !!overDoc.pinned === !!doc.pinned) over.classList.add("drag-over"); + if (overDoc && !!overDoc.pinned === !!doc.pinned) {over.classList.add("drag-over");} } }; const onScroll = () => { cachedItems = null; }; @@ -148,9 +150,9 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement, d cachedItems = null; if (dragging) { const target = list.querySelector(".doc-item.drag-over"); - for (const item of list.querySelectorAll(".doc-item")) item.classList.remove("drag-over", "dragging"); + for (const item of list.querySelectorAll(".doc-item")) {item.classList.remove("drag-over", "dragging");} document.body.style.cursor = ""; - if (target?.dataset.docId) reorderDoc(doc.id, target.dataset.docId); + if (target?.dataset.docId) {reorderDoc(doc.id, target.dataset.docId);} } dragging = false; }; @@ -181,7 +183,7 @@ function createDeleteButton(doc: DocMeta, appData: AppData, appSettings: Setting let confirmTimer: ReturnType | null = null; del.addEventListener("click", (e) => { e.stopPropagation(); - if (appData.docs.length === 1) return; + if (appData.docs.length === 1) {return;} if (!del.classList.contains("confirm")) { del.classList.add("confirm"); del.textContent = "✓"; @@ -191,12 +193,12 @@ function createDeleteButton(doc: DocMeta, appData: AppData, appSettings: Setting }, 2000); return; } - if (confirmTimer) clearTimeout(confirmTimer); + if (confirmTimer) {clearTimeout(confirmTimer);} void backupDeletedSheet(appSettings.dataDir, doc.title, appData.contents[doc.id] ?? ""); delete appData.contents[doc.id]; appData.docs = appData.docs.filter((d) => d.id !== doc.id); - if (appData.activeId === doc.id) switchDoc(appData.docs[0].id); - else renderDocList(); + if (appData.activeId === doc.id) {switchDoc(appData.docs[0].id);} + else {renderDocList();} saveAppData(appData); }); return del; @@ -244,13 +246,13 @@ function newDoc(content = ""): void { } async function closeActiveDoc(): Promise { - if (data.docs.length <= 1) return; + if (data.docs.length <= 1) {return;} const doc = data.docs.find((d) => d.id === data.activeId); - if (!doc) return; + if (!doc) {return;} const content = data.contents[doc.id] ?? ""; if (content.trim()) { const ans = await askModal(t("closeSheet"), t("close"), t("cancel")); - if (ans !== "a") return; + if (ans !== "a") {return;} } const idx = data.docs.findIndex((d) => d.id === doc.id); void backupDeletedSheet(settings.dataDir, doc.title, content); @@ -328,7 +330,7 @@ function bindFormattingSettings(): void { resultsWidth.addEventListener("input", save); // live while sliding matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { - if (settings.theme === "system") applySettings(); + if (settings.theme === "system") {applySettings();} }); } @@ -361,9 +363,9 @@ function bindHotkeySettings(): void { hotkey.value = mods.length ? mods.join("+") + "+…" : ""; return; } - if (mods.length === 0) return; // a bare key is not a global hotkey + if (mods.length === 0) {return;} // a bare key is not a global hotkey const keyName = normalizeKeyName(e); - if (!keyName) return; + if (!keyName) {return;} const combo = [...mods, keyName].join("+"); const old = settings.hotkey; if (await registerHotkey(old, combo)) { @@ -423,11 +425,11 @@ function bindDataDirSettings(): void { renderDataDir(); dataDirBtn.addEventListener("click", async () => { const picked = await chooseFolder(); - if (!picked || picked === settings.dataDir) return; + if (!picked || picked === settings.dataDir) {return;} let strategy: "move" | "overwrite" | "use_existing" = "move"; if (await dataDirHasDocuments(picked)) { const ans = await askModal(t("folderConflict"), t("useExisting"), t("replaceMine")); - if (ans === null) return; + if (ans === null) {return;} strategy = ans === "a" ? "use_existing" : "overwrite"; } try { @@ -445,7 +447,7 @@ function bindDataDirSettings(): void { const fresh = await loadAppData(picked); if (fresh && fresh.docs.length > 0) { data = fresh; - if (!data.docs.some((d) => d.id === data.activeId)) data.activeId = data.docs[0].id; + if (!data.docs.some((d) => d.id === data.activeId)) {data.activeId = data.docs[0].id;} switchDoc(data.activeId); } } @@ -458,8 +460,8 @@ function bindSettingsNavigation(): void { const tabPanels = document.querySelectorAll(".settings-tabpanel"); for (const tab of tabs) { tab.addEventListener("click", () => { - for (const other of tabs) other.classList.toggle("active", other === tab); - for (const panel of tabPanels) panel.classList.toggle("hidden", panel.dataset.tabpanel !== tab.dataset.tab); + for (const other of tabs) {other.classList.toggle("active", other === tab);} + for (const panel of tabPanels) {panel.classList.toggle("hidden", panel.dataset.tabpanel !== tab.dataset.tab);} }); } @@ -495,10 +497,10 @@ async function showAppVersion(): Promise { /** Physical key (e.code) → Tauri accelerator name, layout-independent (works on ru). */ function normalizeKeyName(e: KeyboardEvent): string | null { const code = e.code; - if (/^Key[A-Z]$/.test(code)) return code.slice(3); - if (/^Digit\d$/.test(code)) return code.slice(5); - if (/^Numpad\d$/.test(code)) return code; - if (/^F\d{1,2}$/.test(code)) return code; + if (/^Key[A-Z]$/.test(code)) {return code.slice(3);} + if (/^Digit\d$/.test(code)) {return code.slice(5);} + if (/^Numpad\d$/.test(code)) {return code;} + if (/^F\d{1,2}$/.test(code)) {return code;} const map: Record = { Space: "Space", Enter: "Enter", Backspace: "Backspace", Delete: "Delete", Tab: "Tab", Home: "Home", End: "End", PageUp: "PageUp", PageDown: "PageDown", @@ -512,12 +514,12 @@ function normalizeKeyName(e: KeyboardEvent): string | null { // ---------- tauri integration async function registerHotkey(old: string | null, combo: string): Promise { - if (!isTauri()) return true; + if (!isTauri()) {return true;} try { const gs = await import("@tauri-apps/plugin-global-shortcut"); - if (old) await gs.unregister(old).catch(() => {}); + if (old) {await gs.unregister(old).catch(() => {});} await gs.register(combo, async (e) => { - if (e.state !== "Pressed") return; + if (e.state !== "Pressed") {return;} const { getCurrentWindow } = await import("@tauri-apps/api/window"); const win = getCurrentWindow(); if (await win.isVisible()) { @@ -540,18 +542,18 @@ async function registerHotkey(old: string | null, combo: string): Promise { - if (!isTauri()) return; + if (!isTauri()) {return;} try { const auto = await import("@tauri-apps/plugin-autostart"); - if (enabled) await auto.enable(); - else await auto.disable(); + if (enabled) {await auto.enable();} + else {await auto.disable();} } catch (e) { console.warn("autostart failed", e); } } async function applyAlwaysOnTop(enabled: boolean): Promise { - if (!isTauri()) return; + if (!isTauri()) {return;} const { getCurrentWindow } = await import("@tauri-apps/api/window"); await getCurrentWindow().setAlwaysOnTop(enabled).catch((e) => console.warn("always-on-top failed", e)); } @@ -569,7 +571,7 @@ async function refreshRates(force = false): Promise { workspace.invalidateAll(); editor.refresh(); ratesFetchedAt = payload.fetchedAt; - if (force) toast(t("ratesUpdated")); + if (force) {toast(t("ratesUpdated"));} } renderRatesInfo(); } @@ -585,14 +587,14 @@ let marketFetchedAt = 0; function applyAllRates(): void { const marketRates: Record = {}; for (const [sym, price] of Object.entries(marketPrices)) { - if (price > 0) marketRates[sym] = 1 / price; + if (price > 0) {marketRates[sym] = 1 / price;} } engine.setRates({ ...liveRates, ...marketRates }); } async function refreshMarket(force = false): Promise { - if (!isTauri()) return; - const info = $("#market-info") as HTMLElement; + if (!isTauri()) {return;} + const info = $("#market-info"); info.style.display = ""; info.classList.add("spin"); info.textContent = t("market"); @@ -604,7 +606,7 @@ async function refreshMarket(force = false): Promise { applyAllRates(); workspace.invalidateAll(); editor.refresh(); - if (force) toast(t("marketUpdated")); + if (force) {toast(t("marketUpdated"));} } renderMarketInfo(); } @@ -615,7 +617,7 @@ const HIST_DATE_RE = /(?:on|на)\s+(\d{4}-\d{2}-\d{2})/gi; async function fetchNeededHistoricalRates(text: string): Promise { const dates = new Set(); - for (const m of text.matchAll(HIST_DATE_RE)) dates.add(m[1]!); + for (const m of text.matchAll(HIST_DATE_RE)) {dates.add(m[1]);} const datesToFetch = Array.from(dates).filter((date) => !engine.hasHistoricalRates(date)); let fetched = false; if (datesToFetch.length > 0) { @@ -632,7 +634,7 @@ async function fetchNeededHistoricalRates(text: string): Promise { } function renderMarketInfo(): void { - const el = $("#market-info") as HTMLElement; + const el = $("#market-info"); if (marketFetchedAt === 0) { el.style.display = "none"; return; } el.style.display = ""; el.textContent = `${t("market")}: ${agoText(marketFetchedAt)} ↺`; @@ -667,9 +669,9 @@ function renderTotal(): void { function agoText(unixSec: number): string { const s = Math.max(0, Math.floor(Date.now() / 1000) - unixSec); - if (s < 90) return t("justNow"); - if (s < 3600) return t("minAgo").replace("{}", String(Math.round(s / 60))); - if (s < 86400) return t("hourAgo").replace("{}", String(Math.round(s / 3600))); + if (s < 90) {return t("justNow");} + if (s < 3600) {return t("minAgo").replace("{}", String(Math.round(s / 60)));} + if (s < 86400) {return t("hourAgo").replace("{}", String(Math.round(s / 3600)));} return t("dayAgo").replace("{}", String(Math.round(s / 86400))); } @@ -718,7 +720,7 @@ function showModal(msg: string, aLabel: string, bLabel: string): Promise<"a" | " // a click that just refocused the window (native dialog closing, or // bringing a backgrounded tray window to front) lands on the backdrop // too — ignore a backdrop dismiss that arrives right after refocus - if (e.target === modal && performance.now() - lastWindowFocusAt > 300) done(null); + if (e.target === modal && performance.now() - lastWindowFocusAt > 300) {done(null);} }; }); } @@ -730,7 +732,7 @@ function toast(msg: string): void { const el = $("#toast"); el.textContent = msg; el.classList.remove("hidden"); - if (toastTimer) clearTimeout(toastTimer); + if (toastTimer) {clearTimeout(toastTimer);} toastTimer = setTimeout(() => el.classList.add("hidden"), 1200); } @@ -764,7 +766,7 @@ async function initDataAndEngine(): Promise { const id = uid(); data = { docs: [{ id, title: "Sample" }], activeId: id, contents: { [id]: welcomeText(settings.language) } }; } - if (!data.docs.some((d) => d.id === data.activeId)) data.activeId = data.docs[0].id; + if (!data.docs.some((d) => d.id === data.activeId)) {data.activeId = data.docs[0].id;} const scripts = await loadExtensionScripts(); await runExtensions(engine, scripts); @@ -816,7 +818,7 @@ async function initDataAndEngine(): Promise { docs: () => data.docs.map((d) => ({ id: d.id, title: d.title, text: data.contents[d.id] ?? "" })), t, onOpen: (docId, line) => { - if (docId !== data.activeId) switchDoc(docId); + if (docId !== data.activeId) {switchDoc(docId);} editor.goToLine(line); }, }); @@ -832,7 +834,7 @@ function bindTitleUI(): void { doc.title = v.slice(0, 60); doc.customTitle = true; const rewrites = workspace.renameSheet(doc.id, oldTitle, doc.title); - for (const r of rewrites) data.contents[r.id] = r.text; + for (const r of rewrites) {data.contents[r.id] = r.text;} workspace.invalidateAll(); } else { doc.customTitle = false; @@ -844,7 +846,7 @@ function bindTitleUI(): void { saveAppData(data); }); titleField.addEventListener("keydown", (e) => { - if (e.key === "Enter" || e.key === "Escape") titleField.blur(); + if (e.key === "Enter" || e.key === "Escape") {titleField.blur();} }); } @@ -887,8 +889,8 @@ function bindSidebarUI(): void { if (!panel.classList.contains("hidden") && !target.closest("#settings-panel") && !target.closest("#open-settings")) { panel.classList.add("hidden"); } - if (!settings.sidebarVisible) return; - if (target.closest("#sidebar") || target.closest("#toggle-sidebar")) return; + if (!settings.sidebarVisible) {return;} + if (target.closest("#sidebar") || target.closest("#toggle-sidebar")) {return;} settings.sidebarVisible = false; $("#sidebar").classList.add("hidden"); void saveSettings(settings); @@ -957,7 +959,7 @@ function bindExportUI(): void { document.addEventListener("click", () => exportMenu.classList.add("hidden")); exportMenu.addEventListener("click", async (e) => { const btn = (e.target as HTMLElement).closest("[data-action]"); - if (!btn) return; + if (!btn) {return;} exportMenu.classList.add("hidden"); const action = btn.dataset.action; if (action === "image") { @@ -999,9 +1001,9 @@ function bindRatesAndMarketUI(): void { async function bindLifecycle(): Promise { void onFileDrop((content) => newDoc(content)); - if (!(await registerHotkey(null, settings.hotkey))) toast(t("hotkeyFailed")); - if (settings.autostart) await applyAutostart(true); - if (settings.alwaysOnTop) await applyAlwaysOnTop(true); + if (!(await registerHotkey(null, settings.hotkey))) {toast(t("hotkeyFailed"));} + if (settings.autostart) {await applyAutostart(true);} + if (settings.alwaysOnTop) {await applyAlwaysOnTop(true);} void onAppQuit(() => flushAppData()); void refreshRates(); setInterval(() => void refreshRates(), 60 * 60 * 1000); @@ -1009,7 +1011,7 @@ async function bindLifecycle(): Promise { setInterval(() => void refreshMarket(), 15 * 60 * 1000); const launched = await getLaunchFile(); - if (launched) newDoc(launched); + if (launched) {newDoc(launched);} void onOpenFile((content) => newDoc(content)); editor.focus(); @@ -1023,12 +1025,12 @@ async function bindLifecycle(): Promise { } } - if (settings.autoUpdateEnabled) void checkUpdate(); + if (settings.autoUpdateEnabled) {void checkUpdate();} // a tray app can stay running for weeks without a cold restart, and the // update check above only ever runs once at boot — recheck periodically // so a long-lived process still notices new releases; re-read the setting // on every tick since the user can flip it while the app is running - setInterval(() => { if (settings.autoUpdateEnabled) void checkUpdate(); }, 6 * 60 * 60 * 1000); + setInterval(() => { if (settings.autoUpdateEnabled) {void checkUpdate();} }, 6 * 60 * 60 * 1000); } async function initUI(): Promise { @@ -1059,15 +1061,15 @@ async function boot(): Promise { async function checkUpdate(manual = false): Promise { const result = await checkForUpdate(); if (result === "error") { - if (manual) toast(t("updateCheckFailed")); + if (manual) {toast(t("updateCheckFailed"));} return; } if (!result) { - if (manual) toast(t("upToDate")); + if (manual) {toast(t("upToDate"));} return; } const choice = await askModal(t("updateAvailable").replace("{}", result.version), t("updateInstall"), t("updateLater")); - if (choice !== "a") return; + if (choice !== "a") {return;} toast(t("updateInstalling")); try { await result.install(); diff --git a/src/storage.ts b/src/storage.ts index 9544485..7e7cd6b 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -77,7 +77,7 @@ export async function loadSettings(): Promise { const raw = isTauri() ? await invoke("load_file", { name: "settings.json" }) : localStorage.getItem("summarum.settings"); - if (raw) return { ...defaultSettingsData, ...JSON.parse(raw) }; + if (raw) {return { ...defaultSettingsData, ...JSON.parse(raw) };} } catch (e) { console.warn("loadSettings failed", e); } @@ -86,8 +86,8 @@ export async function loadSettings(): Promise { export async function saveSettings(s: SettingsData): Promise { const raw = JSON.stringify(s, null, 2); - if (isTauri()) await invoke("save_file", { name: "settings.json", contents: raw }); - else localStorage.setItem("summarum.settings", raw); + if (isTauri()) {await invoke("save_file", { name: "settings.json", contents: raw });} + else {localStorage.setItem("summarum.settings", raw);} } // ---------- documents @@ -97,7 +97,7 @@ export async function loadAppData(dataDir: string): Promise { const raw = isTauri() ? await invoke("load_documents", { dir: dirArg(dataDir) }) : localStorage.getItem("summarum.documents"); - if (raw) return JSON.parse(raw); + if (raw) {return JSON.parse(raw);} } catch (e) { console.warn("loadAppData failed", e); } @@ -115,7 +115,7 @@ export function setDataDir(dataDir: string): void { export function saveAppData(data: AppData): void { // debounce: autosave on every keystroke without disk churn pendingData = data; - if (saveTimer) clearTimeout(saveTimer); + if (saveTimer) {clearTimeout(saveTimer);} saveTimer = setTimeout(() => void flushAppData(), 400); } @@ -125,12 +125,12 @@ export async function flushAppData(): Promise { clearTimeout(saveTimer); saveTimer = null; } - if (!pendingData) return; + if (!pendingData) {return;} const raw = JSON.stringify(pendingData); pendingData = null; try { - if (isTauri()) await invoke("save_documents", { dir: dirArg(currentDataDir), contents: raw }); - else localStorage.setItem("summarum.documents", raw); + if (isTauri()) {await invoke("save_documents", { dir: dirArg(currentDataDir), contents: raw });} + else {localStorage.setItem("summarum.documents", raw);} } catch (e) { console.warn("saveAppData failed", e); } @@ -138,7 +138,7 @@ export async function flushAppData(): Promise { /** quit initiated from the tray: flush unsaved edits, then exit for real */ export async function onAppQuit(cb: () => Promise): Promise { - if (!isTauri()) return; + if (!isTauri()) {return;} const { listen } = await import("@tauri-apps/api/event"); await listen("app-quit", () => void cb().finally(() => void invoke("exit_app"))); } @@ -146,7 +146,7 @@ export async function onAppQuit(cb: () => Promise): Promise { // ---------- backups & data folder export async function runBackups(dataDir: string, retentionDays: number): Promise { - if (!isTauri()) return; + if (!isTauri()) {return;} try { await invoke("run_backups", { dir: dirArg(dataDir), retentionDays }); } catch (e) { @@ -155,7 +155,7 @@ export async function runBackups(dataDir: string, retentionDays: number): Promis } export async function backupDeletedSheet(dataDir: string, title: string, contents: string): Promise { - if (!isTauri() || !contents.trim()) return; + if (!isTauri() || !contents.trim()) {return;} try { await invoke("backup_deleted_sheet", { dir: dirArg(dataDir), title, contents }); } catch (e) { @@ -164,24 +164,24 @@ export async function backupDeletedSheet(dataDir: string, title: string, content } export async function openBackupsFolder(dataDir: string): Promise { - if (isTauri()) await invoke("open_backups_folder", { dir: dirArg(dataDir) }); + if (isTauri()) {await invoke("open_backups_folder", { dir: dirArg(dataDir) });} } /** Rust-side picker: migrateDataDir only accepts a folder chosen through this. */ export async function chooseFolder(): Promise { - if (!isTauri()) return null; + if (!isTauri()) {return null;} return (await invoke("pick_data_dir")) ?? null; } export async function dataDirHasDocuments(dir: string): Promise { - if (!isTauri()) return false; + if (!isTauri()) {return false;} return invoke("data_dir_has_documents", { dir }); } export type MigrateStrategy = "move" | "overwrite" | "use_existing"; export async function migrateDataDir(oldDir: string, newDir: string, strategy: MigrateStrategy): Promise { - if (!isTauri()) return; + if (!isTauri()) {return;} await invoke("migrate_data_dir", { oldDir: dirArg(oldDir), newDir, strategy }); } @@ -213,7 +213,7 @@ export async function fetchRates(force = false): Promise { // ---------- historical rates export async function fetchHistoricalRatesBatch(dates: string[]): Promise>> { - if (dates.length === 0) return {}; + if (dates.length === 0) {return {};} try { if (isTauri()) { return await invoke>>("fetch_historical_rates_batch", { dates }); @@ -222,7 +222,7 @@ export async function fetchHistoricalRatesBatch(dates: string[]): Promise { const rates = await fetchHistoricalRates(date); - if (rates) results[date] = rates; + if (rates) {results[date] = rates;} }) ); return results; @@ -239,10 +239,10 @@ export async function fetchHistoricalRates(date: string): Promise = { USD: 1 }; for (const [k, v] of Object.entries(json.rates as Record)) { - if (v > 0) out[k.toUpperCase()] = v; + if (v > 0) {out[k.toUpperCase()] = v;} } return out; } catch (e) { @@ -254,14 +254,14 @@ export async function fetchHistoricalRates(date: string): Promise { - if (!isTauri()) return false; + if (!isTauri()) {return false;} return invoke("write_image_file", { dataBase64 }); } // ---------- market data export async function fetchMarketData(symbols: string[]): Promise> { - if (!isTauri() || symbols.length === 0) return {}; + if (!isTauri() || symbols.length === 0) {return {};} try { return await invoke>("fetch_market_data", { symbols }); } catch (e) { @@ -273,7 +273,7 @@ export async function fetchMarketData(symbols: string[]): Promise { - if (!isTauri()) return []; + if (!isTauri()) {return [];} try { return await invoke<{ name: string; code: string }[]>("load_extensions"); } catch (e) { @@ -283,13 +283,13 @@ export async function loadExtensionScripts(): Promise<{ name: string; code: stri } export async function openExtensionsFolder(): Promise { - if (isTauri()) await invoke("open_extensions_folder"); + if (isTauri()) {await invoke("open_extensions_folder");} } // ---------- .numi file association export async function getLaunchFile(): Promise { - if (!isTauri()) return null; + if (!isTauri()) {return null;} try { return await invoke("get_launch_file"); } catch { @@ -298,7 +298,7 @@ export async function getLaunchFile(): Promise { } export async function onOpenFile(cb: (content: string) => void): Promise { - if (!isTauri()) return; + if (!isTauri()) {return;} const { listen } = await import("@tauri-apps/api/event"); await listen("open-file", (e) => cb(e.payload)); } @@ -308,7 +308,7 @@ export async function onFileDrop(cb: (content: string) => void): Promise { if (isTauri()) { const { getCurrentWebview } = await import("@tauri-apps/api/webview"); await getCurrentWebview().onDragDropEvent(async (event) => { - if (event.payload.type !== "drop") return; + if (event.payload.type !== "drop") {return;} for (const path of event.payload.paths) { try { const content = await invoke("read_text_file", { path }); @@ -324,7 +324,7 @@ export async function onFileDrop(cb: (content: string) => void): Promise { window.addEventListener("drop", async (e) => { e.preventDefault(); for (const f of e.dataTransfer?.files ?? []) { - if (/\.(numi|txt|md)$/i.test(f.name)) cb(await f.text()); + if (/\.(numi|txt|md)$/i.test(f.name)) {cb(await f.text());} } }); } diff --git a/src/ui/editor.ts b/src/ui/editor.ts index 44cf173..618e366 100644 --- a/src/ui/editor.ts +++ b/src/ui/editor.ts @@ -1,10 +1,12 @@ // CodeMirror editor wired to the engine: evaluates on change, highlights // engine tokens, renders the results overlay, autocompletes phrases. -import { EditorView, ViewPlugin, ViewUpdate, Decoration, DecorationSet, keymap, drawSelection } from "@codemirror/view"; +import type { ViewUpdate, DecorationSet} from "@codemirror/view"; +import { EditorView, ViewPlugin, Decoration, keymap, drawSelection } from "@codemirror/view"; import { EditorState, StateEffect, StateField, RangeSetBuilder } from "@codemirror/state"; import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; -import { autocompletion, CompletionContext, CompletionResult, acceptCompletion } from "@codemirror/autocomplete"; -import { SumEngine, LineResult } from "../engine"; +import type { CompletionContext, CompletionResult} from "@codemirror/autocomplete"; +import { autocompletion, acceptCompletion } from "@codemirror/autocomplete"; +import type { SumEngine, LineResult } from "../engine"; import { buildSparkline } from "./sparkline"; export interface EditorCallbacks { @@ -20,7 +22,7 @@ const setResults = StateEffect.define(); const resultsField = StateField.define({ create: () => [], update(value, tr) { - for (const e of tr.effects) if (e.is(setResults)) value = e.value; + for (const e of tr.effects) {if (e.is(setResults)) {value = e.value;}} return value; }, }); @@ -56,7 +58,7 @@ function buildDecorations(state: EditorState): DecorationSet { marks.sort((a, b) => a.from - b.from); let last = line.from; for (const m of marks) { - if (m.from < last) continue; // overlapping (comment vs tokens) — first wins + if (m.from < last) {continue;} // overlapping (comment vs tokens) — first wins builder.add(m.from, m.to, Decoration.mark({ class: m.cls })); last = m.to; } @@ -68,8 +70,8 @@ const highlightField = StateField.define({ create: buildDecorations, update(value, tr) { const hasResults = tr.effects.some((e) => e.is(setResults)); - if (hasResults) return buildDecorations(tr.state); - if (tr.docChanged) return value.map(tr.changes); + if (hasResults) {return buildDecorations(tr.state);} + if (tr.docChanged) {return value.map(tr.changes);} return value; }, provide: (f) => EditorView.decorations.from(f), @@ -79,20 +81,20 @@ const highlightField = StateField.define({ function buildVarHighlight(state: EditorState): DecorationSet { const builder = new RangeSetBuilder(); const sel = state.selection.main; - if (!sel.empty) return builder.finish(); + if (!sel.empty) {return builder.finish();} const text = state.doc.toString(); // expand a word around the cursor const isWordCh = (ch: string) => /[\p{L}\d_]/u.test(ch); let a = sel.head; let b = sel.head; - while (a > 0 && isWordCh(text[a - 1])) a--; - while (b < text.length && isWordCh(text[b])) b++; - if (a === b) return builder.finish(); + while (a > 0 && isWordCh(text[a - 1])) {a--;} + while (b < text.length && isWordCh(text[b])) {b++;} + if (a === b) {return builder.finish();} const word = text.slice(a, b); - if (!/^[\p{L}_][\p{L}\d_]*$/u.test(word)) return builder.finish(); + if (!/^[\p{L}_][\p{L}\d_]*$/u.test(word)) {return builder.finish();} // only highlight if the word is actually assigned somewhere in the document const esc = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - if (!new RegExp(`^\\s*${esc}\\s*=`, "mu").test(text)) return builder.finish(); + if (!new RegExp(`^\\s*${esc}\\s*=`, "mu").test(text)) {return builder.finish();} const re = new RegExp(`(?({ create: () => Decoration.none, update(value, tr) { for (const e of tr.effects) { - if (e.is(setVarHighlight)) return e.value; + if (e.is(setVarHighlight)) {return e.value;} } - if (tr.docChanged) return value.map(tr.changes); + if (tr.docChanged) {return value.map(tr.changes);} return value; }, provide: (f) => EditorView.decorations.from(f), @@ -129,7 +131,7 @@ const varHighlightPlugin = ViewPlugin.fromClass( } schedule(view: EditorView) { - if (this.timeout) clearTimeout(this.timeout); + if (this.timeout) {clearTimeout(this.timeout);} this.timeout = setTimeout(() => { const decos = buildVarHighlight(view.state); view.dispatch({ effects: setVarHighlight.of(decos) }); @@ -137,7 +139,7 @@ const varHighlightPlugin = ViewPlugin.fromClass( } destroy() { - if (this.timeout) clearTimeout(this.timeout); + if (this.timeout) {clearTimeout(this.timeout);} } } ); @@ -160,8 +162,8 @@ export class SumEditor { let engineOptions: { label: string; type: string; detail?: string }[] | null = null; const completionSource = (ctx: CompletionContext): CompletionResult | null => { const word = ctx.matchBefore(/[\p{L}_]+$/u); - if (!word || (word.from === word.to && !ctx.explicit)) return null; - if (word.to - word.from < 2 && !ctx.explicit) return null; + if (!word || (word.from === word.to && !ctx.explicit)) {return null;} + if (word.to - word.from < 2 && !ctx.explicit) {return null;} // the engine's phrase list is fixed after boot — map it once engineOptions ??= this.engine.completions().map((c) => ({ label: c.label, @@ -243,7 +245,7 @@ export class SumEditor { const lineTo = doc.lineAt(sel.to); const multiLine = lineFrom.number !== lineTo.number; const coversLine = sel.from <= lineFrom.from && sel.to >= lineTo.to && !sel.empty; - if (!sel.empty && !multiLine && !coversLine) return; + if (!sel.empty && !multiLine && !coversLine) {return;} const lines: string[] = []; for (let n = lineFrom.number; n <= lineTo.number; n++) { const line = doc.line(n); @@ -255,8 +257,8 @@ export class SumEditor { }); this.resultsEl.addEventListener("click", (e) => { - const el = (e.target as HTMLElement).closest(".result-line") as HTMLElement | null; - if (el?.dataset.value) this.cb.onCopy(el.dataset.value); + const el = (e.target as HTMLElement).closest(".result-line"); + if (el?.dataset.value) {this.cb.onCopy(el.dataset.value);} }); } @@ -318,19 +320,19 @@ export class SumEditor { const r = this.results[i]; const isChart = r.value?.kind === "chart"; const isError = !!r.error; - if (!r.text && !isChart && !isError) continue; + if (!r.text && !isChart && !isError) {continue;} const line = doc.line(i + 1); const block = this.view.lineBlockAt(line.from); const top = docTop + block.top - overlayTop; - if (top < -40 || top > this.resultsEl.clientHeight + 40) continue; + if (top < -40 || top > this.resultsEl.clientHeight + 40) {continue;} if (isChart && r.value?.kind === "chart") { frag.appendChild(buildSparkline(r.value.points.map((p) => p.toNumber()), top)); } else { const el = document.createElement("div"); el.className = "result-line" + (isError ? " result-error" : ""); el.textContent = isError ? "#ref?" : r.text; - if (!isError) el.dataset.value = r.text!; - if (isError) el.title = r.error!; + if (!isError) {el.dataset.value = r.text!;} + if (isError) {el.title = r.error!;} el.style.top = `${top}px`; frag.appendChild(el); } diff --git a/src/ui/search.ts b/src/ui/search.ts index 7b332a0..3cd08a9 100644 --- a/src/ui/search.ts +++ b/src/ui/search.ts @@ -1,7 +1,7 @@ // Full-text and numeric ("> 100", "~ 50") search across every open sheet. // Owns the #search-overlay DOM and its own input debounce. -import { SumEngine, Value } from "../engine"; -import { Workspace } from "../workspace"; +import type { SumEngine, Value } from "../engine"; +import type { Workspace } from "../workspace"; export interface SearchHit { docId: string; @@ -34,15 +34,15 @@ const $ = (sel: string): T => document.querySelector(sel) function parseResultQuery(engine: SumEngine, q: string): { op: string; threshold: Value } | null { const m = /^(>=|<=|>|<|=|~)\s*(.+)$/.exec(q.trim()); - if (!m) return null; + if (!m) {return null;} const v = engine.evaluateExpression(m[2].trim()); - if (!v || v.kind !== "quantity") return null; + if (!v || v.kind !== "quantity") {return null;} return { op: m[1], threshold: v }; } function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] { const q = query.trim(); - if (!q) return []; + if (!q) {return [];} const docs = deps.docs(); const rq = parseResultQuery(deps.engine, q); if (rq && rq.threshold.kind === "quantity") { @@ -53,7 +53,7 @@ function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] { const lines = doc.text.split("\n"); for (let i = 0; i < results.length; i++) { const r = results[i]; - if (!r.value || r.value.kind !== "quantity") continue; + if (!r.value || r.value.kind !== "quantity") {continue;} const v = r.value.value; let match = false; switch (rq.op) { @@ -66,7 +66,7 @@ function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] { } if (match) { hits.push({ docId: doc.id, docTitle: doc.title, line: i + 1, text: lines[i] ?? "", result: r.text ?? undefined }); - if (hits.length >= 200) return hits; + if (hits.length >= 200) {return hits;} } } } @@ -79,7 +79,7 @@ function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] { for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(ql)) { hits.push({ docId: doc.id, docTitle: doc.title, line: i + 1, text: lines[i] }); - if (hits.length >= 200) return hits; + if (hits.length >= 200) {return hits;} } } } @@ -128,7 +128,7 @@ export function initSearch(deps: SearchDeps): SearchController { // doesn't recompute the whole workspace on every keystroke let searchTimer: ReturnType | null = null; input.addEventListener("input", () => { - if (searchTimer) clearTimeout(searchTimer); + if (searchTimer) {clearTimeout(searchTimer);} const q = input.value; searchTimer = setTimeout(() => render(q, searchAllSheets(deps, q)), 150); }); @@ -140,7 +140,7 @@ export function initSearch(deps: SearchDeps): SearchController { } }); overlay.addEventListener("mousedown", (e) => { - if (e.target === overlay) close(); + if (e.target === overlay) {close();} }); return { diff --git a/src/ui/sparkline.ts b/src/ui/sparkline.ts index c410158..37d6a72 100644 --- a/src/ui/sparkline.ts +++ b/src/ui/sparkline.ts @@ -1,7 +1,7 @@ export function buildSparkline(pts: number[], top: number): SVGSVGElement { const NS = "http://www.w3.org/2000/svg"; const W = 80, H = 20, pad = 2; - const svg = document.createElementNS(NS, "svg") as SVGSVGElement; + const svg = document.createElementNS(NS, "svg"); svg.setAttribute("width", String(W)); svg.setAttribute("height", String(H)); svg.setAttribute("viewBox", `0 0 ${W} ${H}`); diff --git a/src/updater.ts b/src/updater.ts index 292859e..fc0a087 100644 --- a/src/updater.ts +++ b/src/updater.ts @@ -11,7 +11,7 @@ interface AvailableUpdate { * (`vite dev`) also reports null. */ export async function checkForUpdate(): Promise { - if (!isTauri()) return null; + if (!isTauri()) {return null;} try { const { check } = await import("@tauri-apps/plugin-updater"); const update = await check(); diff --git a/src/workspace.test.ts b/src/workspace.test.ts index 97905b5..6cd9eca 100644 --- a/src/workspace.test.ts +++ b/src/workspace.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from "vitest"; import { SumEngine } from "./engine"; -import { Workspace, SheetSource } from "./workspace"; +import type { SheetSource } from "./workspace"; +import { Workspace } from "./workspace"; function makeWorkspace(initial: Record) { const engine = new SumEngine(); diff --git a/src/workspace.ts b/src/workspace.ts index c6bb6a1..cf5d19b 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -58,7 +58,7 @@ export class Workspace { while (changed) { changed = false; for (const s of this.sheets()) { - if (dirty.has(s.id)) continue; + if (dirty.has(s.id)) {continue;} for (const title of this.referencedTitles(s.text, s.id)) { const target = this.findSheetByTitle(title); if (target && dirty.has(target.id)) { @@ -69,7 +69,7 @@ export class Workspace { } } } - for (const id of dirty) this.cache.delete(id); + for (const id of dirty) {this.cache.delete(id);} } invalidateAll(): void { @@ -90,9 +90,9 @@ export class Workspace { const replacement = /^[\p{L}_][\p{L}\d_]*$/u.test(trimmedNew) ? `@${trimmedNew}.` : `@[${trimmedNew}].`; const out: { id: string; text: string }[] = []; for (const s of this.sheets()) { - if (s.id === renamedId) continue; + if (s.id === renamedId) {continue;} const rewritten = s.text.replace(bareRe, replacement).replace(bracketRe, replacement); - if (rewritten !== s.text) out.push({ id: s.id, text: rewritten }); + if (rewritten !== s.text) {out.push({ id: s.id, text: rewritten });} } return out; } @@ -100,10 +100,10 @@ export class Workspace { private referencedTitles(text: string, id?: string): string[] { if (id) { const cached = this.parsedRefsCache.get(id); - if (cached && cached.text === text) return cached.refs; + if (cached && cached.text === text) {return cached.refs;} } const out: string[] = []; - for (const m of text.matchAll(XREF_SCAN_RE)) out.push((m[1] ?? m[2]).trim()); + for (const m of text.matchAll(XREF_SCAN_RE)) {out.push((m[1] ?? m[2]).trim());} if (id) { this.parsedRefsCache.set(id, { text, refs: out }); } @@ -126,7 +126,7 @@ export class Workspace { private resolveXRef(sheetTitle: string, key: string): XRefResolution { const target = this.findSheetByTitle(sheetTitle); - if (!target) return { ok: false, reason: `sheet "${sheetTitle}" not found` }; + if (!target) {return { ok: false, reason: `sheet "${sheetTitle}" not found` };} if (this.resolving.has(target.id)) { return { ok: false, reason: "circular reference" }; } @@ -142,17 +142,17 @@ export class Workspace { : { ok: false, reason: `sheet "${sheetTitle}" has no result` }; } const v = exports.vars.get(key); - if (!v) return { ok: false, reason: `no variable "${key}" in "${sheetTitle}"` }; + if (!v) {return { ok: false, reason: `no variable "${key}" in "${sheetTitle}"` };} return { ok: true, value: v }; } private exportsFor(sheet: SheetSource): SheetExports { const cached = this.cache.get(sheet.id); - if (cached) return cached; + if (cached) {return cached;} const results = this.doEvaluate(sheet.id, sheet.text); const vars = new Map(); for (const r of results) { - if (r.assign && r.value) vars.set(r.assign, r.value); + if (r.assign && r.value) {vars.set(r.assign, r.value);} } let last: Value | null = null; for (let i = results.length - 1; i >= 0; i--) {