├── web ├── public │ └── .gitkeep ├── .prettierignore ├── src │ ├── vite-env.d.ts │ ├── main.tsx │ ├── components │ │ ├── NewTabLink.tsx │ │ └── PendingOutput.tsx │ ├── widget.tsx │ ├── hooks │ │ ├── useHistory.ts │ │ ├── useFend.ts │ │ └── useCurrentInput.ts │ ├── lib │ │ ├── worker.ts │ │ ├── exchange-rates.ts │ │ ├── WaitGroup.ts │ │ └── fend.ts │ ├── index.css │ └── App.tsx ├── tsconfig.json ├── index.html ├── widget.html ├── tsconfig.node.json ├── tsconfig.app.json ├── cloudflare │ └── cf-worker.js ├── build.sh ├── eslint.config.js ├── vite.config.ts └── package.json ├── cli ├── LICENSE.md ├── src │ ├── color.rs │ ├── interrupt.rs │ ├── color │ │ ├── base.rs │ │ ├── output_colors.rs │ │ └── style.rs │ ├── helper.rs │ ├── terminal.rs │ ├── context.rs │ ├── default_config.toml │ ├── args.rs │ ├── file_paths.rs │ ├── custom_units.rs │ └── main.rs ├── build.rs └── Cargo.toml ├── core ├── LICENSE.md ├── src │ ├── result.rs │ ├── format.rs │ ├── interrupt.rs │ ├── num │ │ ├── unit │ │ │ ├── base_unit.rs │ │ │ ├── unit_exponent.rs │ │ │ └── named_unit.rs │ │ ├── exact.rs │ │ ├── formatting_style.rs │ │ └── base.rs │ ├── ident.rs │ ├── json.rs │ ├── date │ │ ├── day.rs │ │ ├── day_of_week.rs │ │ ├── year.rs │ │ ├── parser.rs │ │ └── month.rs │ ├── num.rs │ ├── eval.rs │ ├── scope.rs │ ├── value │ │ └── built_in_function.rs │ ├── inline_substitutions.rs │ └── date.rs ├── Cargo.toml └── README.md ├── .rustfmt.toml ├── icon ├── resources.rc ├── create-resources.ps1 └── icon.svg ├── .git-blame-ignore-revs ├── wasm ├── .gitignore ├── README.md ├── Cargo.toml └── src │ └── lib.rs ├── windows-msix ├── fend-signing-cert.pfx ├── build.ps1 ├── README.md └── AppxManifest.xml ├── windows-wix ├── .config │ └── dotnet-tools.json ├── build.ps1 └── main.wxs ├── documentation ├── build.sh ├── add-header-ids.lua ├── pandoc-metadata.yml ├── chapters │ ├── configuration.md │ └── scripting.md ├── manpage.md ├── include-code-files.lua ├── include-files.lua └── index.md ├── telegram-bot ├── tsconfig.json ├── build.sh ├── esbuild.ts ├── package.json ├── deploy.sh └── index.ts ├── .codecov.yml ├── .gitignore ├── contrib ├── test-aur.sh ├── zsh-helper.zsh ├── check-for-updates.sh └── deploy.sh ├── .cargo └── config.toml ├── Cargo.toml ├── LICENSE.md ├── CONTRIBUTING.md ├── README.md └── CODE_OF_CONDUCT.md /web/public/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cli/LICENSE.md: -------------------------------------------------------------------------------- 1 | ../LICENSE.md -------------------------------------------------------------------------------- /core/LICENSE.md: -------------------------------------------------------------------------------- 1 | ../LICENSE.md -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | hard_tabs = true 2 | -------------------------------------------------------------------------------- /icon/resources.rc: -------------------------------------------------------------------------------- 1 | fend ICON "fend-icon.ico" -------------------------------------------------------------------------------- /web/.prettierignore: -------------------------------------------------------------------------------- 1 | /cloudflare/ 2 | /dist/ 3 | /public/ 4 | -------------------------------------------------------------------------------- /web/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # fix formatting 2 | 24932229e6205b8f88e51d99d846b54e8e5c7028 3 | -------------------------------------------------------------------------------- /wasm/.gitignore: -------------------------------------------------------------------------------- 1 | /pkg/ 2 | /pkgweb/ 3 | /pkg-nodejs/ 4 | /pkg-fend-web/ 5 | /fend-wasm/ 6 | wasm-pack.log 7 | -------------------------------------------------------------------------------- /core/src/result.rs: -------------------------------------------------------------------------------- 1 | use crate::error::FendError; 2 | 3 | pub(crate) type FResult = Result; 4 | -------------------------------------------------------------------------------- /wasm/README.md: -------------------------------------------------------------------------------- 1 | # fend-wasm 2 | 3 | This is the WebAssembly port of [fend](https://github.com/printfn/fend). 4 | -------------------------------------------------------------------------------- /cli/src/color.rs: -------------------------------------------------------------------------------- 1 | mod base; 2 | mod output_colors; 3 | mod style; 4 | 5 | pub use output_colors::OutputColors; 6 | -------------------------------------------------------------------------------- /windows-msix/fend-signing-cert.pfx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/printfn/fend/HEAD/windows-msix/fend-signing-cert.pfx -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] 4 | } 5 | -------------------------------------------------------------------------------- /windows-wix/.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "wix": { 6 | "version": "6.0.2", 7 | "commands": ["wix"] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /documentation/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | cd "$(dirname "$0")" 4 | 5 | pandoc --standalone \ 6 | --output=fend.1 \ 7 | --lua-filter=include-code-files.lua \ 8 | --lua-filter=include-files.lua \ 9 | manpage.md 10 | -------------------------------------------------------------------------------- /telegram-bot/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "skipLibCheck": true, 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "allowSyntheticDefaultImports": true, 7 | "strict": true, 8 | "noEmit": true 9 | }, 10 | "include": ["."] 11 | } 12 | -------------------------------------------------------------------------------- /telegram-bot/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | cd "$(dirname "$0")" 4 | 5 | rm -rfv ../wasm/pkg 6 | (cd ../wasm && wasm-pack build) 7 | 8 | npm ci 9 | npm exec tsc 10 | node esbuild.ts 11 | 12 | rm -f lambda_package.zip 13 | 14 | zip -j -r lambda_package.zip package.json dist/ 15 | -------------------------------------------------------------------------------- /core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "fend-core" 3 | version.workspace = true 4 | description.workspace = true 5 | edition.workspace = true 6 | repository.workspace = true 7 | homepage.workspace = true 8 | keywords.workspace = true 9 | categories.workspace = true 10 | license.workspace = true 11 | readme = "README.md" 12 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: 0% # must be >0% to pass 6 | threshold: 100% # coverage may drop by up to 100% 7 | patch: 8 | default: 9 | # don't require individual patches to have any particular code coverage 10 | target: 0% 11 | threshold: 100% 12 | -------------------------------------------------------------------------------- /web/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 7 | ReactDOM.createRoot(document.getElementById('root')!).render( 8 | 9 | 10 | , 11 | ); 12 | -------------------------------------------------------------------------------- /web/src/components/NewTabLink.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | 3 | type Props = { 4 | children: ReactNode; 5 | href: string; 6 | }; 7 | 8 | export default function NewTabLink({ children, href }: Props) { 9 | return ( 10 | 11 | {children} 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /web/src/widget.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 7 | ReactDOM.createRoot(document.getElementById('root')!).render( 8 | 9 | 10 | , 11 | ); 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /documentation/fend.1 2 | 3 | /icon/fend-icon.ico 4 | /icon/resources.res 5 | 6 | /target/ 7 | 8 | /telegram-bot/dist/ 9 | /telegram-bot/lambda_package.zip 10 | /telegram-bot/node_modules/ 11 | 12 | /web/node_modules/ 13 | /web/public/documentation/ 14 | /web/public/fend-icon-128.png 15 | /web/dist/ 16 | 17 | /windows-msix/build/ 18 | /windows-msix/fend-windows-x64.msix 19 | 20 | /windows-wix/.wix/ 21 | /windows-wix/build/ 22 | /windows-wix/obj/ 23 | -------------------------------------------------------------------------------- /web/src/components/PendingOutput.tsx: -------------------------------------------------------------------------------- 1 | import type { Ref } from 'react'; 2 | import { ThreeDotsScale } from 'react-svg-spinners'; 3 | 4 | type Props = { 5 | ref: Ref; 6 | hint: string; 7 | isPending: boolean; 8 | }; 9 | 10 | export default function PendingOutput({ ref, hint, isPending }: Props) { 11 | return ( 12 |

13 | {hint || (isPending ? : <> )} 14 |

