Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e07b3d2
feat(metadata): add function description types (HF-249)
marcin-kordas-hoc Jun 9, 2026
25130c2
feat(metadata): add canonical function id helper (HF-249)
marcin-kordas-hoc Jun 9, 2026
cdb89aa
feat(metadata): generate function syntax from parameters (HF-249)
marcin-kordas-hoc Jun 9, 2026
2b4c625
feat(metadata): add list/details merge builders (HF-249)
marcin-kordas-hoc Jun 9, 2026
6e7c50f
feat(metadata): compose FUNCTION_DOCS with seed categories (HF-249)
marcin-kordas-hoc Jun 9, 2026
bcc39ef
feat(metadata): expose getAvailableFunctions/getFunctionDetails publi…
marcin-kordas-hoc Jun 9, 2026
ba34e55
feat(metadata): migrate full 363-function catalogue from docs (HF-249)
marcin-kordas-hoc Jun 9, 2026
58185f1
docs(metadata): document getAvailableFunctions/getFunctionDetails (HF…
marcin-kordas-hoc Jun 9, 2026
3f8857b
ci: retrigger after tests-branch cleanup (HF-249)
marcin-kordas-hoc Jun 10, 2026
b78c11b
fix(metadata): correct instance @category and guard catalogue arity d…
marcin-kordas-hoc Jun 10, 2026
abc7a12
fix(metadata): degrade getFunctionDetails to undefined on catalogue d…
marcin-kordas-hoc Jun 10, 2026
385fc11
docs(metadata): link PR in CHANGELOG entry (HF-249)
marcin-kordas-hoc Jun 10, 2026
384ab7a
ci: retrigger after tests-branch matcher fix (HF-249)
marcin-kordas-hoc Jun 10, 2026
e15a3c1
Merge develop into feature/hf-249-function-metadata-api (HF-249)
marcin-kordas-hoc Jun 10, 2026
d86f869
fix(metadata): list only currently-registered functions, consistent w…
marcin-kordas-hoc Jun 10, 2026
4078a56
ci: retrigger after tests-branch cursor fixes (HF-249)
marcin-kordas-hoc Jun 10, 2026
c98e9b1
fix(metadata): share listability gate so getAvailableFunctions and ge…
marcin-kordas-hoc Jun 10, 2026
7676790
fix(metadata): Title-case parameter names for casing consistency (HF-…
marcin-kordas-hoc Jun 23, 2026
378ae9e
Merge branch 'develop' into feature/hf-249-function-metadata-api
sequba Jun 23, 2026
a7b5d97
refactor(metadata): apply review feedback to function metadata API (H…
marcin-kordas-hoc Jun 23, 2026
ffdaa8b
fix(metadata): report shadowed built-ins as custom, harden ordering a…
marcin-kordas-hoc Jun 23, 2026
594d2ce
fix(metadata): break module-load cycle from built-in owner lookup (HF…
marcin-kordas-hoc Jun 24, 2026
07e8065
docs(guide): drop redundant "no syntax string" note from function met…
marcin-kordas-hoc Jun 24, 2026
ff28537
feat(metadata): author SUM and SUMIF reference metadata; optional doc…
marcin-kordas-hoc Jun 25, 2026
8207bf3
docs(guide): document documentationUrl and examples in getFunctionDet…
marcin-kordas-hoc Jun 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

### Added

- Added the `getAvailableFunctions()` and `getFunctionDetails()` methods (both static and instance) for retrieving function metadata. [#1692](https://github.com/handsontable/hyperformula/pull/1692)
- Added an Indonesian (Bahasa Indonesia) language pack. [#1674](https://github.com/handsontable/hyperformula/pull/1674)

## [3.3.0] - 2026-05-20
Expand Down
57 changes: 57 additions & 0 deletions docs/guide/custom-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -538,3 +538,60 @@ MyCustomPlugin.translations = {
};
```
:::

## Function metadata

HyperFormula exposes metadata about the functions it knows (category, translated
name, short description, and the parameter list). You can retrieve it with the
`getAvailableFunctions()` and `getFunctionDetails()` methods, available both as
static methods and as instance methods:

```js
// a short list of available functions, with names translated for a language
const functions = HyperFormula.getAvailableFunctions('enGB');

// the full details of a single function
const sumDetails = HyperFormula.getFunctionDetails('SUM', 'enGB');
```

`getAvailableFunctions()` returns entries sorted alphabetically by their
localized name, each with `localizedName`, `canonicalName`, `category`, and
`shortDescription`. `getFunctionDetails()` returns the same fields plus the
ordered `parameters` list (each with `name`, `description`, and `optional`),
`repeatLastArgs` — the number of trailing parameters that repeat for functions
with a variable number of arguments (`0` for a fixed argument list) — and
`documentationUrl` and `examples`, which carry the function's documentation link
and usage examples where authored (otherwise `''` and `[]`).

The same methods are also available on an instance, where they use the
instance's configured language by default:

```js
const hf = HyperFormula.buildEmpty({ language: 'enGB' });

// a short list of available functions
const functions = hf.getAvailableFunctions();

// the full details of a single function
const sumDetails = hf.getFunctionDetails('SUM');
```

Both built-in functions and their aliases are included. Custom (user-registered)
functions are registered per instance, so they are listed by the **instance**
methods, not by the static ones — the static methods only see the globally
registered built-ins and their aliases. A custom function has no shipped
catalogue entry, so its `category` is `undefined`, its `shortDescription` is
empty, and its parameters are reported positionally (`Arg1`, `Arg2`, ...).

```js
const hf = HyperFormula.buildEmpty({
language: 'enGB',
functionPlugins: [MyCustomPlugin],
});

// the instance methods include the custom function
const details = hf.getFunctionDetails('MY_FUNCTION'); // { canonicalName: 'MY_FUNCTION', category: undefined, ... }

// the static methods do not, as custom functions are instance-scoped
const staticDetails = HyperFormula.getFunctionDetails('MY_FUNCTION', 'enGB'); // undefined
```
234 changes: 234 additions & 0 deletions scripts/hf249-migrate-function-docs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
/**
* @license
* Copyright (c) 2025 Handsoncode. All rights reserved.
*/

/**
* HF-249 — function metadata migration tool (dev-only; never shipped, because `tsconfig.json` `include`
* is restricted to `["src"]`).
*
* Regenerates the per-category `FunctionDoc` catalogue files under
* `src/interpreter/functionMetadata/categories/` (and their `index.ts` barrel) from
* `docs/guide/built-in-functions.md`:
*
* - the canonical id set is taken from `implementedFunctions` (aliases and protected ids are excluded);
* - `category` and `shortDescription` come from the doc table's "Function ID" section and "Description" column;
* - parameter names come from the "Syntax" column, but their COUNT and optionality are governed by the
* implementation arity — the syntax column is only a (sometimes dirty) source of human-readable names.
*
* Run with: `npm run tsnode scripts/hf249-migrate-function-docs.ts`
*/

import * as fs from 'fs'
import * as path from 'path'
import {HyperFormula} from '../src'
import {FUNCTION_CATEGORIES, FunctionCategory} from '../src/interpreter/functionMetadata/FunctionDescription'

const REPO_ROOT = path.resolve(__dirname, '..')
const DOC_PATH = path.join(REPO_ROOT, 'docs/guide/built-in-functions.md')
const CATEGORIES_DIR = path.join(REPO_ROOT, 'src/interpreter/functionMetadata/categories')
const INDEX_PATH = path.join(REPO_ROOT, 'src/interpreter/functionMetadata/index.ts')

/** Parameter names the documentation under-specifies relative to the implementation arity. */
const PARAMETER_NAME_OVERRIDES: Record<string, string[]> = {
'T.TEST': ['Array1', 'Array2', 'Tails', 'Type'],
}

interface DocRow {
category: FunctionCategory,
description: string,
syntax: string,
}

/** Maps each documented function id to its category, description and raw syntax (first occurrence wins). */
function parseDoc(): Map<string, DocRow> {
const markdown = fs.readFileSync(DOC_PATH, 'utf8')
const rows = new Map<string, DocRow>()
let category: FunctionCategory | null = null
for (const line of markdown.split('\n')) {
const header = /^### (.+?)\s*$/.exec(line)
if (header) {
category = (FUNCTION_CATEGORIES as readonly string[]).includes(header[1]) ? header[1] as FunctionCategory : null
continue
}
if (category && line.startsWith('|') && !/^\|\s*:?-+/.test(line)) {
const cells = line.split('|').map(cell => cell.trim())
const id = cells[1]
if (!id || id === 'Function ID' || rows.has(id)) {
continue
}
rows.set(id, {category, description: cells[2] ?? '', syntax: cells[3] ?? ''})
}
}
return rows
}

/** Extracts trimmed parameter names from a syntax string, dropping optional brackets, quotes and ellipsis markers. */
function parseSyntaxNames(syntax: string): string[] {
const open = syntax.indexOf('(')
const close = syntax.lastIndexOf(')')
if (open < 0 || close < 0) {
return []
}
const inner = syntax.slice(open + 1, close).replace(/[[\]]/g, '')
return inner
.split(',')
.map(part => part.trim().replace(/^"|"$/g, '').replace(/^\.+/, '').trim())
.filter(part => part.length > 0)
}

/** Disambiguates repeated names (e.g. `[Number, Number]` -> `[Number1, Number2]`) so every name is unique. */
function uniquify(names: string[]): string[] {
const totals: Record<string, number> = {}
for (const name of names) {
totals[name] = (totals[name] ?? 0) + 1
}
const seen: Record<string, number> = {}
return names.map(name => {
if (totals[name] > 1) {
seen[name] = (seen[name] ?? 0) + 1
return `${name}${seen[name]}`
}
return name
})
}

/**
* Produces exactly `arity` unique, non-empty parameter names. The syntax column lists `Name1, Name2, ...NameN`
* for repeating groups, so the first `arity` names already collapse those groups onto the implementation arity.
*/
function deriveParameterNames(id: string, syntax: string, arity: number): string[] {
const override = PARAMETER_NAME_OVERRIDES[id]
if (override !== undefined) {
if (override.length !== arity) {
throw new Error(`Override for ${id} has ${override.length} names but the implementation arity is ${arity}`)
}
return override
}
const names = parseSyntaxNames(syntax).slice(0, arity)
while (names.length < arity) {
names.push(`Arg${names.length + 1}`)
}
return uniquify(names)
}

/** The category's file name, e.g. `'Math and trigonometry'` -> `'math-and-trigonometry'`. */
function kebabCase(category: string): string {
return category.toLowerCase().replace(/\s+/g, '-')
}

/** The category's exported constant name, e.g. `'Math and trigonometry'` -> `'MATH_AND_TRIGONOMETRY_DOCS'`. */
function constName(category: string): string {
return `${category.toUpperCase().replace(/\s+/g, '_')}_DOCS`
}

/** Renders a single-quoted TypeScript string literal, escaping backslashes and single quotes. */
function asStringLiteral(value: string): string {
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, '\\\'')}'`
}

/** Renders an object key: a bare identifier where valid, otherwise a quoted string (e.g. `'HF.ADD'`). */
function asKey(id: string): string {
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(id) ? id : asStringLiteral(id)
}

const LICENSE = `/**
* @license
* Copyright (c) 2025 Handsoncode. All rights reserved.
*/`

interface Entry {
id: string,
description: string,
names: string[],
}

/** Renders the source of one `categories/<kebab>.ts` file, with entries sorted by canonical id. */
function emitCategoryFile(category: FunctionCategory, entries: Entry[]): string {
const body = [...entries].sort((a, b) => a.id.localeCompare(b.id)).map(entry => {
const params = entry.names.map(name => `{name: ${asStringLiteral(name)}, description: ''}`).join(', ')
return ` ${asKey(entry.id)}: {
category: ${asStringLiteral(category)},
shortDescription: ${asStringLiteral(entry.description)},
parameters: [${params}],
},`
}).join('\n')
return `${LICENSE}

import {FunctionDoc} from '../FunctionDescription'

/**
* Catalogue entries for the "${category}" category. Generated from \`docs/guide/built-in-functions.md\` by
* \`scripts/hf249-migrate-function-docs.ts\`; parameter descriptions are authored in a later phase.
*/
export const ${constName(category)}: Record<string, FunctionDoc> = {
${body}
}
`
}

/** Renders the source of the `index.ts` barrel that composes every category file into `FUNCTION_DOCS`. */
function emitIndex(categories: FunctionCategory[]): string {
const ordered = [...categories].sort((a, b) => kebabCase(a).localeCompare(kebabCase(b)))
const imports = ordered.map(category => `import {${constName(category)}} from './categories/${kebabCase(category)}'`).join('\n')
const spreads = ordered.map(category => ` ...${constName(category)},`).join('\n')
return `${LICENSE}

import {FunctionDoc} from './FunctionDescription'
${imports}

export * from './FunctionDescription'

/**
* Canonical-id-keyed catalogue of human-readable function metadata, composed from the per-category files.
* Generated by \`scripts/hf249-migrate-function-docs.ts\`. Coverage of the whole canonical set is enforced by test.
*/
export const FUNCTION_DOCS: Record<string, FunctionDoc> = {
${spreads}
}
`
}

/** Reads the docs and implementation arity, derives every entry, and writes the category files and barrel. */
function main(): void {
const arityById = new Map<string, number>()
for (const plugin of HyperFormula.getAllFunctionPlugins()) {
const impl = plugin.implementedFunctions
for (const id of Object.keys(impl)) {
arityById.set(id, (impl[id].parameters ?? []).length)
}
}

const docRows = parseDoc()
const byCategory = new Map<FunctionCategory, Entry[]>()
const missing: string[] = []

for (const id of arityById.keys()) {
const row = docRows.get(id)
if (row === undefined) {
missing.push(id)
continue
}
const names = deriveParameterNames(id, row.syntax, arityById.get(id) as number)
const list = byCategory.get(row.category) ?? []
list.push({id, description: row.description, names})
byCategory.set(row.category, list)
}

if (missing.length > 0) {
throw new Error(`No documentation row for canonical ids: ${missing.join(', ')}`)
}

const categories = [...byCategory.keys()]
for (const category of categories) {
const file = path.join(CATEGORIES_DIR, `${kebabCase(category)}.ts`)
fs.writeFileSync(file, emitCategoryFile(category, byCategory.get(category) as Entry[]))
console.log(`wrote ${path.relative(REPO_ROOT, file)} (${(byCategory.get(category) as Entry[]).length})`)
}
fs.writeFileSync(INDEX_PATH, emitIndex(categories))

const total = categories.reduce((sum, category) => sum + (byCategory.get(category) as Entry[]).length, 0)
console.log(`wrote ${path.relative(REPO_ROOT, INDEX_PATH)}; ${total} functions across ${categories.length} categories`)
}

main()
Loading
Loading