Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
}
],
"no-control-regex": "off",
"no-eval": "off",
"no-eval": "error",
"no-redeclare": "error",
"no-restricted-imports": [
"error",
Expand Down
6 changes: 5 additions & 1 deletion src/lib/litegraph/src/litegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,11 @@ export { isColorable } from './utils/type'
export { createUuidv4 } from './utils/uuid'
export type { UUID } from './utils/uuid'
export { truncateText } from './utils/textUtils'
export { getWidgetStep, resolveNodeRootGraphId } from './utils/widget'
export {
evaluateInput,
getWidgetStep,
resolveNodeRootGraphId
} from './utils/widget'
export { distributeSpace, type SpaceRequest } from './utils/spaceDistribution'

export { BaseWidget } from './widgets/BaseWidget'
Expand Down
121 changes: 121 additions & 0 deletions src/lib/litegraph/src/utils/mathParser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, expect, test } from 'vitest'

import { evaluateMathExpression } from '@/lib/litegraph/src/utils/mathParser'

describe('evaluateMathExpression', () => {
test.each([
['2+3', 5],
['10-4', 6],
['3*7', 21],
['15/3', 5]
])('basic arithmetic: %s = %d', (input, expected) => {
expect(evaluateMathExpression(input)).toBe(expected)
})

test.each([
['2+3*4', 14],
['(2+3)*4', 20],
['10-2*3', 4],
['10/2+3', 8]
])('operator precedence: %s = %d', (input, expected) => {
expect(evaluateMathExpression(input)).toBe(expected)
})

test.each([
['3.14*2', 6.28],
['.5+.5', 1],
['1.5+2.5', 4],
['0.1+0.2', 0.1 + 0.2],
['123.', 123],
['123.+3', 126]
])('decimals: %s', (input, expected) => {
expect(evaluateMathExpression(input)).toBe(expected)
})

test.each([
[' 2 + 3 ', 5],
[' 10 - 4 ', 6],
[' ( 2 + 3 ) * 4 ', 20]
])('whitespace handling: "%s" = %d', (input, expected) => {
expect(evaluateMathExpression(input)).toBe(expected)
})

test.each([
['((2+3))', 5],
['(1+(2*(3+4)))', 15],
['((1+2)*(3+4))', 21]
])('nested parentheses: %s = %d', (input, expected) => {
expect(evaluateMathExpression(input)).toBe(expected)
})

test.each([
['-5', -5],
['-(3+2)', -5],
['--5', 5],
['+5', 5],
['-3*2', -6],
['2*-3', -6],
['1+-2', -1],
['2--3', 5],
['-2*-3', 6],
['-(2+3)*-(4+5)', 45]
])('unary operators: %s = %d', (input, expected) => {
expect(evaluateMathExpression(input)).toBe(expected)
})

test.each([
['2 /2+3 * 4.75- -6', 21.25],
['2 / (2 + 3) * 4.33 - -6', 7.732],
['12* 123/-(-5 + 2)', 492],
['((80 - (19)))', 61],
['(1 - 2) + -(-(-(-4)))', 3],
['1 - -(-(-(-4)))', -3],
['12* 123/(-5 + 2)', -492],
['12 * -123', -1476],
['((2.33 / (2.9+3.5)*4) - -6)', 7.45625],
['123.45*(678.90 / (-2.5+ 11.5)-(80 -19) *33.25) / 20 + 11', -12042.760875],
[
'(123.45*(678.90 / (-2.5+ 11.5)-(((80 -(19))) *33.25)) / 20) - (123.45*(678.90 / (-2.5+ 11.5)-(((80 -(19))) *33.25)) / 20) + (13 - 2)/ -(-11) ',
1
]
])('complex expression: %s', (input, expected) => {
expect(evaluateMathExpression(input)).toBeCloseTo(expected as number)
})

test.each(['', 'abc', '2+', '(2+3', '2+3)', '()', '*3', '2 3', '.', '123..'])(
'invalid input returns undefined: "%s"',
(input) => {
expect(evaluateMathExpression(input)).toBeUndefined()
}
)

test('division by zero returns Infinity', () => {
expect(evaluateMathExpression('1/0')).toBe(Infinity)
})

test('0/0 returns NaN', () => {
expect(evaluateMathExpression('0/0')).toBeNaN()
})

test.each([
['10%3', 1],
['10%3+1', 2],
['7%2', 1]
])('modulo: %s = %d', (input, expected) => {
expect(evaluateMathExpression(input)).toBe(expected)
})

test('negative zero is normalized to positive zero', () => {
expect(Object.is(evaluateMathExpression('-0'), 0)).toBe(true)
})

test('deeply nested parentheses exceeding depth limit returns undefined', () => {
const input = '('.repeat(201) + '1' + ')'.repeat(201)
expect(evaluateMathExpression(input)).toBeUndefined()
})

test('parentheses within depth limit evaluate correctly', () => {
const input = '('.repeat(200) + '1' + ')'.repeat(200)
expect(evaluateMathExpression(input)).toBe(1)
})
})
116 changes: 116 additions & 0 deletions src/lib/litegraph/src/utils/mathParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
type Token = { type: 'number'; value: number } | { type: 'op'; value: string }