15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /core/README.md: -------------------------------------------------------------------------------- 1 | # fend-core 2 | 3 | This library implements most of the features of [fend](https://github.com/printfn/fend). 4 | 5 | It requires no dependencies and can easily be used by other Rust programs. 6 | 7 | ## Example 8 | 9 | ```rust 10 | extern crate fend_core; 11 | 12 | fn main() { 13 | let mut context = fend_core::Context::new(); 14 | let result = fend_core::evaluate("1 + 1", &mut context).unwrap(); 15 | assert_eq!(result.get_main_result(), "2"); 16 | } 17 | ``` 18 | -------------------------------------------------------------------------------- /contrib/test-aur.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -exuo pipefail 3 | docker pull archlinux:latest 4 | docker run -ti archlinux bash -euxc \ 5 | "pacman -Syyu git curl go sudo base-devel --noconfirm 6 | useradd -m -G wheel -s /bin/bash user 7 | echo \"%wheel ALL=(ALL) NOPASSWD: ALL\" | sudo EDITOR=\"tee -a\" visudo 8 | sudo -u user bash -euxc \" 9 | cd 10 | git clone https://aur.archlinux.org/yay.git 11 | cd yay 12 | makepkg -si --noconfirm 13 | yay --noconfirm -Syu aur/fend 14 | fend \\\"1 kg to lbs\\\" 15 | \"" 16 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | fend - an arbitrary-precision unit-aware calculator 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /web/widget.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | fend - an arbitrary-precision unit-aware calculator 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /contrib/zsh-helper.zsh: -------------------------------------------------------------------------------- 1 | # This can be added to a .zshrc file to make fend nicer to use 2 | # on the command-line. It makes sure that globbing is disabled, which 3 | # means that e.g. `fend 2 * 3` will work as expected (instead of zsh 4 | # trying to expand `*` into a list of files). 5 | # 6 | # You can try out how this works by running `source zsh-helper.zsh`, or you 7 | # can simply copy-paste this into your ~/.zshrc file. 8 | # 9 | # Note that this only works in zsh, not in bash or any other shell. 10 | # 11 | 12 | alias fend="nocorrect noglob command fend" 13 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | # Statically link the C Runtime on Windows (MSVCR140.dll or similar). 2 | # Recommended for portable binaries, see https://github.com/volks73/cargo-wix/issues/115 3 | # and https://github.com/volks73/cargo-wix/commit/bc06cb856f603a661050d9cc118bb71f973458bb 4 | 5 | # After building fend, this can be verified by running: 6 | # ``` 7 | # cargo install pelite 8 | # pedump target/release/fend.exe -i 9 | # ``` 10 | # and checking that the output doesn't contain any MSVCR DLLs 11 | 12 | [target.x86_64-pc-windows-msvc] 13 | rustflags = ["-Ctarget-feature=+crt-static"] 14 | -------------------------------------------------------------------------------- /icon/create-resources.ps1: -------------------------------------------------------------------------------- 1 | $ErrorActionPreference = "Stop" 2 | 3 | # https://learn.microsoft.com/en-us/windows/win32/menurc/using-rc-the-rc-command-line- 4 | 5 | Set-Location "$PSScriptRoot" 6 | 7 | & magick icon.svg ` 8 | `( "-clone" 0 "-resize" 16x16 `) ` 9 | `( "-clone" 0 "-resize" 32x32 `) ` 10 | `( "-clone" 0 "-resize" 48x48 `) ` 11 | `( "-clone" 0 "-resize" 64x64 `) ` 12 | `( "-clone" 0 "-resize" 96x96 `) ` 13 | `( "-clone" 0 "-resize" 128x128 `) ` 14 | `( "-clone" 0 "-resize" 256x256 `) ` 15 | "-delete" 0 "-alpha" remove "-colors" 256 fend-icon.ico 16 | 17 | & rc /v resources.rc 18 | -------------------------------------------------------------------------------- /cli/build.rs: -------------------------------------------------------------------------------- 1 | use std::{env, error, fs}; 2 | 3 | fn link_icon() -> Result<(), Box> { 4 | if env::var("TARGET")? == "x86_64-pc-windows-msvc" { 5 | println!("cargo::rerun-if-changed=../icon/resources.res"); 6 | if fs::exists("../icon/resources.res")? { 7 | println!("cargo::rustc-link-arg-bins=icon/resources.res"); 8 | } else { 9 | return Err( 10 | "could not find `resources.res` file; fend will not have an app icon".into(), 11 | ); 12 | } 13 | } 14 | Ok(()) 15 | } 16 | 17 | fn main() { 18 | if let Err(e) = link_icon() { 19 | println!("cargo::warning={e}"); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["cli", "core", "wasm"] 3 | resolver = "2" 4 | 5 | [workspace.package] 6 | version = "1.5.7" 7 | description = "Arbitrary-precision unit-aware calculator" 8 | edition = "2024" 9 | homepage = "https://github.com/printfn/fend" 10 | repository = "https://github.com/printfn/fend" 11 | keywords = ["calculator", "cli", "conversion", "math", "tool"] 12 | categories = ["command-line-utilities", "mathematics", "science"] 13 | license = "MIT" 14 | 15 | [workspace.dependencies] 16 | fend-core = { version = "1.5.7", path = "core" } 17 | 18 | [profile.release] 19 | lto = true 20 | opt-level = "z" # small code size 21 | strip = "symbols" 22 | -------------------------------------------------------------------------------- /telegram-bot/esbuild.ts: -------------------------------------------------------------------------------- 1 | import { context } from 'esbuild'; 2 | import { wasmLoader } from 'esbuild-plugin-wasm'; 3 | 4 | const watch = process.argv.includes('--watch'); 5 | 6 | async function main() { 7 | const ctx = await context({ 8 | entryPoints: ['index.ts'], 9 | bundle: true, 10 | outdir: 'dist', 11 | platform: 'node', 12 | format: 'esm', 13 | plugins: [wasmLoader()], 14 | }); 15 | 16 | if (watch) { 17 | await ctx.watch(); 18 | } else { 19 | await ctx.rebuild(); 20 | await ctx.dispose(); 21 | } 22 | } 23 | 24 | try { 25 | await main(); 26 | } catch (e) { 27 | console.error(e); 28 | process.exit(1); 29 | }; 30 | -------------------------------------------------------------------------------- /web/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "Bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /core/src/format.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Interrupt; 2 | use crate::num::Exact; 3 | use crate::result::FResult; 4 | use std::fmt; 5 | 6 | pub(crate) trait Format { 7 | type Params: Default; 8 | type Out: fmt::Display + fmt::Debug; 9 | 10 | fn format(&self, params: &Self::Params, int: &I) -> FResult>; 11 | 12 | /// Simpler alternative to calling format 13 | fn fm(&self, int: &I) -> FResult { 14 | Ok(self.format(&Default::default(), int)?.value) 15 | } 16 | } 17 | 18 | pub(crate) trait DisplayDebug: fmt::Display + fmt::Debug + Send + Sync {} 19 | 20 | impl DisplayDebug for T {} 21 | -------------------------------------------------------------------------------- /documentation/add-header-ids.lua: -------------------------------------------------------------------------------- 1 | function generateSlug(el) 2 | if el.tag == "Header" then 3 | local headerText = pandoc.utils.stringify(el.content) 4 | local slug = urlify(headerText) 5 | el.attr = { id = slug } 6 | end 7 | return el 8 | end 9 | 10 | function urlify(text) 11 | -- Replace non-alphanumeric characters with hyphens 12 | text = text:gsub("[^a-zA-Z0-9]", "-") 13 | -- Remove extra hyphens 14 | text = text:gsub("-+", "-") 15 | -- Remove leading and trailing hyphens 16 | text = text:gsub("^%-*(.-)%-*$", "%1") 17 | -- Convert to lowercase 18 | text = text:lower() 19 | return text 20 | end 21 | 22 | -- Apply the filter 23 | return { 24 | { Header = generateSlug } 25 | } 26 | -------------------------------------------------------------------------------- /wasm/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "fend-wasm" 3 | version.workspace = true 4 | description.workspace = true 5 | edition.workspace = true 6 | homepage.workspace = true 7 | repository.workspace = true 8 | keywords.workspace = true 9 | categories.workspace = true 10 | license.workspace = true 11 | publish = false 12 | 13 | [lib] 14 | crate-type = ["cdylib", "rlib"] 15 | 16 | [features] 17 | #default = ["console_error_panic_hook"] 18 | default = [] 19 | 20 | [dependencies] 21 | fend-core.workspace = true 22 | js-sys = "0.3.82" 23 | wasm-bindgen = "0.2.105" 24 | web-time = "1.1.0" 25 | 26 | [package.metadata.wasm-pack.profile.release] 27 | wasm-opt = ["-Oz", "--enable-nontrapping-float-to-int", "--enable-bulk-memory"] 28 | -------------------------------------------------------------------------------- /documentation/pandoc-metadata.yml: -------------------------------------------------------------------------------- 1 | header-includes: | 2 | ```{=html} 3 | 4 | 24 | ``` 25 | lang: "en" 26 | linkcolor: "blue" 27 | pagetitle: "fend Manual" 28 | -------------------------------------------------------------------------------- /web/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ESNext", 5 | "useDefineForClassFields": true, 6 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "Bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["src"] 26 | } 27 | -------------------------------------------------------------------------------- /windows-wix/build.ps1: -------------------------------------------------------------------------------- 1 | $ErrorActionPreference = "Stop" 2 | 3 | if (Test-Path $PSScriptRoot\build) { 4 | Remove-Item -Recurse -Force $PSScriptRoot\build 5 | } 6 | 7 | mkdir $PSScriptRoot\build 8 | 9 | Set-Location "$PSScriptRoot" 10 | 11 | dotnet tool restore 12 | dotnet tool run wix extension add WixToolset.UI.wixext/6.0.2 13 | dotnet tool run wix extension add WixToolset.Util.wixext/6.0.2 14 | 15 | dotnet tool run wix build ` 16 | -arch x64 -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext ` 17 | -d Version="$Env:FEND_VERSION" ` 18 | -d FendExePath="$PSScriptRoot\..\target\release\fend.exe" ` 19 | -d LicenseMdPath="$PSScriptRoot\..\LICENSE.md" ` 20 | -d IconPath="$PSScriptRoot\..\icon\fend-icon.ico" ` 21 | -o "$PSScriptRoot\build\fend-windows-x64.msi" "$PSScriptRoot\main.wxs" 22 | -------------------------------------------------------------------------------- /web/src/hooks/useHistory.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | 3 | const initialHistory = JSON.parse(localStorage.getItem('fend_history') || '[]') as string[]; 4 | 5 | export function useHistory() { 6 | const [history, setHistory] = useState(initialHistory); 7 | 8 | const addToHistory = useCallback((newEntry: string) => { 9 | if (newEntry.startsWith(' ')) return; 10 | if (newEntry.trim().length === 0) return; 11 | setHistory(prevHistory => { 12 | const updatedHistory = [...prevHistory, newEntry].filter( 13 | (entry, idx, array) => idx === 0 || entry !== array[idx - 1], 14 | ); 15 | localStorage.setItem('fend_history', JSON.stringify(updatedHistory.slice(-100))); 16 | return updatedHistory; 17 | }); 18 | }, []); 19 | 20 | return { history, addToHistory }; 21 | } 22 | -------------------------------------------------------------------------------- /web/cloudflare/cf-worker.js: -------------------------------------------------------------------------------- 1 | // This needs to be manually deployed to Cloudflare 2 | 3 | export default { 4 | async fetch(request, env, ctx) { 5 | if (request.url === 'https://fend.pr.workers.dev/exchange-rates' && request.method === 'GET') { 6 | const fetchResult = await fetch('https://treasury.un.org/operationalrates/xsql2XML.php', { 7 | cf: { 8 | cacheTtl: 86400, 9 | cacheEverything: true, 10 | }, 11 | headers: { 12 | 'X-Source': 'Cloudflare-Workers', 13 | }, 14 | }); 15 | const data = await fetchResult.text(); 16 | return new Response(data, { 17 | headers: { 18 | 'Access-Control-Allow-Origin': 'https://printfn.github.io', 19 | 'Cache-Control': 'max-age=172800', 20 | }, 21 | }); 22 | } else { 23 | return new Response(null, { 24 | status: 404, 25 | }); 26 | } 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /web/src/lib/worker.ts: -------------------------------------------------------------------------------- 1 | import { evaluateFendWithVariablesJson, initialiseWithHandlers } from 'fend-wasm'; 2 | 3 | export type FendArgs = { 4 | input: string; 5 | timeout: number; 6 | variables: string; 7 | currencyData: Map; 8 | }; 9 | 10 | export type FendResult = { ok: true; result: string; variables: string } | { ok: false; message: string }; 11 | 12 | function eventListener({ data }: MessageEvent) { 13 | try { 14 | initialiseWithHandlers(data.currencyData); 15 | const result = JSON.parse(evaluateFendWithVariablesJson(data.input, data.timeout, data.variables)) as FendResult; 16 | postMessage(result); 17 | } catch (e: unknown) { 18 | console.error(e); 19 | throw e; 20 | } 21 | } 22 | self.addEventListener('message', (ev: MessageEvent) => { 23 | eventListener(ev); 24 | }); 25 | postMessage('ready'); 26 | -------------------------------------------------------------------------------- /web/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | cd "$(dirname "$0")" 4 | 5 | # Ubuntu 24.04 doesn't ship with ImageMagick v7 6 | if ! type magick &>/dev/null; then 7 | shopt -s expand_aliases # https://unix.stackexchange.com/a/1498 8 | alias magick=convert 9 | fi 10 | 11 | rm -rf ../wasm/fend-wasm 12 | (cd ../wasm && wasm-pack build -d fend-wasm) 13 | 14 | npm ci 15 | npm run lint 16 | npm run format -- --check 17 | 18 | magick ../icon/icon.svg -resize "128x128" public/fend-icon-128.png 19 | 20 | mkdir -p public/documentation 21 | 22 | (cd ../documentation && pandoc --standalone \ 23 | --output=../web/public/documentation/index.html \ 24 | --metadata-file=pandoc-metadata.yml \ 25 | --lua-filter=include-code-files.lua \ 26 | --lua-filter=include-files.lua \ 27 | --lua-filter=add-header-ids.lua \ 28 | index.md) 29 | 30 | npm run build 31 | -------------------------------------------------------------------------------- /web/eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import js from '@eslint/js'; 4 | import globals from 'globals'; 5 | import reactHooks from 'eslint-plugin-react-hooks'; 6 | import reactRefresh from 'eslint-plugin-react-refresh'; 7 | import tseslint from 'typescript-eslint'; 8 | 9 | export default tseslint.config( 10 | js.configs.recommended, 11 | ...tseslint.configs.strictTypeChecked, 12 | reactHooks.configs.flat.recommended, 13 | reactRefresh.configs.vite, 14 | { ignores: ['dist', 'cloudflare', 'eslint.config.js'] }, 15 | { 16 | files: ['**/*.{ts,tsx}'], 17 | }, 18 | { 19 | languageOptions: { 20 | globals: globals.browser, 21 | parserOptions: { 22 | projectService: true, 23 | tsconfigRootDir: import.meta.dirname, 24 | }, 25 | }, 26 | rules: { 27 | '@typescript-eslint/promise-function-async': 'error', 28 | }, 29 | }, 30 | ); 31 | -------------------------------------------------------------------------------- /web/src/hooks/useFend.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | import { fend } from '../lib/fend'; 3 | 4 | export function useFend() { 5 | const [variables, setVariables] = useState(''); 6 | 7 | const evaluate = useCallback( 8 | async (input: string) => { 9 | const result = await fend(input, 1000000000, variables); 10 | console.log(result); 11 | if (result.ok && result.variables.length > 0) { 12 | setVariables(result.variables); 13 | } 14 | return result; 15 | }, 16 | [variables], 17 | ); 18 | 19 | const evaluateHint = useCallback( 20 | async (input: string) => { 21 | const result = await fend(input, 100, variables); 22 | if (!result.ok) { 23 | return ''; 24 | } else { 25 | return result.result; 26 | } 27 | }, 28 | [variables], 29 | ); 30 | 31 | return { evaluate, evaluateHint }; 32 | } 33 | -------------------------------------------------------------------------------- /telegram-bot/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "telegram-bot", 3 | "version": "0.0.0", 4 | "description": "Arbitrary-precision unit-aware calculator", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/printfn/fend.git" 13 | }, 14 | "keywords": [ 15 | "calculator", 16 | "telegram", 17 | "math" 18 | ], 19 | "author": "printfn", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/printfn/fend/issues" 23 | }, 24 | "homepage": "https://github.com/printfn/fend#readme", 25 | "dependencies": { 26 | "fend-wasm": "file:../wasm/pkg" 27 | }, 28 | "devDependencies": { 29 | "@types/node": "^24.10.1", 30 | "esbuild": "^0.27.0", 31 | "esbuild-plugin-wasm": "^1.1.0", 32 | "typescript": "^5.9.3" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /core/src/interrupt.rs: -------------------------------------------------------------------------------- 1 | use crate::{error::FendError, result::FResult}; 2 | 3 | /// This trait controls fend's interrupt functionality. 4 | /// 5 | /// If the `should_interrupt` method returns `true`, fend will attempt to 6 | /// interrupt the current calculation and return `Err(FendError::Interrupted)`. 7 | /// 8 | /// This can be used to implement timeouts or user interrupts via e.g. Ctrl-C. 9 | pub trait Interrupt: Sync { 10 | /// Returns `true` if the current calculation should be interrupted. 11 | fn should_interrupt(&self) -> bool; 12 | } 13 | 14 | pub(crate) fn test_int(int: &I) -> FResult<()> { 15 | if int.should_interrupt() { 16 | Err(FendError::Interrupted) 17 | } else { 18 | Ok(()) 19 | } 20 | } 21 | 22 | #[derive(Default)] 23 | pub(crate) struct Never; 24 | impl Interrupt for Never { 25 | fn should_interrupt(&self) -> bool { 26 | false 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /telegram-bot/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | cd "$(dirname "$0")" 4 | 5 | a() { 6 | aws --no-cli-pager --region ap-southeast-2 "$@" 7 | } 8 | 9 | echo "Updating function configuration..." 10 | 11 | # # Warning: 12 | # 13 | # The `update-function-configuration` and `update-function-code` commands 14 | # print all environment variables, including the Telegram Bot API token, 15 | # so we redirect the output to /dev/null. 16 | 17 | a lambda update-function-configuration \ 18 | --function-name fend-telegram-bot \ 19 | --environment "Variables={TELEGRAM_BOT_API_TOKEN=$TELEGRAM_BOT_API_TOKEN}" >/dev/null 20 | 21 | a lambda wait function-updated-v2 --function-name fend-telegram-bot 22 | 23 | echo "Updating function code..." 24 | 25 | a lambda update-function-code \ 26 | --function-name fend-telegram-bot \ 27 | --zip-file fileb://lambda_package.zip >/dev/null 28 | 29 | a lambda wait function-updated-v2 --function-name fend-telegram-bot 30 | -------------------------------------------------------------------------------- /web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import react from '@vitejs/plugin-react'; 3 | import wasm from 'vite-plugin-wasm'; 4 | import { defineConfig, searchForWorkspaceRoot } from 'vite'; 5 | 6 | const ReactCompilerConfig = {}; 7 | 8 | export default defineConfig({ 9 | base: '/fend/', 10 | build: { 11 | minify: false, 12 | rollupOptions: { 13 | input: { 14 | main: resolve(__dirname, 'index.html'), 15 | widget: resolve(__dirname, 'widget.html'), 16 | }, 17 | }, 18 | sourcemap: true, 19 | target: 'esnext', 20 | }, 21 | worker: { 22 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 23 | plugins: () => [wasm()], 24 | format: 'es', 25 | }, 26 | plugins: [ 27 | wasm(), 28 | react({ 29 | babel: { 30 | plugins: [['babel-plugin-react-compiler', ReactCompilerConfig]], 31 | }, 32 | }), 33 | ], 34 | server: { 35 | fs: { 36 | allow: [searchForWorkspaceRoot(process.cwd()), '../wasm/fend-wasm'], 37 | }, 38 | }, 39 | }); 40 | -------------------------------------------------------------------------------- /documentation/chapters/configuration.md: -------------------------------------------------------------------------------- 1 | The CLI version of fend supports a configuration file. 2 | 3 | The location of this file differs based on your operating system: 4 | 5 | * Linux: `$XDG_CONFIG_HOME/fend/config.toml` (usually `$HOME/.config/fend/config.toml`) 6 | * macOS: `$HOME/.config/fend/config.toml` 7 | * Windows: `\Users\{UserName}\.config\fend\config.toml` 8 | 9 | You can always confirm the path that fend uses by typing `help`. You can also 10 | see the default configuration file that fend uses by running `fend --default-config`. 11 | 12 | You can override the config path location using the 13 | environment variable `FEND_CONFIG_DIR`. 14 | 15 | fend stores its history file in `$HOME/.local/state/fend/history` by default, 16 | although this can be overridden with the `FEND_STATE_DIR` environment variable. 17 | 18 | Cache data is stored in `$HOME/.cache/fend` by default. This can be overridden 19 | with the `FEND_CACHE_DIR` environment variable. 20 | 21 | These are the configuration options currently available, along with their default values: 22 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2025 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /core/src/num/unit/base_unit.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, fmt, io}; 2 | 3 | use crate::result::FResult; 4 | use crate::serialize::{Deserialize, Serialize}; 5 | 6 | /// Represents a base unit, identified solely by its name. The name is not exposed to the user. 7 | #[derive(Clone, PartialEq, Eq, Hash)] 8 | pub(crate) struct BaseUnit { 9 | name: Cow<'static, str>, 10 | } 11 | 12 | impl fmt::Debug for BaseUnit { 13 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 14 | write!(f, "{}", self.name) 15 | } 16 | } 17 | 18 | impl BaseUnit { 19 | pub(crate) const fn new(name: Cow<'static, str>) -> Self { 20 | Self { name } 21 | } 22 | 23 | pub(crate) const fn new_static(name: &'static str) -> Self { 24 | Self { 25 | name: Cow::Borrowed(name), 26 | } 27 | } 28 | 29 | pub(crate) fn name(&self) -> &str { 30 | self.name.as_ref() 31 | } 32 | 33 | pub(crate) fn serialize(&self, write: &mut impl io::Write) -> FResult<()> { 34 | self.name.as_ref().serialize(write)?; 35 | Ok(()) 36 | } 37 | 38 | pub(crate) fn deserialize(read: &mut impl io::Read) -> FResult { 39 | Ok(Self { 40 | name: Cow::Owned(String::deserialize(read)?), 41 | }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "fend" 3 | version.workspace = true 4 | description.workspace = true 5 | edition.workspace = true 6 | repository.workspace = true 7 | homepage.workspace = true 8 | keywords.workspace = true 9 | categories.workspace = true 10 | license.workspace = true 11 | readme = "../README.md" 12 | 13 | [dependencies] 14 | fend-core.workspace = true 15 | rand = { version = "0.9.2", default-features = false, features = ["thread_rng"] } 16 | rustyline = { version = "17.0.2", default-features = false, features = ["with-file-history", "custom-bindings"] } 17 | serde = { version = "1.0.228", default-features = false } 18 | toml = { version = "0.9.8", default-features = false, features = ["parse", "serde", "std"] } 19 | reqwest = { version = "0.12.24", default-features = false, features = ["http2", "system-proxy"], optional = true } 20 | tokio = { version = "1.48.0", default-features = false, features = ["macros", "rt-multi-thread", "signal", "sync", "time"] } 21 | 22 | [target.'cfg(windows)'.dependencies] 23 | windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } 24 | 25 | [features] 26 | native-tls = ["dep:reqwest", "reqwest/native-tls"] 27 | rustls = ["dep:reqwest", "reqwest/rustls-tls-native-roots"] 28 | default = ["native-tls"] 29 | -------------------------------------------------------------------------------- /cli/src/interrupt.rs: -------------------------------------------------------------------------------- 1 | use std::{process, sync}; 2 | 3 | pub struct CtrlC { 4 | running: sync::Arc, 5 | } 6 | 7 | impl fend_core::Interrupt for CtrlC { 8 | fn should_interrupt(&self) -> bool { 9 | let running = self.running.load(sync::atomic::Ordering::Relaxed); 10 | !running 11 | } 12 | } 13 | 14 | impl CtrlC { 15 | pub fn reset(&self) { 16 | self.running.store(true, sync::atomic::Ordering::SeqCst); 17 | } 18 | } 19 | 20 | pub fn register_handler() -> CtrlC { 21 | let interrupt = CtrlC { 22 | running: sync::Arc::new(sync::atomic::AtomicBool::new(true)), 23 | }; 24 | 25 | let r = interrupt.running.clone(); 26 | let handler = move || { 27 | if !r.load(sync::atomic::Ordering::SeqCst) { 28 | // we already pressed Ctrl+C, so now kill the program 29 | process::exit(1); 30 | } 31 | r.store(false, sync::atomic::Ordering::SeqCst); 32 | }; 33 | tokio::task::spawn(async move { 34 | loop { 35 | if let Err(e) = tokio::signal::ctrl_c().await { 36 | eprintln!("unable to set Ctrl-C handler: {e}"); 37 | break; 38 | } 39 | handler(); 40 | } 41 | }); 42 | 43 | interrupt 44 | } 45 | 46 | #[derive(Default)] 47 | pub struct Never {} 48 | impl fend_core::Interrupt for Never { 49 | fn should_interrupt(&self) -> bool { 50 | false 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /icon/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /core/src/ident.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, fmt, io}; 2 | 3 | use crate::{ 4 | result::FResult, 5 | serialize::{Deserialize, Serialize}, 6 | }; 7 | 8 | #[derive(Clone, Debug, PartialEq, Eq)] 9 | pub(crate) struct Ident(Cow<'static, str>); 10 | 11 | impl Ident { 12 | pub(crate) fn new_str(s: &'static str) -> Self { 13 | Self(Cow::Borrowed(s)) 14 | } 15 | 16 | pub(crate) fn new_string(s: String) -> Self { 17 | Self(Cow::Owned(s)) 18 | } 19 | 20 | pub(crate) fn as_str(&self) -> &str { 21 | self.0.as_ref() 22 | } 23 | 24 | pub(crate) fn is_prefix_unit(&self) -> bool { 25 | // when changing this also make sure to change number output formatting 26 | // lexer identifier splitting 27 | ["$", "\u{a3}", "\u{a5}"].contains(&&*self.0) 28 | } 29 | 30 | pub(crate) fn serialize(&self, write: &mut impl io::Write) -> FResult<()> { 31 | self.0.as_ref().serialize(write) 32 | } 33 | 34 | pub(crate) fn deserialize(read: &mut impl io::Read) -> FResult { 35 | Ok(Self(Cow::Owned(String::deserialize(read)?))) 36 | } 37 | } 38 | 39 | impl From for Ident { 40 | fn from(value: String) -> Self { 41 | Self(Cow::Owned(value)) 42 | } 43 | } 44 | 45 | impl From<&'static str> for Ident { 46 | fn from(value: &'static str) -> Self { 47 | Self(Cow::Borrowed(value)) 48 | } 49 | } 50 | 51 | impl fmt::Display for Ident { 52 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 53 | write!(f, "{}", self.0) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /web/src/lib/exchange-rates.ts: -------------------------------------------------------------------------------- 1 | import { WaitGroup } from './WaitGroup'; 2 | 3 | const wg = new WaitGroup(); 4 | let exchangeRateCache: Map | undefined; 5 | 6 | async function fetchExchangeRates() { 7 | try { 8 | const map = new Map(); 9 | const res = await fetch('https://fend.pr.workers.dev/exchange-rates'); 10 | const xml = await res.text(); 11 | const dom = new DOMParser().parseFromString(xml, 'text/xml'); 12 | 13 | for (const node of dom.querySelectorAll('UN_OPERATIONAL_RATES')) { 14 | const currency = node.querySelector('f_curr_code')?.textContent; 15 | if (!currency) continue; 16 | const rateStr = node.querySelector('rate')?.textContent; 17 | if (!rateStr) continue; 18 | const rate = Number.parseFloat(rateStr); 19 | 20 | if (!Number.isNaN(rate) && Number.isFinite(rate)) { 21 | map.set(currency, rate); 22 | } 23 | } 24 | return map; 25 | } catch (e) { 26 | throw new Error('failed to fetch currencies', { cause: e }); 27 | } 28 | } 29 | 30 | export async function getExchangeRates(): Promise> { 31 | await wg.wait(); 32 | 33 | try { 34 | wg.enter(); 35 | if (exchangeRateCache) { 36 | return exchangeRateCache; 37 | } 38 | exchangeRateCache = await fetchExchangeRates(); 39 | return exchangeRateCache; 40 | } catch (e) { 41 | console.log(e); 42 | exchangeRateCache = new Map(); 43 | return exchangeRateCache; 44 | } finally { 45 | wg.leave(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /documentation/manpage.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: fend 3 | author: printfn 4 | section: 1 5 | --- 6 | 7 | # NAME 8 | 9 | fend - arbitrary-precision unit-aware calculator 10 | 11 | # SYNOPSIS 12 | 13 | _fend_ **[option | file | expression]...** **[\--]** **[expression]...** 14 | 15 | # OPTIONS 16 | 17 | **-h**, **\--help** 18 | : Show help 19 | 20 | **-v**, **-V**, **\--version** 21 | : Show the current version number 22 | 23 | **\--default-config**, **\--print-default-config** 24 | : Print the default configuration file 25 | 26 | **-e**, **\--eval** **\** 27 | : Evaluate the given expression (e.g. `1+1`) 28 | 29 | **-f**, **\--file** **\** 30 | : Read and evaluate the given file 31 | 32 | # DESCRIPTION 33 | 34 | ```{.include} 35 | chapters/expressions.md 36 | ``` 37 | 38 | # CONFIGURATION 39 | 40 | ```{.include} 41 | chapters/configuration.md 42 | ``` 43 | 44 | ```{.toml include="../cli/src/default_config.toml"} 45 | ``` 46 | 47 | # SCRIPTING 48 | 49 | ```{.include} 50 | chapters/scripting.md 51 | ``` 52 | 53 | # EXIT VALUES 54 | 55 | **0** 56 | : Success 57 | 58 | **1** 59 | : Error 60 | 61 | # BUGS 62 | 63 | Bugs and feature suggestions can be reported at 64 | [https://github.com/printfn/fend/issues](https://github.com/printfn/fend/issues). 65 | 66 | # COPYRIGHT 67 | 68 | fend is available under the MIT license. You can find the source code at 69 | [https://github.com/printfn/fend](https://github.com/printfn/fend). 70 | 71 | # CHANGELOG 72 | 73 | ```{.include} 74 | ../CHANGELOG.md 75 | ``` 76 | -------------------------------------------------------------------------------- /web/src/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | background: transparent; 3 | color: inherit; 4 | border: none; 5 | font-weight: inherit; 6 | font-size: inherit; 7 | font-style: inherit; 8 | margin: 0; 9 | padding: 0; 10 | } 11 | 12 | body { 13 | background: hsl(30, 25%, 90%); 14 | font: 16px / 150% monospace; 15 | color: hsl(30, 25%, 10%); 16 | margin: 0; 17 | } 18 | 19 | a { 20 | color: hsl(90, 100%, 23%); 21 | } 22 | 23 | b { 24 | color: hsl(30, 100%, 30%); 25 | } 26 | 27 | @media (prefers-color-scheme: dark) { 28 | a { 29 | color: hsl(90, 70%, 70%); 30 | } 31 | 32 | b { 33 | color: hsl(30, 85%, 70%); 34 | } 35 | 36 | body { 37 | background: hsl(30, 35%, 10%); 38 | color: hsl(30, 15%, 90%); 39 | } 40 | 41 | svg circle { 42 | fill: white; 43 | } 44 | } 45 | 46 | main { 47 | max-width: 80ch; 48 | padding: 3ch; 49 | margin: auto; 50 | } 51 | 52 | #output p, 53 | #pending-output { 54 | white-space: pre-wrap; 55 | word-break: break-all; 56 | } 57 | 58 | #input { 59 | display: grid; 60 | grid-template-columns: 2ch 1fr; 61 | grid-template-rows: auto auto; 62 | } 63 | #input p { 64 | grid-column: 1 / 3; 65 | grid-row: 2; 66 | } 67 | #input:before { 68 | content: '>'; 69 | } 70 | #input #text { 71 | display: grid; 72 | grid-column: 2; 73 | grid-row: 1; 74 | } 75 | #input #text textarea { 76 | line-height: inherit; 77 | font-family: inherit; 78 | outline: none; 79 | overflow: hidden; 80 | resize: none; 81 | } 82 | #input #text textarea, 83 | #input #text:after { 84 | grid-area: 1 / 1 / 2 / 2; 85 | white-space: pre-wrap; 86 | } 87 | -------------------------------------------------------------------------------- /core/src/json.rs: -------------------------------------------------------------------------------- 1 | /// The method is not meant to be used by other crates! It may change 2 | /// or be removed in the future, with no regard for backwards compatibility. 3 | #[allow(clippy::missing_panics_doc)] 4 | pub fn escape_string(input: &str, out: &mut String) { 5 | for ch in input.chars() { 6 | match ch { 7 | '\\' => out.push_str("\\\\"), 8 | '"' => out.push_str("\\\""), 9 | '\n' => out.push_str("\\n"), 10 | '\r' => out.push_str("\\r"), 11 | '\t' => out.push_str("\\t"), 12 | '\x20'..='\x7e' => out.push(ch), 13 | _ => { 14 | let mut buf = [0; 2]; 15 | for &mut code_unit in ch.encode_utf16(&mut buf) { 16 | out.push_str("\\u"); 17 | out.push(char::from_digit(u32::from(code_unit) / 0x1000, 16).unwrap()); 18 | out.push(char::from_digit(u32::from(code_unit) % 0x1000 / 0x100, 16).unwrap()); 19 | out.push(char::from_digit(u32::from(code_unit) % 0x100 / 0x10, 16).unwrap()); 20 | out.push(char::from_digit(u32::from(code_unit) % 0x10, 16).unwrap()); 21 | } 22 | } 23 | } 24 | } 25 | } 26 | 27 | #[cfg(test)] 28 | mod tests { 29 | use super::*; 30 | 31 | #[track_caller] 32 | fn test_json_str(input: &str, expected: &str) { 33 | let mut out = String::new(); 34 | escape_string(input, &mut out); 35 | assert_eq!(out, expected); 36 | } 37 | 38 | #[test] 39 | fn json_string_encoding() { 40 | test_json_str("abc", "abc"); 41 | test_json_str("fancy string\n", "fancy string\\n"); 42 | test_json_str("\n\t\r\0\\\'\"", "\\n\\t\\r\\u0000\\\\'\\\""); 43 | test_json_str("\u{1d54a}", "\\ud835\\udd4a"); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /core/src/date/day.rs: -------------------------------------------------------------------------------- 1 | use crate::FendError; 2 | use crate::result::FResult; 3 | use crate::{Deserialize, Serialize}; 4 | use std::fmt; 5 | use std::io; 6 | 7 | #[derive(Copy, Clone, Eq, PartialEq)] 8 | pub(crate) struct Day(u8); 9 | 10 | impl Day { 11 | pub(crate) fn value(self) -> u8 { 12 | self.0 13 | } 14 | 15 | pub(crate) fn new(day: u8) -> Self { 16 | assert!(day != 0 && day < 32, "day value {day} is out of range"); 17 | Self(day) 18 | } 19 | 20 | pub(crate) fn serialize(self, write: &mut impl io::Write) -> FResult<()> { 21 | self.value().serialize(write)?; 22 | Ok(()) 23 | } 24 | 25 | pub(crate) fn deserialize(read: &mut impl io::Read) -> FResult { 26 | let n = u8::deserialize(read)?; 27 | if n == 0 || n >= 32 { 28 | return Err(FendError::DeserializationError("day is out of range")); 29 | } 30 | Ok(Self::new(n)) 31 | } 32 | } 33 | 34 | impl fmt::Debug for Day { 35 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 36 | write!(f, "{}", self.0) 37 | } 38 | } 39 | 40 | impl fmt::Display for Day { 41 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 42 | write!(f, "{}", self.0) 43 | } 44 | } 45 | 46 | #[cfg(test)] 47 | mod tests { 48 | use super::*; 49 | 50 | #[test] 51 | #[should_panic(expected = "day value 0 is out of range")] 52 | fn day_0() { 53 | Day::new(0); 54 | } 55 | 56 | #[test] 57 | #[should_panic(expected = "day value 32 is out of range")] 58 | fn day_32() { 59 | Day::new(32); 60 | } 61 | 62 | #[test] 63 | fn day_to_string() { 64 | assert_eq!(Day::new(1).to_string(), "1"); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /web/src/lib/WaitGroup.ts: -------------------------------------------------------------------------------- 1 | export class WaitGroup { 2 | #counter = 0; 3 | #promise = Promise.resolve(); 4 | #resolve?: () => void; 5 | 6 | enter() { 7 | if (this.#counter === 0) { 8 | this.#promise = new Promise(resolve => { 9 | this.#resolve = resolve; 10 | }); 11 | } 12 | ++this.#counter; 13 | } 14 | 15 | leave() { 16 | if (this.#counter <= 0 || !this.#resolve) { 17 | throw new Error('leave() called without a matching enter()'); 18 | } 19 | --this.#counter; 20 | if (this.#counter === 0) { 21 | this.#resolve(); 22 | this.#resolve = undefined; 23 | } 24 | } 25 | 26 | async wait(abortSignal?: AbortSignal) { 27 | if (abortSignal) { 28 | await abortPromise(abortSignal, this.#promise); 29 | } else { 30 | await this.#promise; 31 | } 32 | } 33 | 34 | get counter() { 35 | return this.#counter; 36 | } 37 | } 38 | 39 | async function abortPromise(abortSignal: AbortSignal, promise: Promise) { 40 | return new Promise((resolve, reject) => { 41 | if (abortSignal.aborted) { 42 | reject(abortSignal.reason as Error); 43 | return; 44 | } 45 | 46 | const onAbort = () => { 47 | cleanup(); 48 | reject(abortSignal.reason as Error); 49 | }; 50 | 51 | const cleanup = () => { 52 | abortSignal.removeEventListener('abort', onAbort); 53 | }; 54 | 55 | abortSignal.addEventListener('abort', onAbort); 56 | 57 | promise.then( 58 | value => { 59 | cleanup(); 60 | resolve(value); 61 | }, 62 | (error: unknown) => { 63 | cleanup(); 64 | reject(error as Error); 65 | }, 66 | ); 67 | }); 68 | } 69 | -------------------------------------------------------------------------------- /contrib/check-for-updates.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | cd "$(dirname "$0")/.." 4 | 5 | # This script checks for updates to any of fend's dependencies 6 | cargo update 7 | 8 | cargo metadata --format-version 1 --no-deps \ 9 | | jq -r '.packages[].dependencies[] | select(.name | contains("fend") | not) | .name + " " + (.req | sub("\\^"; ""))' \ 10 | | while read -r line 11 | do 12 | IFS=" " read -r -a words <<< "$line" # https://www.shellcheck.net/wiki/SC2206 13 | dep=${words[0]} 14 | current_version=${words[1]} 15 | latest_version="$(curl -sL "https://crates.io/api/v1/crates/$dep" | jq -r .crate.max_stable_version)" 16 | if [[ "$current_version" != "$latest_version" ]] 17 | then 18 | echo "Update available for $dep: $current_version -> $latest_version" 19 | fi 20 | done 21 | 22 | (cd web && npx npm-check-updates -u && npm update) 23 | (cd telegram-bot && npx npm-check-updates -u && npm update) 24 | 25 | current_wix="$(jq -r .tools.wix.version < windows-wix/.config/dotnet-tools.json)" 26 | # https://learn.microsoft.com/en-us/nuget/api/registration-base-url-resource 27 | latest_wix="$(curl -sL "$( 28 | curl -sL https://api.nuget.org/v3/index.json \ 29 | | jq -r '.resources[] | select(."@type" == "RegistrationsBaseUrl/3.6.0") | ."@id" + "wix/index.json"')" \ 30 | | gzip -d \ 31 | | jq '.items[0].items[].catalogEntry.version | select (. | contains("-") | not)' \ 32 | | jq -sr '. | last')" 33 | 34 | if [[ "$latest_wix" != "$current_wix" ]] 35 | then 36 | echo "Update available for wix: $current_wix -> $latest_wix" 37 | echo "Also check for updates to the wix extensions" 38 | fi 39 | -------------------------------------------------------------------------------- /cli/src/color/base.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | #[derive(Debug, Clone, Eq, PartialEq)] 4 | pub enum Base { 5 | Black, 6 | Red, 7 | Green, 8 | Yellow, 9 | Blue, 10 | Magenta, 11 | Cyan, 12 | White, 13 | Color256(u8), 14 | Unknown(String), 15 | } 16 | 17 | struct BaseVisitor; 18 | 19 | impl serde::de::Visitor<'_> for BaseVisitor { 20 | type Value = Base; 21 | 22 | fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 23 | formatter.write_str( 24 | "`black`, `red`, `green`, `yellow`, `blue`, `magenta`, \ 25 | `cyan`, `white` or `256:n` (e.g. `256:42`)", 26 | ) 27 | } 28 | 29 | fn visit_str(self, v: &str) -> Result { 30 | if let Some(color_n) = v.strip_prefix("256:") 31 | && let Ok(n) = color_n.parse::() 32 | { 33 | return Ok(Base::Color256(n)); 34 | } 35 | Ok(match v { 36 | "black" => Base::Black, 37 | "red" => Base::Red, 38 | "green" => Base::Green, 39 | "yellow" => Base::Yellow, 40 | "blue" => Base::Blue, 41 | "magenta" | "purple" => Base::Magenta, 42 | "cyan" => Base::Cyan, 43 | "white" => Base::White, 44 | unknown_color_name => Base::Unknown(unknown_color_name.to_string()), 45 | }) 46 | } 47 | } 48 | 49 | impl<'de> serde::Deserialize<'de> for Base { 50 | fn deserialize>(deserializer: D) -> Result { 51 | deserializer.deserialize_str(BaseVisitor) 52 | } 53 | } 54 | 55 | impl Base { 56 | pub fn warn_about_unknown_colors(&self) { 57 | if let Self::Unknown(name) = self { 58 | eprintln!("Warning: ignoring unknown color `{name}`"); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /windows-msix/build.ps1: -------------------------------------------------------------------------------- 1 | $ErrorActionPreference = "Stop" 2 | 3 | <# 4 | 5 | Before running this script, make sure the FEND_VERSION and WINDOWS_CERT_PASSWORD environment variables are set. 6 | 7 | For example: 8 | $Env:FEND_VERSION = "1.0.0" 9 | $Env:WINDOWS_CERT_PASSWORD = "MyPassword" 10 | 11 | #> 12 | 13 | # $PSScriptRoot is the directory of this script 14 | 15 | if (Test-Path $PSScriptRoot\build) { 16 | Remove-Item -Recurse -Force $PSScriptRoot\build 17 | } 18 | 19 | if (Test-Path $PSScriptRoot\fend.msix) { 20 | Remove-Item -Force $PSScriptRoot\fend.msix 21 | } 22 | 23 | if (Test-Path $PSScriptRoot\fend-windows-x64.msix) { 24 | Remove-Item -Force $PSScriptRoot\fend-windows-x64.msix 25 | } 26 | 27 | mkdir $PSScriptRoot\build 28 | Copy-Item $PSScriptRoot\..\target\release\fend.exe $PSScriptRoot\build 29 | (Get-Content $PSScriptRoot\AppxManifest.xml).replace('$FEND_VERSION', $Env:FEND_VERSION) | Set-Content $PSScriptRoot\build\AppxManifest.xml 30 | & magick $PSScriptRoot\..\icon\icon.svg -resize 44x44 $PSScriptRoot\build\fend-icon-44.png 31 | & magick $PSScriptRoot\..\icon\icon.svg -resize 150x150 $PSScriptRoot\build\fend-icon-150.png 32 | 33 | & "C:\Program Files (x86)\Windows Kits\10\App Certification Kit\makeappx.exe" ` 34 | pack ` 35 | /d $PSScriptRoot\build ` 36 | /p $PSScriptRoot\fend.msix ` 37 | /verbose 38 | 39 | & "C:\Program Files (x86)\Windows Kits\10\App Certification Kit\signtool.exe" ` 40 | sign ` 41 | /fd SHA256 /a ` 42 | /f $PSScriptRoot\fend-signing-cert.pfx ` 43 | /p $Env:WINDOWS_CERT_PASSWORD ` 44 | $PSScriptRoot\fend.msix 45 | 46 | Move-Item $PSScriptRoot\fend.msix $PSScriptRoot\fend-windows-x64.msix 47 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fend-web", 3 | "private": true, 4 | "version": "0.0.0", 5 | "description": "Arbitrary-precision unit-aware calculator", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "tsc -b && vite build", 10 | "preview": "vite preview", 11 | "format": "prettier --write .", 12 | "lint": "eslint ." 13 | }, 14 | "dependencies": { 15 | "fend-wasm": "file:../wasm/fend-wasm", 16 | "react": "^19.2.0", 17 | "react-dom": "^19.2.0", 18 | "react-svg-spinners": "^0.3.1" 19 | }, 20 | "devDependencies": { 21 | "@eslint/js": "^9.39.1", 22 | "@types/node": "^24.10.1", 23 | "@types/react": "^19.2.3", 24 | "@types/react-dom": "^19.2.3", 25 | "@vitejs/plugin-react": "^5.1.1", 26 | "babel-plugin-react-compiler": "^1.0.0", 27 | "eslint": "^9.39.1", 28 | "eslint-plugin-react-hooks": "^7.0.1", 29 | "eslint-plugin-react-refresh": "^0.4.24", 30 | "globals": "^16.5.0", 31 | "prettier": "^3.6.2", 32 | "typescript": "^5.9.3", 33 | "typescript-eslint": "^8.46.4", 34 | "vite": "^7.2.2", 35 | "vite-plugin-wasm": "^3.5.0" 36 | }, 37 | "overrides": { 38 | "react-svg-spinners": { 39 | "react": "$react", 40 | "react-dom": "$react-dom" 41 | } 42 | }, 43 | "repository": { 44 | "type": "git", 45 | "url": "git+https://github.com/printfn/fend.git" 46 | }, 47 | "author": "printfn", 48 | "license": "MIT", 49 | "bugs": { 50 | "url": "https://github.com/printfn/fend/issues" 51 | }, 52 | "homepage": "https://github.com/printfn/fend#readme", 53 | "prettier": { 54 | "useTabs": true, 55 | "arrowParens": "avoid", 56 | "singleQuote": true, 57 | "printWidth": 120 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /documentation/chapters/scripting.md: -------------------------------------------------------------------------------- 1 | You can use `fend` programmatically using pipes or command-line arguments: 2 | 3 | ```bash 4 | $ echo "sin (pi/4)" | fend 5 | approx. 0.7071067811 6 | $ fend "sqrt 2" 7 | approx. 1.4142135619 8 | ``` 9 | 10 | The return code is 0 on success, or 1 if an error occurs during evaluation. 11 | 12 | You can also specify filenames directly on the command-line, like this: 13 | 14 | ```bash 15 | $ cat calculation.txt 16 | 16^2 17 | $ fend calculation.txt 18 | 256 19 | ``` 20 | 21 | By default, fend will automatically try to read in files, or fall back to 22 | evaluating expressions. This behavior can be overridden with these command-line 23 | options: 24 | 25 | * `-f` (or `--file`): read and evaluate the specified file 26 | * `-e` (or `--eval`) evaluate the specified expression 27 | 28 | For example: 29 | 30 | ``` 31 | $ cat calculation.txt 32 | 16^2 33 | $ fend calculation.txt 34 | 256 35 | $ fend -f calculation.txt 36 | 256 37 | $ fend -e calculation.txt 38 | Error: unknown identifier 'calculation.txt' 39 | ``` 40 | 41 | Or: 42 | 43 | ``` 44 | $ fend 1+1 45 | 2 46 | $ fend -f 1+1 47 | Error: No such file or directory (os error 2) 48 | $ fend -e 1+1 49 | 2 50 | ``` 51 | 52 | `-f` and `-e` can be specified multiple times, in which case fend will evaluate 53 | each specified expression one after the other. Any variables defined in earlier 54 | expressions can be used by later expressions: 55 | 56 | ```bash 57 | $ fend -e "a = 5" -e "2a" 58 | 10 59 | ``` 60 | 61 | Trailing newlines can be omitted by prefixing the calculation with 62 | `@no_trailing_newline`, like so: 63 | 64 | ```bash 65 | $ fend @no_trailing_newline 5+5 66 | 10 67 | ``` 68 | -------------------------------------------------------------------------------- /core/src/date/day_of_week.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | error::FendError, 3 | result::FResult, 4 | serialize::{Deserialize, Serialize}, 5 | }; 6 | use std::{fmt, io}; 7 | 8 | #[derive(Clone, Copy, PartialEq, Eq)] 9 | pub(crate) enum DayOfWeek { 10 | Sunday, 11 | Monday, 12 | Tuesday, 13 | Wednesday, 14 | Thursday, 15 | Friday, 16 | Saturday, 17 | } 18 | 19 | impl fmt::Debug for DayOfWeek { 20 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 21 | let s = match self { 22 | Self::Sunday => "Sunday", 23 | Self::Monday => "Monday", 24 | Self::Tuesday => "Tuesday", 25 | Self::Wednesday => "Wednesday", 26 | Self::Thursday => "Thursday", 27 | Self::Friday => "Friday", 28 | Self::Saturday => "Saturday", 29 | }; 30 | write!(f, "{s}") 31 | } 32 | } 33 | 34 | impl DayOfWeek { 35 | pub(crate) fn as_u8(self) -> u8 { 36 | match self { 37 | Self::Sunday => 0, 38 | Self::Monday => 1, 39 | Self::Tuesday => 2, 40 | Self::Wednesday => 3, 41 | Self::Thursday => 4, 42 | Self::Friday => 5, 43 | Self::Saturday => 6, 44 | } 45 | } 46 | 47 | pub(crate) fn serialize(self, write: &mut impl io::Write) -> FResult<()> { 48 | self.as_u8().serialize(write)?; 49 | Ok(()) 50 | } 51 | 52 | pub(crate) fn deserialize(read: &mut impl io::Read) -> FResult { 53 | Ok(match u8::deserialize(read)? { 54 | 0 => Self::Sunday, 55 | 1 => Self::Monday, 56 | 2 => Self::Tuesday, 57 | 3 => Self::Wednesday, 58 | 4 => Self::Thursday, 59 | 5 => Self::Friday, 60 | 6 => Self::Saturday, 61 | _ => { 62 | return Err(FendError::DeserializationError( 63 | "day of week is out of range", 64 | )); 65 | } 66 | }) 67 | } 68 | } 69 | 70 | impl fmt::Display for DayOfWeek { 71 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 72 | write!(f, "{self:?}") 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /core/src/num/exact.rs: -------------------------------------------------------------------------------- 1 | // helper struct for keeping track of which values are exact 2 | 3 | use std::fmt; 4 | use std::ops::Neg; 5 | 6 | #[derive(Copy, Clone)] 7 | pub(crate) struct Exact { 8 | pub(crate) value: T, 9 | pub(crate) exact: bool, 10 | } 11 | 12 | impl fmt::Debug for Exact { 13 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 14 | if self.exact { 15 | write!(f, "exactly ")?; 16 | } else { 17 | write!(f, "approx. ")?; 18 | } 19 | write!(f, "{:?}", self.value)?; 20 | Ok(()) 21 | } 22 | } 23 | 24 | impl Exact { 25 | pub(crate) fn new(value: T, exact: bool) -> Self { 26 | Self { value, exact } 27 | } 28 | 29 | pub(crate) fn apply R>(self, f: F) -> Exact { 30 | Exact:: { 31 | value: f(self.value), 32 | exact: self.exact, 33 | } 34 | } 35 | 36 | pub(crate) fn try_and_then< 37 | R: fmt::Debug, 38 | E: fmt::Debug, 39 | F: FnOnce(T) -> Result, E>, 40 | >( 41 | self, 42 | f: F, 43 | ) -> Result, E> { 44 | Ok(f(self.value)?.combine(self.exact)) 45 | } 46 | 47 | pub(crate) fn combine(self, x: bool) -> Self { 48 | Self { 49 | value: self.value, 50 | exact: self.exact && x, 51 | } 52 | } 53 | 54 | pub(crate) fn re<'a>(&'a self) -> Exact<&'a T> { 55 | Exact::<&'a T> { 56 | value: &self.value, 57 | exact: self.exact, 58 | } 59 | } 60 | } 61 | 62 | impl Exact<(A, B)> { 63 | pub(crate) fn pair(self) -> (Exact, Exact) { 64 | ( 65 | Exact { 66 | value: self.value.0, 67 | exact: self.exact, 68 | }, 69 | Exact { 70 | value: self.value.1, 71 | exact: self.exact, 72 | }, 73 | ) 74 | } 75 | } 76 | 77 | impl> Neg for Exact { 78 | type Output = Self; 79 | fn neg(self) -> Self { 80 | Self { 81 | value: -self.value, 82 | exact: self.exact, 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guide 2 | 3 | Thank you for contributing to fend! 4 | 5 | Feel free to make any change you'd consider useful. If you need inspiration, you can 6 | always look at the [GitHub Issue List](https://github.com/printfn/fend/issues). 7 | 8 | Remember that fend is not primarily designed to be a programming language, but 9 | intended to be an easy-to-use math/calculator tool. Ease-of-use is more important 10 | than any internal consistency in the codebase. 11 | 12 | This repository has a large number of unit and integration tests, for example 13 | in [`core/tests/integration_tests.rs`](https://github.com/printfn/fend/blob/main/core/tests/integration_tests.rs). 14 | While the tests are really useful for finding bugs and accidental regressions, 15 | try not to take them as gospel! If you need to change any test, don't hesitate 16 | to do so. User-friendliness is more important than strict backwards compatibility. 17 | **Be bold!!** 18 | 19 | If you want to add a unit definition to fend, take a look at 20 | [`core/src/units/builtin.rs`](https://github.com/printfn/fend/blob/main/core/src/units/builtin.rs). 21 | 22 | The repository is organised as a cargo workspace. `core` contains the fend back-end, 23 | performs all the actual calculations, and exposes a small Rust API. It also contains 24 | many unit and integration tests. `cli` depends on `core` and provides an interactive 25 | command-line UI for fend. `wasm` contains Web Assembly bindings to fend, and provides 26 | a JavaScript API. `web` contains code for the website 27 | [printfn.github.io/fend](https://printfn.github.io/fend), which always 28 | updates based on the `main` branch of this repository. 29 | 30 | Make sure to run `cargo fmt` and `cargo clippy` before committing. If a particular 31 | Clippy warning is hard to get rid of, you can always use an `#[allow(...)]` attribute. 32 | To run unit and integration tests, run `cargo test`. These commands will automatically 33 | apply to all Rust crates in the workspace. 34 | -------------------------------------------------------------------------------- /windows-msix/README.md: -------------------------------------------------------------------------------- 1 | # Windows MSIX Installer 2 | 3 | This folder contains the necessary files to build an MSIX installer for Windows. 4 | 5 | ## Certificate 6 | 7 | Building fend requires a valid certificate. See 8 | [here](https://docs.microsoft.com/en-us/windows/msix/package/create-certificate-package-signing) 9 | for more info. 10 | 11 | This command will create a new self-signed certificate: 12 | 13 | ```ps1 14 | New-SelfSignedCertificate -Type Custom -Subject "CN=printfn, O=printfn" -KeyUsage DigitalSignature -FriendlyName "fend package signing certificate" -CertStoreLocation "Cert:\CurrentUser\My" -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3", "2.5.29.19={text}") -NotAfter (Get-Date).AddYears(10) 15 | ``` 16 | 17 | Note the returned thumbprint. 18 | 19 | This will export the certificate to a local password-protected file: 20 | 21 | ```ps1 22 | $PFXPass = ConvertTo-SecureString -String "MyPassword" -Force -AsPlainText 23 | Export-PfxCertificate -Cert cert:\CurrentUser\My\96315AAFF3C6464216DFAC29F1319E27096ED71E -Password $PFXPass -FilePath fend-signing-cert.pfx 24 | ``` 25 | 26 | ## Installation 27 | 28 | Because fend is signed with a self-signed certificate, the 29 | certificate needs to be trusted before installation. 30 | These steps are needed to trust fend's certificate: 31 | 32 | 1. Right-click the MSIX file, and open the "Properties" window 33 | 2. Open to the "Digital Signatures" tab 34 | 3. Select the signature and click on "Details" 35 | 4. In the "General" tab, click on "View Certificate" 36 | 5. In the "General" tab, click on "Install Certificate..." 37 | 6. Change the store location to "**Local Machine**", then click "Next" 38 | 7. Choose the option "Place all certificates in the following store", then click on "Browse..." and select the "**Trusted People**" store. The checkbox "Show physical stores" should be disabled. Then click on "OK" to confirm the store, and click on "Next". 39 | 8. Click on "Finish" to import the certificate. 40 | 9. Close the other windows, and proceed to install fend by double-clicking the MSIX file. 41 | -------------------------------------------------------------------------------- /windows-msix/AppxManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | fend 11 | printfn 12 | Arbitrary-precision unit-aware calculator 13 | fend-icon-150.png 14 | 15 | 16 | 17 | 18 | 19 | 23 | 24 | 25 | 26 | 27 | 28 | 33 | 38 | 39 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /web/src/hooks/useCurrentInput.ts: -------------------------------------------------------------------------------- 1 | import { FormEvent, startTransition, useCallback, useState } from 'react'; 2 | import { useHistory } from './useHistory'; 3 | 4 | export function useCurrentInput(evaluateHint: (input: string) => Promise) { 5 | const { history, addToHistory } = useHistory(); 6 | const [currentInput, setCurrentInputInternal] = useState(''); 7 | const [navigation, setNavigation] = useState(0); 8 | const [hint, setHint] = useState(''); 9 | 10 | const setCurrentInput = useCallback( 11 | (value: string) => { 12 | setCurrentInputInternal(value); 13 | startTransition(async () => { 14 | setHint(await evaluateHint(value)); 15 | }); 16 | }, 17 | [evaluateHint], 18 | ); 19 | 20 | const navigate = useCallback( 21 | (direction: 'up' | 'down') => { 22 | setNavigation(n => { 23 | let newValue: number; 24 | switch (direction) { 25 | case 'up': 26 | newValue = Math.min(n + 1, history.length); 27 | break; 28 | case 'down': 29 | newValue = Math.max(n - 1, 0); 30 | break; 31 | } 32 | if (newValue > 0) { 33 | setCurrentInput(history[history.length - newValue]); 34 | } 35 | if (newValue === 0) { 36 | setCurrentInput(''); 37 | } 38 | return newValue; 39 | }); 40 | }, 41 | [history, setCurrentInput], 42 | ); 43 | 44 | const onInput = useCallback( 45 | (e: string | FormEvent) => { 46 | setNavigation(0); 47 | setCurrentInput(typeof e === 'string' ? e : e.currentTarget.value); 48 | }, 49 | [setCurrentInput], 50 | ); 51 | 52 | const upArrow = useCallback(() => { 53 | if (currentInput.trim().length !== 0 && navigation === 0) { 54 | // todo we should allow navigating history if input has been typed 55 | return; 56 | } 57 | navigate('up'); 58 | }, [currentInput, navigate, navigation]); 59 | 60 | const downArrow = useCallback(() => { 61 | navigate('down'); 62 | }, [navigate]); 63 | 64 | const submit = useCallback(() => { 65 | addToHistory(currentInput); 66 | setNavigation(0); 67 | }, [currentInput, addToHistory]); 68 | 69 | return { currentInput, submit, onInput, downArrow, upArrow, hint }; 70 | } 71 | -------------------------------------------------------------------------------- /documentation/include-code-files.lua: -------------------------------------------------------------------------------- 1 | --- modified from 2 | --- https://github.com/pandoc/lua-filters/blob/master/include-code-files/include-code-files.lua 3 | --- commit 8b61648e1c69a0455b0d7e4229288ee6fa63bb21 from 2021-11-13 4 | 5 | --- include-code-files.lua – filter to include code from source files 6 | --- 7 | --- Copyright: © 2020 Bruno BEAUFILS 8 | --- License: MIT – see LICENSE file for details 9 | 10 | --- Dedent a line 11 | local function dedent (line, n) 12 | return line:sub(1,n):gsub(" ","") .. line:sub(n+1) 13 | end 14 | 15 | --- Filter function for code blocks 16 | local function transclude (cb) 17 | if cb.attributes.include then 18 | local content = "" 19 | local fh = io.open(cb.attributes.include) 20 | if not fh then 21 | io.stderr:write("Cannot open file " .. cb.attributes.include .. " | Skipping includes\n") 22 | error("Cannot open file " .. cb.attributes.include) 23 | else 24 | local number = 1 25 | local start = 1 26 | 27 | -- change hyphenated attributes to PascalCase 28 | for i,pascal in pairs({"startLine", "endLine"}) 29 | do 30 | local hyphen = pascal:gsub("%u", "-%0"):lower() 31 | if cb.attributes[hyphen] then 32 | cb.attributes[pascal] = cb.attributes[hyphen] 33 | cb.attributes[hyphen] = nil 34 | end 35 | end 36 | 37 | if cb.attributes.startLine then 38 | cb.attributes.startFrom = cb.attributes.startLine 39 | start = tonumber(cb.attributes.startLine) 40 | end 41 | for line in fh:lines ("L") 42 | do 43 | if cb.attributes.dedent then 44 | line = dedent(line, cb.attributes.dedent) 45 | end 46 | if number >= start then 47 | if not cb.attributes.endLine or number <= tonumber(cb.attributes.endLine) then 48 | content = content .. line 49 | end 50 | end 51 | number = number + 1 52 | end 53 | fh:close() 54 | end 55 | -- remove key-value pair for used keys 56 | cb.attributes.include = nil 57 | cb.attributes.startLine = nil 58 | cb.attributes.endLine = nil 59 | cb.attributes.dedent = nil 60 | -- return final code block 61 | return pandoc.CodeBlock(content, cb.attr) 62 | end 63 | end 64 | 65 | return { 66 | { CodeBlock = transclude } 67 | } 68 | -------------------------------------------------------------------------------- /cli/src/helper.rs: -------------------------------------------------------------------------------- 1 | use tokio::runtime::Handle; 2 | 3 | use crate::{config, context::Context}; 4 | 5 | pub struct Hint(String); 6 | 7 | impl rustyline::hint::Hint for Hint { 8 | fn display(&self) -> &str { 9 | self.0.as_str() 10 | } 11 | 12 | fn completion(&self) -> Option<&str> { 13 | None 14 | } 15 | } 16 | 17 | pub struct Helper<'a> { 18 | ctx: Context<'a>, 19 | config: &'a config::Config, 20 | } 21 | 22 | impl<'a> Helper<'a> { 23 | pub fn new(ctx: Context<'a>, config: &'a config::Config) -> Self { 24 | Self { ctx, config } 25 | } 26 | } 27 | 28 | impl rustyline::hint::Hinter for Helper<'_> { 29 | type Hint = Hint; 30 | 31 | fn hint(&self, line: &str, _pos: usize, _ctx: &rustyline::Context<'_>) -> Option { 32 | tokio::task::block_in_place(|| { 33 | let rt = Handle::current(); 34 | rt.block_on(async { 35 | let result = self.ctx.eval_hint(line).await; 36 | let s = result.get_main_result(); 37 | Some(if s.is_empty() { 38 | return None; 39 | } else if self.config.enable_colors { 40 | Hint(format!( 41 | "\n{}", 42 | crate::print_spans(result.get_main_result_spans().collect(), self.config) 43 | )) 44 | } else { 45 | Hint(format!("\n{s}")) 46 | }) 47 | }) 48 | }) 49 | } 50 | } 51 | 52 | impl rustyline::highlight::Highlighter for Helper<'_> {} 53 | 54 | impl rustyline::validate::Validator for Helper<'_> {} 55 | 56 | #[derive(Debug)] 57 | pub struct FendCandidate { 58 | completion: fend_core::Completion, 59 | } 60 | impl rustyline::completion::Candidate for FendCandidate { 61 | fn display(&self) -> &str { 62 | self.completion.display() 63 | } 64 | fn replacement(&self) -> &str { 65 | self.completion.insert() 66 | } 67 | } 68 | 69 | impl rustyline::completion::Completer for Helper<'_> { 70 | type Candidate = FendCandidate; 71 | 72 | fn complete( 73 | &self, 74 | line: &str, 75 | pos: usize, 76 | _ctx: &rustyline::Context<'_>, 77 | ) -> rustyline::Result<(usize, Vec)> { 78 | let (pos, completions) = fend_core::get_completions_for_prefix(&line[..pos]); 79 | let v: Vec<_> = completions 80 | .into_iter() 81 | .map(|c| FendCandidate { completion: c }) 82 | .collect(); 83 | Ok((pos, v)) 84 | } 85 | } 86 | 87 | impl rustyline::Helper for Helper<'_> {} 88 | -------------------------------------------------------------------------------- /core/src/num.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | mod base; 4 | mod bigrat; 5 | mod biguint; 6 | mod complex; 7 | #[allow(unused_macros, unused_variables, dead_code)] 8 | mod continued_fraction; 9 | mod dist; 10 | mod exact; 11 | mod formatting_style; 12 | mod real; 13 | mod unit; 14 | 15 | pub(crate) use formatting_style::FormattingStyle; 16 | 17 | use crate::error::FendError; 18 | 19 | pub(crate) type Number = unit::Value; 20 | pub(crate) type Base = base::Base; 21 | pub(crate) type Exact = exact::Exact; 22 | 23 | #[derive(Debug)] 24 | pub(crate) enum RangeBound { 25 | None, 26 | Open(T), 27 | Closed(T), 28 | } 29 | 30 | impl RangeBound { 31 | fn into_dyn(self) -> RangeBound> { 32 | match self { 33 | Self::None => RangeBound::None, 34 | Self::Open(v) => RangeBound::Open(Box::new(v)), 35 | Self::Closed(v) => RangeBound::Closed(Box::new(v)), 36 | } 37 | } 38 | } 39 | 40 | #[derive(Debug)] 41 | pub(crate) struct Range { 42 | pub(crate) start: RangeBound, 43 | pub(crate) end: RangeBound, 44 | } 45 | 46 | impl Range { 47 | pub(crate) fn open(start: T, end: T) -> Self { 48 | Self { 49 | start: RangeBound::Open(start), 50 | end: RangeBound::Open(end), 51 | } 52 | } 53 | } 54 | 55 | impl Range { 56 | const ZERO_OR_GREATER: Self = Self { 57 | start: RangeBound::Closed(0), 58 | end: RangeBound::None, 59 | }; 60 | } 61 | 62 | impl fmt::Display for Range { 63 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { 64 | match &self.start { 65 | RangeBound::None => write!(f, "(-\u{221e}, ")?, // infinity symbol 66 | RangeBound::Open(v) => write!(f, "({v}, ")?, 67 | RangeBound::Closed(v) => write!(f, "[{v}, ")?, 68 | } 69 | match &self.end { 70 | RangeBound::None => write!(f, "\u{221e})")?, 71 | RangeBound::Open(v) => write!(f, "{v})")?, 72 | RangeBound::Closed(v) => write!(f, "{v}]")?, 73 | } 74 | Ok(()) 75 | } 76 | } 77 | 78 | fn out_of_range< 79 | T: fmt::Display + fmt::Debug + Send + Sync + 'static, 80 | U: fmt::Display + fmt::Debug + Send + Sync + 'static, 81 | >( 82 | value: T, 83 | range: Range, 84 | ) -> FendError { 85 | FendError::OutOfRange { 86 | value: Box::new(value), 87 | range: Range { 88 | start: range.start.into_dyn(), 89 | end: range.end.into_dyn(), 90 | }, 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /cli/src/color/output_colors.rs: -------------------------------------------------------------------------------- 1 | use super::{base::Base, style::Color}; 2 | use std::collections; 3 | 4 | #[derive(Debug, Default)] 5 | pub struct OutputColors { 6 | styles: collections::HashMap, 7 | } 8 | 9 | impl<'de> serde::Deserialize<'de> for OutputColors { 10 | fn deserialize>(deserializer: D) -> Result { 11 | Ok(OutputColors { 12 | styles: collections::HashMap::deserialize(deserializer)?, 13 | }) 14 | } 15 | } 16 | 17 | impl PartialEq for OutputColors { 18 | fn eq(&self, other: &Self) -> bool { 19 | self.get_style("number") == other.get_style("number") 20 | && self.get_style("string") == other.get_style("string") 21 | && self.get_style("identifier") == other.get_style("identifier") 22 | && self.get_style("keyword") == other.get_style("keyword") 23 | && self.get_style("built-in-function") == other.get_style("built-in-function") 24 | && self.get_style("date") == other.get_style("date") 25 | && self.get_style("other") == other.get_style("other") 26 | } 27 | } 28 | 29 | impl Eq for OutputColors {} 30 | 31 | impl OutputColors { 32 | fn get_style(&self, name: &str) -> Color { 33 | self.styles.get(name).cloned().unwrap_or_else(|| { 34 | match name { 35 | "number" | "date" | "string" | "other" => Color::default(), 36 | "identifier" => Color::new(Base::White), 37 | "keyword" | "built-in-function" => Color::bold(Base::Blue), 38 | _ => { 39 | // this should never happen 40 | Color::default() 41 | } 42 | } 43 | }) 44 | } 45 | 46 | pub fn print_warnings_about_unknown_keys(&self) { 47 | for (key, style) in &self.styles { 48 | if !matches!( 49 | key.as_str(), 50 | "number" 51 | | "string" | "identifier" 52 | | "keyword" | "built-in-function" 53 | | "date" | "other" 54 | ) { 55 | eprintln!("Warning: ignoring unknown configuration setting `colors.{key}`"); 56 | } 57 | style.print_warnings_about_unknown_keys(key); 58 | } 59 | } 60 | 61 | pub fn get_color(&self, kind: fend_core::SpanKind) -> String { 62 | use fend_core::SpanKind; 63 | 64 | match kind { 65 | SpanKind::Number => self.get_style("number").to_ansi(), 66 | SpanKind::String => self.get_style("string").to_ansi(), 67 | SpanKind::Ident => self.get_style("identifier").to_ansi(), 68 | SpanKind::Keyword => self.get_style("keyword").to_ansi(), 69 | SpanKind::BuiltInFunction => self.get_style("built_in_function").to_ansi(), 70 | SpanKind::Date => self.get_style("date").to_ansi(), 71 | _ => self.get_style("other").to_ansi(), 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /core/src/date/year.rs: -------------------------------------------------------------------------------- 1 | use std::{convert, fmt, io}; 2 | 3 | use crate::{ 4 | error::FendError, 5 | result::FResult, 6 | serialize::{Deserialize, Serialize}, 7 | }; 8 | 9 | #[derive(Copy, Clone, Eq, PartialEq)] 10 | pub(crate) struct Year(i32); 11 | 12 | impl Year { 13 | pub(crate) fn new(year: i32) -> Self { 14 | assert!(year != 0, "year 0 is invalid"); 15 | Self(year) 16 | } 17 | 18 | #[inline] 19 | pub(crate) fn value(self) -> i32 { 20 | self.0 21 | } 22 | 23 | pub(crate) fn next(self) -> Self { 24 | if self.value() == -1 { 25 | Self::new(1) 26 | } else { 27 | Self::new(self.value() + 1) 28 | } 29 | } 30 | 31 | pub(crate) fn prev(self) -> Self { 32 | if self.value() == 1 { 33 | Self::new(-1) 34 | } else { 35 | Self::new(self.value() - 1) 36 | } 37 | } 38 | 39 | pub(crate) fn is_leap_year(self) -> bool { 40 | if self.value() % 400 == 0 { 41 | true 42 | } else if self.value() % 100 == 0 { 43 | false 44 | } else { 45 | self.value() % 4 == 0 46 | } 47 | } 48 | 49 | pub(crate) fn number_of_days(self) -> u16 { 50 | if self.is_leap_year() { 366 } else { 365 } 51 | } 52 | 53 | pub(crate) fn serialize(self, write: &mut impl io::Write) -> FResult<()> { 54 | self.value().serialize(write)?; 55 | Ok(()) 56 | } 57 | 58 | pub(crate) fn deserialize(read: &mut impl io::Read) -> FResult { 59 | Self::try_from(i32::deserialize(read)?) 60 | .map_err(|_| FendError::DeserializationError("year is out of range")) 61 | } 62 | } 63 | 64 | pub(crate) struct InvalidYearError; 65 | 66 | impl convert::TryFrom for Year { 67 | type Error = InvalidYearError; 68 | 69 | fn try_from(year: i32) -> Result { 70 | if year == 0 { 71 | Err(InvalidYearError) 72 | } else { 73 | Ok(Self(year)) 74 | } 75 | } 76 | } 77 | 78 | impl fmt::Debug for Year { 79 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 80 | if self.value() < 0 { 81 | write!(f, "{} BC", -self.0) 82 | } else { 83 | write!(f, "{}", self.0) 84 | } 85 | } 86 | } 87 | 88 | impl fmt::Display for Year { 89 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 90 | if self.value() < 0 { 91 | write!(f, "{} BC", -self.0) 92 | } else { 93 | write!(f, "{}", self.0) 94 | } 95 | } 96 | } 97 | 98 | #[cfg(test)] 99 | mod tests { 100 | use super::*; 101 | 102 | #[test] 103 | #[should_panic(expected = "year 0 is invalid")] 104 | fn year_0() { 105 | Year::new(0); 106 | } 107 | 108 | #[test] 109 | fn negative_year_string() { 110 | assert_eq!(Year::new(-823).to_string(), "823 BC"); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /core/src/date/parser.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | date::{Date, Day, Month, Year}, 3 | error::FendError, 4 | result::FResult, 5 | }; 6 | use std::convert; 7 | 8 | fn parse_char(s: &str) -> Result<(char, &str), ()> { 9 | let ch = s.chars().next().ok_or(())?; 10 | let (_, b) = s.split_at(ch.len_utf8()); 11 | Ok((ch, b)) 12 | } 13 | 14 | fn parse_specific_char(s: &str, c: char) -> Result<&str, ()> { 15 | let (ch, s) = parse_char(s)?; 16 | if ch == c { Ok(s) } else { Err(()) } 17 | } 18 | 19 | fn parse_digit(s: &str) -> Result<(i32, &str), ()> { 20 | let (ch, b) = parse_char(s)?; 21 | let digit = ch.to_digit(10).ok_or(())?; 22 | let digit_i32: i32 = convert::TryInto::try_into(digit).map_err(|_| ())?; 23 | Ok((digit_i32, b)) 24 | } 25 | 26 | fn parse_num(s: &str, leading_zeroes: bool) -> Result<(i32, &str), ()> { 27 | let (mut num, mut s) = parse_digit(s)?; 28 | if !leading_zeroes && num == 0 { 29 | return Err(()); 30 | } 31 | while let Ok((digit, remaining)) = parse_digit(s) { 32 | num = num.checked_mul(10).ok_or(())?; 33 | num = num.checked_add(digit).ok_or(())?; 34 | s = remaining; 35 | } 36 | Ok((num, s)) 37 | } 38 | 39 | fn parse_yyyymmdd(s: &str) -> Result<(Date, &str), ()> { 40 | let (year, s) = parse_num(s, false)?; 41 | let s = parse_specific_char(s, '-')?; 42 | if year < 1000 { 43 | return Err(()); 44 | } 45 | let year = Year::new(year); 46 | let (month, s) = parse_num(s, true)?; 47 | let s = parse_specific_char(s, '-')?; 48 | let month: u8 = month.try_into().map_err(|_| ())?; 49 | let month: Month = convert::TryInto::try_into(month).map_err(|_| ())?; 50 | let (day, s) = parse_num(s, true)?; 51 | if day < 1 || day > i32::from(month.number_of_days(year)) { 52 | return Err(()); 53 | } 54 | let day: u8 = convert::TryInto::try_into(day).map_err(|_| ())?; 55 | let day = Day::new(day); 56 | Ok((Date { year, month, day }, s)) 57 | } 58 | 59 | pub(crate) fn parse_date(s: &str) -> FResult { 60 | let trimmed = s.trim(); 61 | if let Ok((date, remaining)) = parse_yyyymmdd(trimmed) 62 | && remaining.is_empty() 63 | { 64 | return Ok(date); 65 | } 66 | Err(FendError::ParseDateError(s.to_string())) 67 | } 68 | 69 | #[cfg(test)] 70 | mod tests { 71 | use super::*; 72 | 73 | #[test] 74 | fn parse_date_tests() { 75 | parse_date("2021-04-14").unwrap(); 76 | parse_date("2021-4-14").unwrap(); 77 | parse_date("9999-12-31").unwrap(); 78 | parse_date("1000-01-01").unwrap(); 79 | parse_date("1000-1-1").unwrap(); 80 | parse_date("10000-1-1").unwrap(); 81 | parse_date("214748363-1-1").unwrap(); 82 | parse_date("2147483647-1-1").unwrap(); 83 | 84 | parse_date("999-01-01").unwrap_err(); 85 | parse_date("2021-02-29").unwrap_err(); 86 | parse_date("2100-02-29").unwrap_err(); 87 | parse_date("7453-13-01").unwrap_err(); 88 | parse_date("2147483648-1-1").unwrap_err(); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /web/src/lib/fend.ts: -------------------------------------------------------------------------------- 1 | import { getExchangeRates } from './exchange-rates'; 2 | import type { FendArgs, FendResult } from './worker'; 3 | import MyWorker from './worker?worker'; 4 | 5 | function newAbortError(message: string) { 6 | const e = new Error(message); 7 | e.name = 'AbortError'; 8 | return e; 9 | } 10 | 11 | type State = 'new' | 'ready' | 'busy'; 12 | type WorkerCache = { 13 | worker: Worker; 14 | state: State; 15 | initialisedPromise: Promise; 16 | resolveDone?: (r: FendResult) => void; 17 | rejectError?: (e: Error) => void; 18 | }; 19 | 20 | function init() { 21 | let resolveInitialised: () => void; 22 | const result: WorkerCache = { 23 | state: 'new', 24 | worker: new MyWorker({ 25 | name: 'fend worker', 26 | }), 27 | initialisedPromise: new Promise(resolve => { 28 | resolveInitialised = resolve; 29 | }), 30 | }; 31 | result.worker.onmessage = (e: MessageEvent) => { 32 | result.state = 'ready'; 33 | if (e.data === 'ready') { 34 | resolveInitialised(); 35 | } else { 36 | result.resolveDone?.(e.data); 37 | } 38 | }; 39 | result.worker.onerror = e => { 40 | result.state = 'ready'; 41 | result.rejectError?.(new Error(e.message, { cause: e })); 42 | }; 43 | result.worker.onmessageerror = e => { 44 | result.state = 'ready'; 45 | result.rejectError?.(new Error('received messageerror event', { cause: e })); 46 | }; 47 | return result; 48 | } 49 | 50 | let workerCache: WorkerCache = init(); 51 | let id = 0; 52 | 53 | async function query(args: FendArgs) { 54 | let w = workerCache; 55 | const i = ++id; 56 | if (w.state === 'new') { 57 | await w.initialisedPromise; 58 | if (i < id) { 59 | throw newAbortError('created new worker during initialisation'); 60 | } 61 | } 62 | if (w.state === 'busy') { 63 | console.log('terminating existing worker'); 64 | w.worker.terminate(); 65 | w.resolveDone?.({ ok: false, message: 'cancelled' }); 66 | w = init(); 67 | workerCache = w; 68 | await w.initialisedPromise; 69 | if (i < id) { 70 | throw newAbortError('created new worker while worker was busy'); 71 | } 72 | } 73 | if (w.state !== 'ready') { 74 | throw new Error('unexpected worker state: ' + w.state); 75 | } 76 | const p = new Promise((resolve, reject) => { 77 | w.resolveDone = resolve; 78 | w.rejectError = reject; 79 | }); 80 | w.state = 'busy'; 81 | w.worker.postMessage(args); 82 | return await p; 83 | } 84 | 85 | export async function fend(input: string, timeout: number, variables: string): Promise { 86 | try { 87 | const currencyData = await getExchangeRates(); 88 | const args: FendArgs = { input, timeout, variables, currencyData }; 89 | return await query(args); 90 | } catch (e) { 91 | if (e instanceof Error && e.name === 'AbortError') { 92 | return { ok: false, message: 'Aborted' }; 93 | } 94 | console.error(e); 95 | alert('Failed to initialise WebAssembly'); 96 | return { ok: false, message: 'Failed to initialise WebAssembly' }; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /core/src/eval.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use crate::{ 4 | Span, ast, error::Interrupt, lexer, parser, result::FResult, scope::Scope, value::Value, 5 | }; 6 | 7 | pub(crate) fn evaluate_to_value( 8 | input: &str, 9 | scope: Option>, 10 | attrs: Attrs, 11 | spans: &mut Vec, 12 | context: &mut crate::Context, 13 | int: &I, 14 | ) -> FResult { 15 | let lex = lexer::lex(input, context, int); 16 | let mut tokens = vec![]; 17 | let mut missing_open_parens: i32 = 0; 18 | for token in lex { 19 | let token = token?; 20 | if matches!(token, lexer::Token::Symbol(lexer::Symbol::CloseParens)) { 21 | missing_open_parens += 1; 22 | } 23 | tokens.push(token); 24 | } 25 | for _ in 0..missing_open_parens { 26 | tokens.insert(0, lexer::Token::Symbol(lexer::Symbol::OpenParens)); 27 | } 28 | let parsed = parser::parse_tokens(&tokens)?; 29 | let result = ast::evaluate(parsed, scope, attrs, spans, context, int)?; 30 | Ok(result) 31 | } 32 | 33 | #[derive(Clone, Copy, Eq, PartialEq, Debug)] 34 | #[allow(clippy::struct_excessive_bools)] 35 | pub(crate) struct Attrs { 36 | pub(crate) debug: bool, 37 | pub(crate) show_approx: bool, 38 | pub(crate) plain_number: bool, 39 | pub(crate) trailing_newline: bool, 40 | } 41 | 42 | impl Default for Attrs { 43 | fn default() -> Self { 44 | Self { 45 | debug: false, 46 | show_approx: true, 47 | plain_number: false, 48 | trailing_newline: true, 49 | } 50 | } 51 | } 52 | 53 | fn parse_attrs(mut input: &str) -> (Attrs, &str) { 54 | let mut attrs = Attrs::default(); 55 | while input.starts_with('@') { 56 | if let Some(remaining) = input.strip_prefix("@debug ") { 57 | attrs.debug = true; 58 | input = remaining; 59 | } else if let Some(remaining) = input.strip_prefix("@noapprox ") { 60 | attrs.show_approx = false; 61 | input = remaining; 62 | } else if let Some(remaining) = input.strip_prefix("@plain_number ") { 63 | attrs.plain_number = true; 64 | input = remaining; 65 | } else if let Some(remaining) = input.strip_prefix("@no_trailing_newline ") { 66 | attrs.trailing_newline = false; 67 | input = remaining; 68 | } else { 69 | break; 70 | } 71 | } 72 | (attrs, input) 73 | } 74 | 75 | /// This also saves the calculation result in a variable `_` and `ans` 76 | pub(crate) fn evaluate_to_spans( 77 | input: &str, 78 | scope: Option>, 79 | context: &mut crate::Context, 80 | int: &I, 81 | ) -> FResult<(Vec, Attrs)> { 82 | let (attrs, input) = parse_attrs(input); 83 | let mut spans = vec![]; 84 | let value = evaluate_to_value(input, scope, attrs, &mut spans, context, int)?; 85 | context.variables.insert("_".to_string(), value.clone()); 86 | context.variables.insert("ans".to_string(), value.clone()); 87 | Ok(( 88 | if attrs.debug { 89 | vec![Span::from_string(format!("{value:?}"))] 90 | } else { 91 | if context.echo_result { 92 | value.format(0, &mut spans, attrs, false, context, int)?; 93 | } 94 | spans 95 | }, 96 | attrs, 97 | )) 98 | } 99 | -------------------------------------------------------------------------------- /cli/src/terminal.rs: -------------------------------------------------------------------------------- 1 | use crate::{config, context, file_paths, helper}; 2 | use std::{error, io, mem, path}; 3 | 4 | // contains wrapper code for terminal handling, using third-party 5 | // libraries where necessary 6 | 7 | pub fn is_terminal_stdout() -> bool { 8 | // check if stdout is a tty (which affects whether to show colors) 9 | std::io::IsTerminal::is_terminal(&std::io::stdout()) 10 | } 11 | 12 | pub fn is_terminal_stdin() -> bool { 13 | // check if stdin is a tty (used for whether to show an 14 | // interactive prompt) 15 | std::io::IsTerminal::is_terminal(&std::io::stdin()) 16 | } 17 | 18 | pub struct PromptState<'a> { 19 | rl: rustyline::Editor, rustyline::history::FileHistory>, 20 | config: &'a config::Config, 21 | history_path: Option, 22 | } 23 | 24 | pub fn init_prompt<'a>( 25 | config: &'a config::Config, 26 | context: &context::Context<'a>, 27 | ) -> Result, Box> { 28 | use rustyline::{ 29 | Cmd, Editor, KeyCode, KeyEvent, Modifiers, Movement, config::Builder, history::FileHistory, 30 | }; 31 | 32 | let mut rl = Editor::, FileHistory>::with_config( 33 | Builder::new() 34 | .history_ignore_space(true) 35 | .auto_add_history(true) 36 | .max_history_size(config.max_history_size)? 37 | .build(), 38 | )?; 39 | rl.set_helper(Some(helper::Helper::new(context.clone(), config))); 40 | rl.bind_sequence( 41 | KeyEvent(KeyCode::Right, Modifiers::NONE), 42 | Cmd::Move(Movement::ForwardChar(1)), 43 | ); 44 | let history_path = match file_paths::get_history_file_location(file_paths::DirMode::DontCreate) 45 | { 46 | Ok(history_path) => { 47 | // ignore error if e.g. no history file exists 48 | mem::drop(rl.load_history(history_path.as_path())); 49 | Some(history_path) 50 | } 51 | Err(_) => None, 52 | }; 53 | Ok(PromptState { 54 | rl, 55 | config, 56 | history_path, 57 | }) 58 | } 59 | 60 | pub enum ReadLineError { 61 | Interrupted, // e.g. Ctrl-C 62 | Eof, 63 | Error(Box), 64 | } 65 | 66 | impl From for ReadLineError { 67 | fn from(err: rustyline::error::ReadlineError) -> Self { 68 | match err { 69 | rustyline::error::ReadlineError::Interrupted => ReadLineError::Interrupted, 70 | rustyline::error::ReadlineError::Eof => ReadLineError::Eof, 71 | err => ReadLineError::Error(err.into()), 72 | } 73 | } 74 | } 75 | 76 | fn save_history( 77 | rl: &mut rustyline::Editor, rustyline::history::FileHistory>, 78 | path: Option<&path::PathBuf>, 79 | ) -> io::Result<()> { 80 | if let Some(history_path) = path { 81 | file_paths::get_state_dir(file_paths::DirMode::Create)?; 82 | if rl.save_history(history_path.as_path()).is_err() { 83 | // Error trying to save history 84 | } 85 | } 86 | Ok(()) 87 | } 88 | 89 | impl PromptState<'_> { 90 | pub fn read_line(&mut self) -> Result { 91 | let res = self.rl.readline(self.config.prompt.as_str()); 92 | // ignore errors when saving history 93 | mem::drop(save_history(&mut self.rl, self.history_path.as_ref())); 94 | Ok(res?) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /core/src/num/formatting_style.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt, io}; 2 | 3 | use crate::{ 4 | error::FendError, 5 | result::FResult, 6 | serialize::{Deserialize, Serialize}, 7 | }; 8 | 9 | #[derive(PartialEq, Eq, Clone, Copy, Default)] 10 | #[must_use] 11 | pub(crate) enum FormattingStyle { 12 | /// Print value as an improper fraction 13 | ImproperFraction, 14 | /// Print as a mixed fraction, e.g. 1 1/2 15 | MixedFraction, 16 | /// Print as a float, possibly indicating recurring digits 17 | /// with parentheses, e.g. 7/9 => 0.(81) 18 | ExactFloat, 19 | /// Print with the given number of decimal places 20 | DecimalPlaces(usize), 21 | /// Print with the given number of significant figures (not including any leading zeroes) 22 | SignificantFigures(usize), 23 | /// If exact and no recurring digits: `ExactFloat`, if complex/imag: `MixedFraction`, 24 | /// otherwise: DecimalPlaces(10) 25 | #[default] 26 | Auto, 27 | /// If not exact: DecimalPlaces(10). If no recurring digits: `ExactFloat`. 28 | /// Other numbers: `MixedFraction`, albeit possibly including fractions of pi 29 | Exact, 30 | } 31 | 32 | impl fmt::Display for FormattingStyle { 33 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { 34 | match self { 35 | Self::ImproperFraction => write!(f, "fraction"), 36 | Self::MixedFraction => write!(f, "mixed_fraction"), 37 | Self::ExactFloat => write!(f, "float"), 38 | Self::Exact => write!(f, "exact"), 39 | Self::DecimalPlaces(d) => write!(f, "{d} dp"), 40 | Self::SignificantFigures(s) => write!(f, "{s} sf"), 41 | Self::Auto => write!(f, "auto"), 42 | } 43 | } 44 | } 45 | 46 | impl fmt::Debug for FormattingStyle { 47 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { 48 | match self { 49 | Self::ImproperFraction => write!(f, "improper fraction"), 50 | Self::MixedFraction => write!(f, "mixed fraction"), 51 | Self::ExactFloat => write!(f, "exact float"), 52 | Self::Exact => write!(f, "exact"), 53 | Self::DecimalPlaces(d) => write!(f, "{d} dp"), 54 | Self::SignificantFigures(s) => write!(f, "{s} sf"), 55 | Self::Auto => write!(f, "auto"), 56 | } 57 | } 58 | } 59 | 60 | impl FormattingStyle { 61 | pub(crate) fn serialize(&self, write: &mut impl io::Write) -> FResult<()> { 62 | match self { 63 | Self::ImproperFraction => 1u8.serialize(write)?, 64 | Self::MixedFraction => 2u8.serialize(write)?, 65 | Self::ExactFloat => 3u8.serialize(write)?, 66 | Self::Exact => 4u8.serialize(write)?, 67 | Self::DecimalPlaces(d) => { 68 | 5u8.serialize(write)?; 69 | d.serialize(write)?; 70 | } 71 | Self::SignificantFigures(s) => { 72 | 6u8.serialize(write)?; 73 | s.serialize(write)?; 74 | } 75 | Self::Auto => 7u8.serialize(write)?, 76 | } 77 | Ok(()) 78 | } 79 | 80 | pub(crate) fn deserialize(read: &mut impl io::Read) -> FResult { 81 | Ok(match u8::deserialize(read)? { 82 | 1 => Self::ImproperFraction, 83 | 2 => Self::MixedFraction, 84 | 3 => Self::ExactFloat, 85 | 4 => Self::Exact, 86 | 5 => Self::DecimalPlaces(usize::deserialize(read)?), 87 | 6 => Self::SignificantFigures(usize::deserialize(read)?), 88 | 7 => Self::Auto, 89 | _ => { 90 | return Err(FendError::DeserializationError( 91 | "formatting style is out of range", 92 | )); 93 | } 94 | }) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /cli/src/context.rs: -------------------------------------------------------------------------------- 1 | use std::time; 2 | 3 | use tokio::sync::RwLock; 4 | 5 | use crate::{config, exchange_rates}; 6 | 7 | pub struct HintInterrupt { 8 | start: time::Instant, 9 | duration: time::Duration, 10 | } 11 | 12 | impl fend_core::Interrupt for HintInterrupt { 13 | fn should_interrupt(&self) -> bool { 14 | time::Instant::now().duration_since(self.start) >= self.duration 15 | } 16 | } 17 | 18 | impl Default for HintInterrupt { 19 | fn default() -> Self { 20 | Self { 21 | start: time::Instant::now(), 22 | duration: time::Duration::from_millis(20), 23 | } 24 | } 25 | } 26 | 27 | pub struct InnerCtx { 28 | core_ctx: fend_core::Context, 29 | 30 | // true if the user typed some partial input, false otherwise 31 | input_typed: bool, 32 | } 33 | 34 | impl InnerCtx { 35 | pub fn new(config: &config::Config) -> Self { 36 | let mut res = Self { 37 | core_ctx: fend_core::Context::new(), 38 | input_typed: false, 39 | }; 40 | if config.coulomb_and_farad { 41 | res.core_ctx.use_coulomb_and_farad(); 42 | } 43 | for custom_unit in &config.custom_units { 44 | res.core_ctx.define_custom_unit_v1( 45 | &custom_unit.singular, 46 | &custom_unit.plural, 47 | &custom_unit.definition, 48 | &custom_unit.attribute.to_fend_core(), 49 | ); 50 | } 51 | res.core_ctx 52 | .set_decimal_separator_style(config.decimal_separator); 53 | let exchange_rate_handler = exchange_rates::ExchangeRateHandler { 54 | enable_internet_access: config.enable_internet_access, 55 | source: config.exchange_rate_source, 56 | max_age: config.exchange_rate_max_age, 57 | }; 58 | res.core_ctx 59 | .set_exchange_rate_handler_v2(exchange_rate_handler); 60 | res 61 | } 62 | } 63 | 64 | #[derive(Clone)] 65 | pub struct Context<'a> { 66 | ctx: &'a RwLock, 67 | } 68 | 69 | impl<'a> Context<'a> { 70 | pub fn new(ctx: &'a RwLock) -> Self { 71 | Self { ctx } 72 | } 73 | 74 | pub async fn eval( 75 | &self, 76 | line: &str, 77 | echo_result: bool, 78 | int: &impl fend_core::Interrupt, 79 | ) -> Result { 80 | use rand::SeedableRng; 81 | 82 | let mut ctx_borrow = self.ctx.write().await; 83 | ctx_borrow 84 | .core_ctx 85 | .set_random_u32_trait(Random(rand::rngs::StdRng::from_os_rng())); 86 | ctx_borrow.core_ctx.set_output_mode_terminal(); 87 | ctx_borrow.core_ctx.set_echo_result(echo_result); 88 | ctx_borrow.input_typed = false; 89 | tokio::task::block_in_place(|| { 90 | fend_core::evaluate_with_interrupt(line, &mut ctx_borrow.core_ctx, int) 91 | }) 92 | } 93 | 94 | pub async fn eval_hint(&self, line: &str) -> fend_core::FendResult { 95 | let mut ctx_borrow = self.ctx.write().await; 96 | ctx_borrow.core_ctx.set_output_mode_terminal(); 97 | ctx_borrow.input_typed = !line.is_empty(); 98 | let int = HintInterrupt::default(); 99 | tokio::task::block_in_place(|| { 100 | fend_core::evaluate_preview_with_interrupt(line, &ctx_borrow.core_ctx, &int) 101 | }) 102 | } 103 | 104 | pub async fn serialize(&self) -> Result, String> { 105 | let mut result = vec![]; 106 | self.ctx 107 | .read() 108 | .await 109 | .core_ctx 110 | .serialize_variables(&mut result)?; 111 | Ok(result) 112 | } 113 | 114 | pub async fn get_input_typed(&self) -> bool { 115 | self.ctx.read().await.input_typed 116 | } 117 | } 118 | 119 | struct Random(rand::rngs::StdRng); 120 | 121 | impl fend_core::random::RandomSource for Random { 122 | fn get_random_u32(&mut self) -> u32 { 123 | use rand::Rng; 124 | self.0.random() 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /cli/src/color/style.rs: -------------------------------------------------------------------------------- 1 | use super::base::Base; 2 | use std::fmt::{self, Write}; 3 | 4 | #[derive(Default, Clone, Debug, Eq, PartialEq)] 5 | pub struct Color { 6 | foreground: Option, 7 | underline: bool, 8 | bold: bool, 9 | unknown_keys: Vec, 10 | } 11 | 12 | impl<'de> serde::Deserialize<'de> for Color { 13 | fn deserialize>(deserializer: D) -> Result { 14 | struct ColorVisitor; 15 | 16 | impl<'de> serde::de::Visitor<'de> for ColorVisitor { 17 | type Value = Color; 18 | 19 | fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 20 | formatter.write_str("a color, with properties `foreground`, `underline` and `bold`") 21 | } 22 | 23 | fn visit_map>( 24 | self, 25 | mut map: V, 26 | ) -> Result { 27 | let mut result = Color::default(); 28 | let mut seen_foreground = false; 29 | let mut seen_underline = false; 30 | let mut seen_bold = false; 31 | while let Some(key) = map.next_key::()? { 32 | match key.as_str() { 33 | "foreground" => { 34 | if seen_foreground { 35 | return Err(serde::de::Error::duplicate_field("foreground")); 36 | } 37 | result.foreground = Some(map.next_value()?); 38 | seen_foreground = true; 39 | } 40 | "underline" => { 41 | if seen_underline { 42 | return Err(serde::de::Error::duplicate_field("underline")); 43 | } 44 | result.underline = map.next_value()?; 45 | seen_underline = true; 46 | } 47 | "bold" => { 48 | if seen_bold { 49 | return Err(serde::de::Error::duplicate_field("bold")); 50 | } 51 | result.bold = map.next_value()?; 52 | seen_bold = true; 53 | } 54 | unknown_key => { 55 | map.next_value::()?; 56 | result.unknown_keys.push(unknown_key.to_string()); 57 | } 58 | } 59 | } 60 | Ok(result) 61 | } 62 | } 63 | 64 | const FIELDS: &[&str] = &["foreground", "underline", "bold"]; 65 | deserializer.deserialize_struct("Color", FIELDS, ColorVisitor) 66 | } 67 | } 68 | 69 | impl Color { 70 | pub fn new(foreground: Base) -> Self { 71 | Self { 72 | foreground: Some(foreground), 73 | underline: false, 74 | bold: false, 75 | unknown_keys: vec![], 76 | } 77 | } 78 | 79 | pub fn bold(foreground: Base) -> Self { 80 | Self { 81 | bold: true, 82 | ..Self::new(foreground) 83 | } 84 | } 85 | 86 | pub fn to_ansi(&self) -> String { 87 | let mut result = "\x1b[".to_string(); 88 | if self.underline { 89 | result.push_str("4;"); 90 | } 91 | if self.bold { 92 | result.push_str("1;"); 93 | } 94 | if let Some(foreground) = &self.foreground { 95 | match foreground { 96 | Base::Black => result.push_str("30"), 97 | Base::Red => result.push_str("31"), 98 | Base::Green => result.push_str("32"), 99 | Base::Yellow => result.push_str("33"), 100 | Base::Blue => result.push_str("34"), 101 | Base::Magenta => result.push_str("35"), 102 | Base::Cyan => result.push_str("36"), 103 | Base::White => result.push_str("37"), 104 | Base::Color256(n) => { 105 | result.push_str("38;5;"); 106 | write!(result, "{n}").unwrap(); 107 | result.push_str(&n.to_string()); 108 | } 109 | Base::Unknown(_) => result.push_str("39"), 110 | } 111 | } else { 112 | result.push_str("39"); 113 | } 114 | result.push('m'); 115 | result 116 | } 117 | 118 | pub fn print_warnings_about_unknown_keys(&self, style_assignment: &str) { 119 | for key in &self.unknown_keys { 120 | eprintln!( 121 | "Warning: ignoring unknown configuration setting `colors.{style_assignment}.{key}`" 122 | ); 123 | } 124 | if let Some(base) = &self.foreground { 125 | base.warn_about_unknown_colors(); 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /documentation/include-files.lua: -------------------------------------------------------------------------------- 1 | --- modified from 2 | --- https://github.com/pandoc/lua-filters/blob/master/include-files/include-files.lua 3 | --- commit b1a13fae6fea07135752107f25b2fcda29424dd1 from 2021-12-27 4 | 5 | --- include-files.lua – filter to include Markdown files 6 | --- 7 | --- Copyright: © 2019–2021 Albert Krewinkel 8 | --- License: MIT – see LICENSE file for details 9 | 10 | -- Module pandoc.path is required and was added in version 2.12 11 | PANDOC_VERSION:must_be_at_least '2.12' 12 | 13 | local List = require 'pandoc.List' 14 | local path = require 'pandoc.path' 15 | local system = require 'pandoc.system' 16 | 17 | --- Get include auto mode 18 | local include_auto = false 19 | function get_vars (meta) 20 | if meta['include-auto'] then 21 | include_auto = true 22 | end 23 | end 24 | 25 | --- Keep last heading level found 26 | local last_heading_level = 0 27 | function update_last_level(header) 28 | last_heading_level = header.level 29 | end 30 | 31 | --- Update contents of included file 32 | local function update_contents(blocks, shift_by, include_path) 33 | local update_contents_filter = { 34 | -- Shift headings in block list by given number 35 | Header = function (header) 36 | if shift_by then 37 | header.level = header.level + shift_by 38 | end 39 | return header 40 | end, 41 | -- If image paths are relative then prepend include file path 42 | Image = function (image) 43 | if path.is_relative(image.src) then 44 | image.src = path.normalize(path.join({include_path, image.src})) 45 | end 46 | return image 47 | end, 48 | -- Update path for include-code-files.lua filter style CodeBlocks 49 | CodeBlock = function (cb) 50 | if cb.attributes.include and path.is_relative(cb.attributes.include) then 51 | cb.attributes.include = 52 | path.normalize(path.join({include_path, cb.attributes.include})) 53 | end 54 | return cb 55 | end 56 | } 57 | 58 | return pandoc.walk_block(pandoc.Div(blocks), update_contents_filter).content 59 | end 60 | 61 | --- Filter function for code blocks 62 | local transclude 63 | function transclude (cb) 64 | -- ignore code blocks which are not of class "include". 65 | if not cb.classes:includes 'include' then 66 | return 67 | end 68 | 69 | -- Markdown is used if this is nil. 70 | local format = cb.attributes['format'] 71 | 72 | -- Attributes shift headings 73 | local shift_heading_level_by = 0 74 | local shift_input = cb.attributes['shift-heading-level-by'] 75 | if shift_input then 76 | shift_heading_level_by = tonumber(shift_input) 77 | else 78 | if include_auto then 79 | -- Auto shift headings 80 | shift_heading_level_by = last_heading_level 81 | end 82 | end 83 | 84 | --- keep track of level before recursion 85 | local buffer_last_heading_level = last_heading_level 86 | 87 | local blocks = List:new() 88 | for line in cb.text:gmatch('[^\n]+') do 89 | if line:sub(1,2) ~= '//' then 90 | local fh = io.open(line) 91 | if not fh then 92 | io.stderr:write("Cannot open file " .. line .. " | Skipping includes\n") 93 | error("Cannot open file " .. line) 94 | else 95 | -- read file as the given format with global reader options 96 | local contents = pandoc.read( 97 | fh:read '*a', 98 | format, 99 | PANDOC_READER_OPTIONS 100 | ).blocks 101 | last_heading_level = 0 102 | -- recursive transclusion 103 | contents = system.with_working_directory( 104 | path.directory(line), 105 | function () 106 | return pandoc.walk_block( 107 | pandoc.Div(contents), 108 | { Header = update_last_level, CodeBlock = transclude } 109 | ) 110 | end).content 111 | --- reset to level before recursion 112 | last_heading_level = buffer_last_heading_level 113 | blocks:extend(update_contents(contents, shift_heading_level_by, 114 | path.directory(line))) 115 | fh:close() 116 | end 117 | end 118 | end 119 | return blocks 120 | end 121 | 122 | return { 123 | { Meta = get_vars }, 124 | { Header = update_last_level, CodeBlock = transclude } 125 | } 126 | -------------------------------------------------------------------------------- /core/src/date/month.rs: -------------------------------------------------------------------------------- 1 | use crate::FendError; 2 | use crate::result::FResult; 3 | use crate::{ 4 | date::Year, 5 | serialize::{Deserialize, Serialize}, 6 | }; 7 | use std::{convert, fmt, io}; 8 | 9 | #[derive(Copy, Clone, Eq, PartialEq)] 10 | pub(crate) enum Month { 11 | January = 1, 12 | February = 2, 13 | March = 3, 14 | April = 4, 15 | May = 5, 16 | June = 6, 17 | July = 7, 18 | August = 8, 19 | September = 9, 20 | October = 10, 21 | November = 11, 22 | December = 12, 23 | } 24 | 25 | impl Month { 26 | pub(crate) fn number_of_days(self, year: Year) -> u8 { 27 | match self { 28 | Self::February => { 29 | if year.is_leap_year() { 30 | 29 31 | } else { 32 | 28 33 | } 34 | } 35 | Self::April | Self::June | Self::September | Self::November => 30, 36 | _ => 31, 37 | } 38 | } 39 | 40 | pub(crate) fn next(self) -> Self { 41 | match self { 42 | Self::January => Self::February, 43 | Self::February => Self::March, 44 | Self::March => Self::April, 45 | Self::April => Self::May, 46 | Self::May => Self::June, 47 | Self::June => Self::July, 48 | Self::July => Self::August, 49 | Self::August => Self::September, 50 | Self::September => Self::October, 51 | Self::October => Self::November, 52 | Self::November => Self::December, 53 | Self::December => Self::January, 54 | } 55 | } 56 | 57 | pub(crate) fn prev(self) -> Self { 58 | match self { 59 | Self::January => Self::December, 60 | Self::February => Self::January, 61 | Self::March => Self::February, 62 | Self::April => Self::March, 63 | Self::May => Self::April, 64 | Self::June => Self::May, 65 | Self::July => Self::June, 66 | Self::August => Self::July, 67 | Self::September => Self::August, 68 | Self::October => Self::September, 69 | Self::November => Self::October, 70 | Self::December => Self::November, 71 | } 72 | } 73 | 74 | fn as_str(self) -> &'static str { 75 | match self { 76 | Self::January => "January", 77 | Self::February => "February", 78 | Self::March => "March", 79 | Self::April => "April", 80 | Self::May => "May", 81 | Self::June => "June", 82 | Self::July => "July", 83 | Self::August => "August", 84 | Self::September => "September", 85 | Self::October => "October", 86 | Self::November => "November", 87 | Self::December => "December", 88 | } 89 | } 90 | 91 | pub(crate) fn serialize(self, write: &mut impl io::Write) -> FResult<()> { 92 | self.as_u8().serialize(write)?; 93 | Ok(()) 94 | } 95 | 96 | pub(crate) fn deserialize(read: &mut impl io::Read) -> FResult { 97 | Self::try_from(u8::deserialize(read)?) 98 | .map_err(|_| FendError::DeserializationError("month is out of range")) 99 | } 100 | 101 | fn as_u8(self) -> u8 { 102 | match self { 103 | Self::January => 1, 104 | Self::February => 2, 105 | Self::March => 3, 106 | Self::April => 4, 107 | Self::May => 5, 108 | Self::June => 6, 109 | Self::July => 7, 110 | Self::August => 8, 111 | Self::September => 9, 112 | Self::October => 10, 113 | Self::November => 11, 114 | Self::December => 12, 115 | } 116 | } 117 | } 118 | 119 | impl fmt::Debug for Month { 120 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 121 | write!(f, "{}", self.as_str()) 122 | } 123 | } 124 | 125 | impl fmt::Display for Month { 126 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 127 | write!(f, "{}", self.as_str()) 128 | } 129 | } 130 | 131 | pub(crate) struct InvalidMonthError; 132 | 133 | impl convert::TryFrom for Month { 134 | type Error = InvalidMonthError; 135 | 136 | fn try_from(month: u8) -> Result { 137 | Ok(match month { 138 | 1 => Self::January, 139 | 2 => Self::February, 140 | 3 => Self::March, 141 | 4 => Self::April, 142 | 5 => Self::May, 143 | 6 => Self::June, 144 | 7 => Self::July, 145 | 8 => Self::August, 146 | 9 => Self::September, 147 | 10 => Self::October, 148 | 11 => Self::November, 149 | 12 => Self::December, 150 | _ => return Err(InvalidMonthError), 151 | }) 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState, useTransition } from 'react'; 2 | import { useCurrentInput } from './hooks/useCurrentInput'; 3 | import NewTabLink from './components/NewTabLink'; 4 | import PendingOutput from './components/PendingOutput'; 5 | import { useFend } from './hooks/useFend'; 6 | 7 | const examples = ` 8 | > 5'10" to cm 9 | 177.8 cm 10 | 11 | > cos (pi/4) + i * (sin (pi/4)) 12 | approx. 0.7071067811 + 0.7071067811i 13 | 14 | > 0b1001 + 3 15 | 0b1100 16 | 17 | > 0xffff to decimal 18 | 65535 19 | 20 | > 100 °C to °F 21 | 212 °F 22 | 23 | > 1 lightyear to parsecs 24 | approx. 0.3066013937 parsecs 25 | 26 | `; 27 | 28 | const exampleContent = ( 29 |

