Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 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
9f11cc9
Merge branch 'develop' into feature/hf-249-function-metadata-api
sequba Jul 14, 2026
c65115b
Add missing XIRR, VSTACK, HSTACK to function catalogue
cursoragent Jul 14, 2026
86ab411
Fix static metadata API to exclude globally-registered custom plugins
cursoragent Jul 14, 2026
707fce8
docs: clarify custom plugins don't appear in static metadata API
cursoragent Jul 14, 2026
8e26e2a
Describe the guidelines about the docs in the DEV_DOCS
sequba Jul 14, 2026
b90f939
Update docs about getAvailableFunctions and getFunctionDetails
sequba Jul 14, 2026
18d4246
Merge branch 'feature/hf-249-function-metadata-api' of github.com:han…
sequba Jul 14, 2026
4612e75
refactor(metadata): use snake_case for all catalogue parameter names …
sequba Jul 14, 2026
2d9196e
fix(metadata): guard getAvailableFunctions against invalid locale cod…
sequba Jul 14, 2026
87a77f7
Merge branch 'develop' into feature/hf-249-function-metadata-api
sequba Jul 15, 2026
40b6468
fix(metadata): correct migrated shortDescriptions (HF-249)
marcin-kordas-hoc Jul 15, 2026
d5d77bd
Function metadata: usage examples, documentation URL, parameter descr…
marcin-kordas-hoc Jul 16, 2026
d256895
Merge branch 'develop' into feature/hf-249-function-metadata-api
sequba Jul 16, 2026
e36e0d1
fix(hf-249): restore DEFAULT_DOCUMENTATION_URL export (HF-300)
marcin-kordas-hoc Jul 23, 2026
43d3178
Merge remote-tracking branch 'upstream/develop' into hf249-base-work
marcin-kordas-hoc Jul 23, 2026
b620b81
chore(ci): re-trigger tests against updated HF-249 tests branch
marcin-kordas-hoc Jul 23, 2026
c8b3e03
Merge branch 'develop' into feature/hf-249-function-metadata-api
marcin-kordas-hoc Jul 23, 2026
08a7bd7
docs(hf-249): add SORT and UNIQUE to the function-metadata catalogue
marcin-kordas-hoc Jul 23, 2026
d42080e
Merge branch 'develop' into feature/hf-249-function-metadata-api
sequba Jul 27, 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 new functions: VSTACK, HSTACK. [#1698](https://github.com/handsontable/hyperformula/pull/1698)
- Added a new function: `XIRR`. [#1701](https://github.com/handsontable/hyperformula/pull/1701)
- Added the UNIQUE function. [#1708](https://github.com/handsontable/hyperformula/pull/1708)
Expand Down
1 change: 1 addition & 0 deletions DEV_DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ A single pull request should contain an atomic self-contained functional change
## Documentation

- Follow the [documentation content guide](DOCS_CONTENT_GUIDE.md) when creating or editing docs (writing style, language, and how to structure guides).
- We try not to duplicate information in the documentation. The API reference (generated from JSDocs) should contain all the details about each function and class (it is the primary source of truth). Guides should provide high-level overview. They may duplicate some of the information from the API reference if they are relevant to the context but, above all, they should link to the API reference for the detailed information.

## How to add a new function

Expand Down
29 changes: 29 additions & 0 deletions docs/guide/built-in-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,35 @@ _Some categories such as compatibility and cube are yet to be supported._
You can modify the built-in functions or create your own, by adding a [custom function](custom-functions.md).
:::

## Function metadata

HyperFormula exposes metadata about the functions it knows — localized and
canonical names, category, a short description, and the parameter list — which is
useful for building UI such as a function picker or a reference panel. Two
methods return it, each available as a **static** method (covering the
globally-registered built-ins and their aliases) and as an **instance** method
(which additionally sees the instance's [custom functions](custom-functions.md)
and defaults to its configured language):

- [`getAvailableFunctions()`](../api/classes/hyperformula.md#getavailablefunctions)
— a short list with one entry per function.
- [`getFunctionDetails()`](../api/classes/hyperformula.md#getfunctiondetails)
— the full details of a single function, including its parameters.

```js
// static methods take the language code explicitly
const functions = HyperFormula.getAvailableFunctions('enGB');
const sumDetails = HyperFormula.getFunctionDetails('SUM', 'enGB');

// instance methods use the instance's configured language
const hf = HyperFormula.buildEmpty({ language: 'enGB' });
const instanceFunctions = hf.getAvailableFunctions();
const instanceSumDetails = hf.getFunctionDetails('SUM');
```

See the linked API reference for each method's exact return shape and for how
custom functions are reported.

## List of available functions

Total number of functions: **{{ $page.functionsCount }}**
Expand Down
4 changes: 4 additions & 0 deletions docs/guide/custom-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ method:
HyperFormula.registerFunctionPlugin(MyCustomPlugin, MyCustomPluginTranslations);
```

::: tip
Custom functions registered with `registerFunctionPlugin()` are available in all HyperFormula instances. However, they do not appear in the static metadata API methods (`HyperFormula.getAvailableFunctions()` and `HyperFormula.getFunctionDetails()`). To retrieve metadata for custom functions, use the instance methods (`hfInstance.getAvailableFunctions()` and `hfInstance.getFunctionDetails()`). See the [Function metadata](built-in-functions.md#function-metadata) section for more details.
:::

### 6. Use your custom function in a formula

Now, you're ready to use your GREET function in a formula.
Expand Down
281 changes: 281 additions & 0 deletions scripts/hf249-migrate-function-docs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
/**
* @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.
*
* WARNING: this regenerates each category file from scratch and does NOT preserve the hand-authored
* `examples` and parameter `description` fields (HF-300). Re-running it silently wipes them — re-author
* afterwards, or extend this tool to carry those fields forward, before committing the result.
*
* 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 (already snake_case). */
const PARAMETER_NAME_OVERRIDES: Record<string, string[]> = {
'T.TEST': ['array1', 'array2', 'tails', 'type'],
}

/**
* Word-segmentation fixes for parameter tokens the documentation writes as a single run-on word (e.g. `Sumrange`),
* so they read consistently with their multi-word siblings (`Sum_Range` -> `sum_range`). Keyed by the mechanically
* snake-cased token; the value is the corrected snake_case name.
*/
const SNAKE_CASE_SEGMENTATION: Record<string, string> = {
sumrange: 'sum_range',
searchcriterion: 'search_criterion',
logicalvalue: 'logical_value',
datestring: 'date_string',
timestring: 'time_string',
startdate: 'start_date',
minimumlength: 'minimum_length',
lowerbound: 'lower_bound',
upperbound: 'upper_bound',
numberx: 'number_x',
numbery: 'number_y',
}

/**
* Converts a raw parameter name to snake_case: lowercase words joined by underscores, with camelCase/PascalCase and
* acronym boundaries split, existing separators unified, and trailing digits kept attached to their word
* (`Number1`, `Number_1` -> `number1`). A small segmentation table then fixes run-on tokens the docs left unsplit.
*
* @param {string} name - the raw parameter name from the syntax column
*/
export function toSnakeCase(name: string): string {
const mechanical = name
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
.replace(/[\s.\-]+/g, '_')
.toLowerCase()
.replace(/_+(\d)/g, '$1')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '')
return SNAKE_CASE_SEGMENTATION[mechanical] ?? mechanical
}

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 snake_case 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; each is normalized to snake_case and any shortfall is padded with `arg1`, `arg2`, ...
*/
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).map(toSnakeCase)
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\`. The \`examples\` and parameter
* descriptions are hand-authored; re-running that script overwrites them.
*/
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`)
}

// Only regenerate when executed directly; guarded so helpers (e.g. `toSnakeCase`) can be imported without side effects.
if (require.main === module) {
main()
}
Loading
Loading