function tokenize(input: string): Token[] | undefined {
const tokens: Token[] = []
const re = /(\d+(?:\.\d*)?|\.\d+)|([+\-*/%()])/g
let lastIndex = 0

for (const match of input.matchAll(re)) {
const gap = input.slice(lastIndex, match.index)
if (gap.trim()) return undefined
lastIndex = match.index + match[0].length

if (match[1]) tokens.push({ type: 'number', value: parseFloat(match[1]) })
else tokens.push({ type: 'op', value: match[2] })
}

if (input.slice(lastIndex).trim()) return undefined
return tokens
}

/**
* Evaluates a basic arithmetic expression string containing
* `+`, `-`, `*`, `/`, `%`, parentheses, and decimal numbers.
* Returns `undefined` for empty or malformed input.
*/
export function evaluateMathExpression(input: string): number | undefined {
const tokenized = tokenize(input)
if (!tokenized || tokenized.length === 0) return undefined

const tokens: Token[] = tokenized
let pos = 0
let depth = 0
const MAX_DEPTH = 200

function peek(): Token | undefined {
return tokens[pos]
}

function consume(): Token {
return tokens[pos++]
}

function primary(): number | undefined {
const t = peek()
if (!t) return undefined

if (t.type === 'number') {
consume()
return t.value
}

if (t.type === 'op' && t.value === '(') {
if (++depth > MAX_DEPTH) return undefined
consume()
const result = expr()
if (result === undefined) return undefined
const closing = peek()
if (!closing || closing.type !== 'op' || closing.value !== ')') {
return undefined
}
consume()
depth--
return result
}

return undefined
}

function unary(): number | undefined {
const t = peek()
if (t?.type === 'op' && (t.value === '+' || t.value === '-')) {
consume()
const operand = unary()
if (operand === undefined) return undefined
return t.value === '-' ? -operand : operand
}
return primary()
}

function factor(): number | undefined {
let left = unary()
if (left === undefined) return undefined

while (
peek()?.type === 'op' &&
(peek()!.value === '*' || peek()!.value === '/' || peek()!.value === '%')
) {
const op = consume().value
const right = unary()
if (right === undefined) return undefined
left =
op === '*' ? left * right : op === '/' ? left / right : left % right
}
return left
}

function expr(): number | undefined {
let left = factor()
if (left === undefined) return undefined

while (
peek()?.type === 'op' &&
(peek()!.value === '+' || peek()!.value === '-')
) {
const op = consume().value
const right = factor()
if (right === undefined) return undefined
left = op === '+' ? left + right : left - right
}
return left
}

const result = expr()
if (result === undefined || pos !== tokens.length) return undefined
return result === 0 ? 0 : result
}
55 changes: 55 additions & 0 deletions src/lib/litegraph/src/utils/widget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, test } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IWidgetOptions } from '@/lib/litegraph/src/litegraph'
import {
evaluateInput,
getWidgetStep,
resolveNodeRootGraphId
} from '@/lib/litegraph/src/litegraph'
Expand Down Expand Up @@ -70,3 +71,57 @@ describe('resolveNodeRootGraphId', () => {
expect(resolveNodeRootGraphId(node, 'app-root-id')).toBe('app-root-id')
})
})

describe('evaluateInput', () => {
test.each([
['42', 42],
['3.14', 3.14],
['-7', -7],
['0', 0]
])('plain number: "%s" = %d', (input, expected) => {
expect(evaluateInput(input)).toBe(expected)
})

test.each([
['2+3', 5],
['(4+2)*3', 18],
['3.14*2', 6.28],
['10/2+3', 8]
])('expression: "%s" = %d', (input, expected) => {
expect(evaluateInput(input)).toBe(expected)
})

test('empty string returns 0 (Number("") === 0)', () => {
expect(evaluateInput('')).toBe(0)
})

test.each(['abc', 'hello world'])(
'invalid input returns undefined: "%s"',
(input) => {
expect(evaluateInput(input)).toBeUndefined()
}
)

test('division by zero returns undefined', () => {
expect(evaluateInput('1/0')).toBeUndefined()
})

test('0/0 returns undefined (NaN is filtered)', () => {
expect(evaluateInput('0/0')).toBeUndefined()
})

test('scientific notation via Number() fallback', () => {
expect(evaluateInput('1e5')).toBe(100000)
})

test('hex notation via Number() fallback', () => {
expect(evaluateInput('0xff')).toBe(255)
})

test.each(['Infinity', '-Infinity'])(
'"%s" returns undefined (non-finite rejected)',
(input) => {
expect(evaluateInput(input)).toBeUndefined()
}
)
})
16 changes: 7 additions & 9 deletions src/lib/litegraph/src/utils/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'

import { evaluateMathExpression } from '@/lib/litegraph/src/utils/mathParser'

/**
* The step value for numeric widgets.
* Use {@link IWidgetOptions.step2} if available, otherwise fallback to
Expand All @@ -12,17 +14,13 @@ export function getWidgetStep(options: IWidgetOptions<unknown>): number {
}

export function evaluateInput(input: string): number | undefined {
// Check if v is a valid equation or a number
if (/^[\d\s.()*+/-]+$/.test(input)) {
// Solve the equation if possible
try {
input = eval(input)
} catch {
// Ignore eval errors
}
const result = evaluateMathExpression(input)
if (result !== undefined) {
if (!isFinite(result)) return undefined
return result
}
const newValue = Number(input)
if (isNaN(newValue)) return undefined
if (!isFinite(newValue)) return undefined
return newValue
}

Expand Down
Loading