30 | {'\n'} 31 | examples: 32 | {examples} 33 | give it a go: 34 |

35 | ); 36 | 37 | export default function App({ widget = false }: { widget?: boolean }) { 38 | const [output, setOutput] = useState(widget ? <> : exampleContent); 39 | const { evaluate, evaluateHint } = useFend(); 40 | const { currentInput, submit, onInput, upArrow, downArrow, hint } = useCurrentInput(evaluateHint); 41 | const inputText = useRef(null); 42 | const pendingOutput = useRef(null); 43 | 44 | const [isPending, startTransition] = useTransition(); 45 | const onKeyDown = (event: KeyboardEvent) => { 46 | if ( 47 | (event.key === 'k' && event.metaKey !== event.ctrlKey && !event.altKey) || 48 | (event.key === 'l' && event.ctrlKey && !event.metaKey && !event.altKey) 49 | ) { 50 | // Cmd+K, Ctrl+K or Ctrl+L to clear the buffer 51 | setOutput(null); 52 | return; 53 | } 54 | if (event.key === 'ArrowUp') { 55 | event.preventDefault(); 56 | upArrow(); 57 | return; 58 | } 59 | if (event.key === 'ArrowDown') { 60 | event.preventDefault(); 61 | downArrow(); 62 | return; 63 | } 64 | 65 | // allow multiple lines to be entered if shift, ctrl 66 | // or meta is held, otherwise evaluate the expression 67 | if (!(event.key === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey)) { 68 | return; 69 | } 70 | event.preventDefault(); 71 | if (currentInput.trim() === 'clear') { 72 | onInput(''); 73 | setOutput(null); 74 | return; 75 | } 76 | 77 | startTransition(async () => { 78 | const request =

{`> ${currentInput}`}

; 79 | submit(); 80 | const fendResult = await evaluate(currentInput); 81 | if (!fendResult.ok && fendResult.message === 'cancelled') { 82 | return; 83 | } 84 | onInput(''); 85 | const result =

{fendResult.ok ? fendResult.result : `Error: ${fendResult.message}`}

; 86 | setOutput(o => ( 87 | <> 88 | {o} 89 | {request} 90 | {result} 91 | 92 | )); 93 | setTimeout(() => { 94 | pendingOutput.current?.scrollIntoView({ behavior: 'smooth' }); 95 | }, 50); 96 | }); 97 | }; 98 | useEffect(() => { 99 | const focus = () => { 100 | // allow the user to select text for copying and 101 | // pasting, but if text is deselected (collapsed) 102 | // refocus the input field 103 | if (document.activeElement !== inputText.current && document.getSelection()?.isCollapsed) { 104 | inputText.current?.focus(); 105 | } 106 | }; 107 | document.addEventListener('click', focus); 108 | return () => { 109 | document.removeEventListener('click', focus); 110 | }; 111 | }, []); 112 | return ( 113 |
114 | {!widget && ( 115 |

116 | fend is an arbitrary-precision 117 | unit-aware calculator. 118 |

119 | )} 120 |
{output}
121 |
122 |
123 |