diff --git a/src/actions.ts b/src/actions.ts index bd6f141..6cd3df2 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -13,8 +13,9 @@ import type { EmberPlusConfig } from './config.js' import type { EmberPlusInstance } from './index.js' import { doMatrixAction, doTake, doClear, setSelectedSource, setSelectedTarget } from './actions/matrix.js' import { learnSetValueActionOptions, setValue, subscribeParameterAction } from './actions/parameter.js' +import { invokeFunctionAction } from './actions/function.js' import { EmberPlusState } from './state.js' -import { filterPathChoices } from './util.js' +import { filterPathChoices, filterFunctionPathChoices } from './util.js' export interface setValueActionOptions extends CompanionOptionValues { path: string @@ -47,6 +48,7 @@ export enum ActionId { Clear = 'clear', SetSelectedSource = 'setSelectedSource', SetSelectedTarget = 'setSelectedTarget', + InvokeFunction = 'invokeFunction', } const pathDropDown = { @@ -560,6 +562,37 @@ export function GetActionsList( ], callback: setSelectedTarget(self, state), }, + [ActionId.InvokeFunction]: { + name: 'Invoke Function', + options: [ + { + ...pathDropDown, + choices: filterFunctionPathChoices(state), + default: filterFunctionPathChoices(state).find(() => true)?.id ?? 'No functions configured!', + }, + pathString, + usePathVar, + { + type: 'textinput', + label: 'Arguments', + id: 'args', + useVariables: { local: true }, + multiline: true, + default: '', + tooltip: + 'Enter arguments comma-separated, line-separated, or as a JSON array (e.g. [123, "text", true]). Dynamic variables are supported.', + }, + { + type: 'checkbox', + label: 'Parse escape characters', + id: 'parseEscapeChars', + default: true, + tooltip: 'Parse escape characters such as \\r \\n \\t in text arguments', + }, + ], + callback: invokeFunctionAction(self, emberClient, state, queue), + subscribe: subscribeParameterAction(self), + }, } return actions diff --git a/src/actions/function.test.ts b/src/actions/function.test.ts new file mode 100644 index 0000000..6c2aaaa --- /dev/null +++ b/src/actions/function.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi } from 'vitest' +import { invokeFunctionAction } from './function.js' +import { Model as EmberModel } from 'emberplus-connection' +import { ElementType } from 'emberplus-connection/dist/model/index.js' + +describe('invokeFunctionAction callback', () => { + it('invokes emberClient.invoke with correctly parsed arguments', async () => { + const mockSelf: any = { logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } } + const mockResponse = Promise.resolve({ success: true, result: [] }) + const mockEmberClient: any = { + getElementByPath: vi.fn(), + invoke: vi.fn().mockResolvedValue({ response: mockResponse }), + } + const funcNode: any = { + contents: { + type: ElementType.Function, + identifier: 'MyFunc', + args: [{ type: EmberModel.ParameterType.Integer, name: 'id' }], + }, + } + const mockState: any = { + emberElement: new Map([['1.2.3', funcNode]]), + updateFunctionMap: vi.fn(), + } + const mockQueue: any = { + add: vi.fn().mockImplementation((fn: any) => fn()), + } + + const callback = invokeFunctionAction(mockSelf, mockEmberClient, mockState, mockQueue) + + const action: any = { + options: { + path: '1.2.3', + usePathVar: false, + args: '42', + parseEscapeChars: true, + }, + } + + const context: any = { + parseVariablesInString: vi.fn().mockImplementation(async (str) => str), + } + + await callback(action, context) + + expect(mockEmberClient.invoke).toHaveBeenCalledWith( + funcNode, + { type: EmberModel.ParameterType.Integer, value: 42 }, + ) + expect(mockSelf.logger.info).toHaveBeenCalledWith( + 'Function "1.2.3" invoked successfully', + [], + ) + }) + + it('logs warning when path is empty', async () => { + const mockSelf: any = { logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } } + const mockEmberClient: any = { invoke: vi.fn() } + const mockState: any = { emberElement: new Map() } + const mockQueue: any = { add: vi.fn().mockImplementation((fn: any) => fn()) } + + const callback = invokeFunctionAction(mockSelf, mockEmberClient, mockState, mockQueue) + const action: any = { options: { path: '', usePathVar: false, args: '' } } + const context: any = { parseVariablesInString: vi.fn().mockImplementation(async (str) => str) } + + await callback(action, context) + + expect(mockSelf.logger.warn).toHaveBeenCalledWith('Invoke Function: Path is empty') + expect(mockEmberClient.invoke).not.toHaveBeenCalled() + }) + + it('logs error when target node is not a Function', async () => { + const mockSelf: any = { logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } } + const mockEmberClient: any = { invoke: vi.fn() } + const paramNode: any = { + contents: { + type: ElementType.Parameter, + identifier: 'Param1', + }, + } + const mockState: any = { + emberElement: new Map([['1.2.3', paramNode]]), + updateFunctionMap: vi.fn(), + } + const mockQueue: any = { add: vi.fn().mockImplementation((fn: any) => fn()) } + + const callback = invokeFunctionAction(mockSelf, mockEmberClient, mockState, mockQueue) + const action: any = { options: { path: '1.2.3', usePathVar: false, args: '' } } + const context: any = { parseVariablesInString: vi.fn().mockImplementation(async (str) => str) } + + await callback(action, context) + + expect(mockSelf.logger.error).toHaveBeenCalledWith('Invoke Function: Node at path "1.2.3" is not a valid Ember+ Function') + expect(mockEmberClient.invoke).not.toHaveBeenCalled() + }) +}) diff --git a/src/actions/function.ts b/src/actions/function.ts new file mode 100644 index 0000000..141a917 --- /dev/null +++ b/src/actions/function.ts @@ -0,0 +1,59 @@ +import type { CompanionActionEvent, CompanionActionContext } from '@companion-module/base' +import { EmberClient, Model as EmberModel } from 'emberplus-connection' +import { ElementType } from 'emberplus-connection/dist/model/index.js' +import type PQueue from 'p-queue' +import type { EmberPlusInstance } from '../index.js' +import { EmberPlusState } from '../state.js' +import { parseEscapeCharacters, parseFunctionArguments, resolveEventPath } from '../util.js' + +export const invokeFunctionAction = + (self: EmberPlusInstance, emberClient: EmberClient, state: EmberPlusState, queue: PQueue) => + async (action: CompanionActionEvent, context: CompanionActionContext): Promise => { + const path = resolveEventPath(action) + if (!path) { + self.logger.warn('Invoke Function: Path is empty') + return + } + + await queue.add(async () => { + try { + let node = state.emberElement.get(path) + if (!node) { + node = await emberClient.getElementByPath(path) + if (node) { + state.emberElement.set(path, node) + if (node.contents.type === ElementType.Function) { + state.updateFunctionMap(path, node) + } + } + } + + if (!node || node.contents.type !== ElementType.Function) { + self.logger.error(`Invoke Function: Node at path "${path}" is not a valid Ember+ Function`) + return + } + + const rawArgsInput = action.options['args']?.toString() ?? '' + const parsedArgsString = await context.parseVariablesInString(rawArgsInput) + const finalArgsString = action.options['parseEscapeChars'] + ? parseEscapeCharacters(parsedArgsString) + : parsedArgsString + + const emberFunc = node.contents as EmberModel.EmberFunction + const typedArgs = parseFunctionArguments(finalArgsString, emberFunc.args) + + self.logger.debug(`Invoking Ember+ Function at "${path}" with arguments:`, typedArgs) + + const request = await emberClient.invoke(node as any, ...typedArgs) + const result = await request.response + + if (result?.success) { + self.logger.info(`Function "${path}" invoked successfully`, result.result ?? '') + } else { + self.logger.warn(`Function "${path}" invocation failed or returned false`, result ?? '') + } + } catch (e) { + self.logger.error(`Failed to invoke function at "${path}":`, e instanceof Error ? e.message : String(e)) + } + }) + } diff --git a/src/index.ts b/src/index.ts index 8e412e1..b41a366 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { hasConnectionChanged, recordParameterAction, parseParameterValue, + discoverFunctionsFromTree, } from './util.js' import { GetVariablesList } from './variables.js' import PQueue from 'p-queue' @@ -252,6 +253,7 @@ export class EmberPlusInstance extends InstanceBase { try { const request = await this.emberClient.getDirectory(this.emberClient.tree) await request.response + discoverFunctionsFromTree(this.emberClient.tree, this.state) this.statusManager.updateStatus(InstanceStatus.Ok) this.finalizeSetup().catch((e) => { this.logger.error('Error during finalize setup', e) @@ -345,8 +347,12 @@ export class EmberPlusInstance extends InstanceBase { }) if (initial_node) { this.logger.debug('Registered for path', path) - this.state.updateParameterMap(path, initial_node) - await this.handleChangedValue(path, initial_node) + if (initial_node.contents.type === ElementType.Function) { + this.state.updateFunctionMap(path, initial_node) + } else if (initial_node.contents.type === ElementType.Parameter) { + this.state.updateParameterMap(path, initial_node) + await this.handleChangedValue(path, initial_node) + } } } catch { this.logger.error('Failed to subscribe to path', path) @@ -377,7 +383,18 @@ export class EmberPlusInstance extends InstanceBase { this.handleChangedValue(path, updatedNode).catch((e) => this.logger.error('Error handling parameter', e)) }) - if (!node || node.contents.type !== ElementType.Parameter) { + if (!node) { + return node + } + + if (node.contents.type === ElementType.Function) { + this.logger.debug('Registered function for path', path) + this.state.updateFunctionMap(path, node) + this.debouncedUpdateActionFeedbackDefs() + return node + } + + if (node.contents.type !== ElementType.Parameter) { return node } diff --git a/src/state.test.ts b/src/state.test.ts index 13fa827..1838068 100644 --- a/src/state.test.ts +++ b/src/state.test.ts @@ -6,6 +6,7 @@ vi.mock('emberplus-connection/dist/model', () => ({ ElementType: { Parameter: 'parameter', Node: 'node', + Function: 'function', }, })) @@ -24,6 +25,17 @@ function makeNode(overrides: Record = {}) { } as any } +function makeFunctionNode(overrides: Record = {}) { + return { + contents: { + type: ElementType.Function, + identifier: 'muteFunction', + description: 'Mute Channel', + ...overrides, + }, + } as any +} + // --------------------------------------------------------------------------- // constructor / initial state // --------------------------------------------------------------------------- @@ -310,4 +322,61 @@ describe('clear', () => { it('can be called on an already-empty state without throwing', () => { new EmberPlusState().clear() }) + + it('clears functions map as well', () => { + const state = new EmberPlusState() + state.updateFunctionMap('0.1.2', makeFunctionNode()) + expect(state.functions.size).toBe(1) + state.clear() + expect(state.functions.size).toBe(0) + }) +}) + +// --------------------------------------------------------------------------- +// clearCache +// --------------------------------------------------------------------------- + +describe('clearCache', () => { + it('clears emberElement map', () => { + const state = new EmberPlusState() + state.updateFunctionMap('0.1.2', makeFunctionNode()) + expect(state.emberElement.size).toBe(1) + state.clearCache() + expect(state.emberElement.size).toBe(0) + }) +}) + +// --------------------------------------------------------------------------- +// updateFunctionMap / getFunction / hasFunction +// --------------------------------------------------------------------------- + +describe('updateFunctionMap / getFunction / hasFunction', () => { + it('stores function node in functions and emberElement maps', () => { + const state = new EmberPlusState() + const funcNode = makeFunctionNode({ identifier: 'fn1' }) + state.updateFunctionMap('1.2.3', funcNode) + + expect(state.hasFunction('1.2.3')).toBe(true) + expect(state.getFunction('1.2.3')?.identifier).toBe('fn1') + expect(state.emberElement.get('1.2.3')).toBe(funcNode) + }) + + it('ignores non-function elements', () => { + const state = new EmberPlusState() + const paramNode = makeNode() + state.updateFunctionMap('1.2.3', paramNode) + + expect(state.hasFunction('1.2.3')).toBe(false) + expect(state.getFunction('1.2.3')).toBeUndefined() + }) + + it('merges data for existing function path', () => { + const state = new EmberPlusState() + state.updateFunctionMap('1.2.3', makeFunctionNode({ identifier: 'fn1', description: 'Old' })) + state.updateFunctionMap('1.2.3', makeFunctionNode({ identifier: 'fn1', description: 'New description' })) + + const stored = state.getFunction('1.2.3') + expect(stored?.identifier).toBe('fn1') + expect(stored?.description).toBe('New description') + }) }) diff --git a/src/state.ts b/src/state.ts index 3333389..445cfb4 100644 --- a/src/state.ts +++ b/src/state.ts @@ -15,6 +15,7 @@ interface Feedbacks { export class EmberPlusState { public selected: CurrentSelected public parameters: Map = new Map() + public functions: Map = new Map() public emberElement: Map> = new Map() public monitoredParameters: Set = new Set() public matrices: string[] = [] @@ -114,6 +115,19 @@ export class EmberPlusState { this.emberElement.set(path, node) } + /** + * Add or merge function node data to functions Map + * @param path Ember Path + * @param node Ember element + */ + public updateFunctionMap(path: string, node: TreeElement): void { + if (node.contents.type !== ElementType.Function) return + + const existing = this.functions.get(path) + this.functions.set(path, existing ? { ...existing, ...node.contents } : (node.contents as EmberModel.EmberFunction)) + this.emberElement.set(path, node) + } + /** * Returns the current enumeration string of the parameter * @param path Ember Path @@ -163,16 +177,33 @@ export class EmberPlusState { /** * Clear cached ember elements */ - public clearCache(): void { this.emberElement.clear() } + /** + * Get function by path + * @param path Ember Path + * @returns EmberFunction or undefined + */ + public getFunction(path: string): EmberModel.EmberFunction | undefined { + return this.functions.get(path) + } + + /** + * Check if function exists + * @param path Ember Path + */ + public hasFunction(path: string): boolean { + return this.functions.has(path) + } + /** * Clear all state */ public clear(): void { this.parameters.clear() + this.functions.clear() this.emberElement.clear() this.feedbacks.byId.clear() this.feedbacks.byPath.clear() diff --git a/src/util.test.ts b/src/util.test.ts index 4a18481..d8538ea 100644 --- a/src/util.test.ts +++ b/src/util.test.ts @@ -8,6 +8,7 @@ import { parseEscapeCharacters, substituteEscapeCharacters, filterPathChoices, + filterFunctionPathChoices, checkNumberLimits, calcRelativeNumber, resolvePath, @@ -18,6 +19,8 @@ import { hasConnectionChanged, recordParameterAction, parseParameterValue, + parseFunctionArguments, + discoverFunctionsFromTree, } from './util.js' import { ActionId } from './actions.js' import { EmberPlusState } from './state.js' @@ -36,6 +39,11 @@ vi.mock('emberplus-connection', () => ({ Enum: 'enum', String: 'string', }, + ElementType: { + Function: 'function', + Parameter: 'parameter', + Node: 'node', + }, ParameterAccess: { None: 'none', Read: 'read', @@ -45,6 +53,14 @@ vi.mock('emberplus-connection', () => ({ }, })) +vi.mock('emberplus-connection/dist/model/index.js', () => ({ + ElementType: { + Function: 'function', + Parameter: 'parameter', + Node: 'node', + }, +})) + vi.mock('./actions', () => ({ ActionId: { SetValueBoolean: 'setValueBoolean', @@ -587,3 +603,120 @@ describe('parseParameterValue', () => { expect(result.value).toBe('raw') }) }) + +// --------------------------------------------------------------------------- +// filterFunctionPathChoices +// --------------------------------------------------------------------------- + +describe('filterFunctionPathChoices', () => { + it('returns formatted choices for registered functions', () => { + const mockState: any = { + functions: new Map([ + ['1.2.3', { identifier: 'Mute', description: 'Mute Channel' }], + ['1.2.4', { identifier: 'Unmute' }], + ['1.2.5', {}], + ]), + } + + const choices = filterFunctionPathChoices(mockState) + expect(choices).toEqual([ + { id: '1.2.3', label: '1.2.3: Mute (Mute Channel)' }, + { id: '1.2.4', label: '1.2.4: Unmute' }, + { id: '1.2.5', label: '1.2.5' }, + ]) + }) + + it('returns empty array when no functions are registered', () => { + const mockState: any = { functions: new Map() } + expect(filterFunctionPathChoices(mockState)).toEqual([]) + }) +}) + +// --------------------------------------------------------------------------- +// parseFunctionArguments +// --------------------------------------------------------------------------- + +describe('parseFunctionArguments', () => { + it('returns empty array for empty input', () => { + expect(parseFunctionArguments('')).toEqual([]) + expect(parseFunctionArguments(' ')).toEqual([]) + }) + + it('parses and casts comma-separated arguments matching expected schema', () => { + const expectedArgs: any[] = [ + { type: EmberModel.ParameterType.Integer, name: 'id' }, + { type: EmberModel.ParameterType.Real, name: 'gain' }, + { type: EmberModel.ParameterType.Boolean, name: 'state' }, + { type: EmberModel.ParameterType.String, name: 'name' }, + ] + + const result = parseFunctionArguments('42, 3.14, true, Hello World', expectedArgs) + expect(result).toEqual([ + { type: EmberModel.ParameterType.Integer, value: 42 }, + { type: EmberModel.ParameterType.Real, value: 3.14 }, + { type: EmberModel.ParameterType.Boolean, value: true }, + { type: EmberModel.ParameterType.String, value: 'Hello World' }, + ]) + }) + + it('infers parameter types when no schema is provided', () => { + const result = parseFunctionArguments('true, 100, 2.5, sample') + expect(result).toEqual([ + { type: EmberModel.ParameterType.Boolean, value: true }, + { type: EmberModel.ParameterType.Integer, value: 100 }, + { type: EmberModel.ParameterType.Real, value: 2.5 }, + { type: EmberModel.ParameterType.String, value: 'sample' }, + ]) + }) + + it('parses JSON array format correctly', () => { + const result = parseFunctionArguments('[1, "abc", false]') + expect(result).toEqual([ + { type: EmberModel.ParameterType.Integer, value: 1 }, + { type: EmberModel.ParameterType.String, value: 'abc' }, + { type: EmberModel.ParameterType.Boolean, value: false }, + ]) + }) + + it('passes through explicit typed objects in JSON array', () => { + const result = parseFunctionArguments('[{"type": "integer", "value": 55}]') + expect(result).toEqual([{ type: 'integer', value: 55 }]) + }) +}) + +// --------------------------------------------------------------------------- +// discoverFunctionsFromTree +// --------------------------------------------------------------------------- + +describe('discoverFunctionsFromTree', () => { + it('recursively discovers function nodes in tree', () => { + const state = new EmberPlusState() + const tree = { + 0: { + number: 1, + contents: { type: 'node' }, + children: { + 0: { + number: 2, + contents: { type: 'function', identifier: 'TestFunc' }, + }, + 1: { + number: 3, + contents: { type: 'parameter' }, + }, + }, + }, + } + + discoverFunctionsFromTree(tree, state) + expect(state.hasFunction('1.2')).toBe(true) + expect(state.getFunction('1.2')?.identifier).toBe('TestFunc') + expect(state.hasFunction('1.3')).toBe(false) + }) + + it('handles empty or null tree gracefully', () => { + const state = new EmberPlusState() + discoverFunctionsFromTree(null, state) + expect(state.functions.size).toBe(0) + }) +}) diff --git a/src/util.ts b/src/util.ts index cb04657..a2adcf0 100644 --- a/src/util.ts +++ b/src/util.ts @@ -4,21 +4,6 @@ import type { CompanionFeedbackInfo, DropdownChoice, } from '@companion-module/base' -import { ActionId, type setValueActionOptions } from './actions.js' -import type { EmberPlusConfig } from './config.js' -import type { EmberPlusInstance } from './index.js' -import { EmberPlusState } from './state.js' -import { Model as EmberModel } from 'emberplus-connection' - -export function assertUnreachable(_never: never): void { - // throw new Error('Unreachable') -} - -export function literal(val: T): T { - return val -} - -export type Required = T extends object ? { [P in keyof T]-?: NonNullable } : T export enum NumberComparitor { Equal = 'eq', @@ -38,6 +23,25 @@ export const comparitorOptions: DropdownChoice[] = [ { id: NumberComparitor.GreaterThanEqual, label: '>=' }, ] +import { ActionId, type setValueActionOptions } from './actions.js' +import type { EmberPlusConfig } from './config.js' +import type { EmberPlusInstance } from './index.js' +import { EmberPlusState } from './state.js' +import { Model as EmberModel } from 'emberplus-connection' +import { ElementType } from 'emberplus-connection/dist/model/index.js' +import type { EmberTypedValue } from 'emberplus-connection/dist/types/index.js' +import type { FunctionArgument } from 'emberplus-connection/dist/model/FunctionArgument.js' + +export function assertUnreachable(_never: never): void { + // throw new Error('Unreachable') +} + +export function literal(val: T): T { + return val +} + +export type Required = T extends object ? { [P in keyof T]-?: NonNullable } : T + export function compareNumber(target: number, comparitor: NumberComparitor, currentValue: number): boolean { const targetValue = Number(target) if (isNaN(targetValue)) { @@ -331,3 +335,143 @@ export function parseParameterValue( return { actionType, value } } + +/** + * Return array of dropdown choices of registered Ember+ functions + */ +export function filterFunctionPathChoices(state: EmberPlusState): DropdownChoice[] { + const choices: DropdownChoice[] = [] + for (const [path, func] of state.functions) { + let label = `${path}` + if (func.identifier) { + label += `: ${func.identifier}` + } + if (func.description) { + label += ` (${func.description})` + } + choices.push({ id: path, label }) + } + return choices +} + +/** + * Parse raw input arguments string into EmberTypedValue array for Ember+ function invocation. + * Handles JSON array input, comma/line separated lists, and schema-based type casting. + */ +export function parseFunctionArguments( + rawArgsString: string, + expectedArgs?: FunctionArgument[], +): EmberTypedValue[] { + const trimmed = rawArgsString.trim() + if (!trimmed) return [] + + // Try JSON parsing if argument input looks like a JSON array + if (trimmed.startsWith('[')) { + try { + const jsonParsed = JSON.parse(trimmed) + if (Array.isArray(jsonParsed)) { + return jsonParsed.map((item, idx) => { + // Check if item is already an EmberTypedValue object ({ type, value }) + if (typeof item === 'object' && item !== null && 'type' in item && 'value' in item) { + return item as EmberTypedValue + } + + const expected = expectedArgs?.[idx] + if (expected) { + return castToEmberTypedValue(item, expected.type) + } + + if (typeof item === 'boolean') { + return { type: EmberModel.ParameterType.Boolean, value: item } + } + if (typeof item === 'number') { + return { + type: Number.isInteger(item) ? EmberModel.ParameterType.Integer : EmberModel.ParameterType.Real, + value: item, + } + } + return { type: EmberModel.ParameterType.String, value: String(item) } + }) + } + } catch { + // Fallback to comma/line separation if JSON parsing fails + } + } + + // Split by newline or comma + const tokens = trimmed.split(/[\n,]+/).map((t) => t.trim()).filter((t) => t.length > 0) + + return tokens.map((token, idx) => { + const expected = expectedArgs?.[idx] + if (expected) { + return castStringToType(token, expected.type) + } + + // Infer type if schema argument is not available + if (token.toLowerCase() === 'true') return { type: EmberModel.ParameterType.Boolean, value: true } + if (token.toLowerCase() === 'false') return { type: EmberModel.ParameterType.Boolean, value: false } + if (/^-?\d+$/.test(token)) return { type: EmberModel.ParameterType.Integer, value: Number.parseInt(token, 10) } + if (/^-?\d+\.\d+$/.test(token)) return { type: EmberModel.ParameterType.Real, value: Number.parseFloat(token) } + + return { type: EmberModel.ParameterType.String, value: token } + }) +} + +function castToEmberTypedValue(value: any, targetType: EmberModel.ParameterType): EmberTypedValue { + switch (targetType) { + case EmberModel.ParameterType.Boolean: + return { type: targetType, value: Boolean(value) } + case EmberModel.ParameterType.Integer: + case EmberModel.ParameterType.Enum: + return { type: targetType, value: Math.round(Number(value)) } + case EmberModel.ParameterType.Real: + return { type: targetType, value: Number(value) } + case EmberModel.ParameterType.String: + default: + return { type: targetType, value: String(value) } + } +} + +function castStringToType(token: string, targetType: EmberModel.ParameterType): EmberTypedValue { + switch (targetType) { + case EmberModel.ParameterType.Boolean: + return { + type: targetType, + value: token.toLowerCase() === 'true' || token === '1', + } + case EmberModel.ParameterType.Integer: + case EmberModel.ParameterType.Enum: { + const parsed = Number.parseInt(token, 10) + return { type: targetType, value: Number.isNaN(parsed) ? 0 : parsed } + } + case EmberModel.ParameterType.Real: { + const parsed = Number.parseFloat(token) + return { type: targetType, value: Number.isNaN(parsed) ? 0 : parsed } + } + case EmberModel.ParameterType.String: + default: + return { type: targetType, value: token } + } +} + +/** + * Recursively discover Function nodes from an Ember+ tree collection. + */ +export function discoverFunctionsFromTree(nodes: any, state: EmberPlusState, parentPath = ''): void { + if (!nodes) return + const elements = Array.isArray(nodes) ? nodes : Object.values(nodes) + + for (const node of elements) { + if (!node || typeof node !== 'object') continue + const num = node.number ?? node.path + const currentPath = parentPath && num !== undefined ? `${parentPath}.${num}` : `${num ?? ''}` + + if (node.contents?.type === ElementType.Function && currentPath) { + state.updateFunctionMap(currentPath, node) + } + + if (node.children) { + discoverFunctionsFromTree(node.children, state, currentPath) + } + } +}