-
Notifications
You must be signed in to change notification settings - Fork 168
Feature/choosecols function #1734
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,17 +3,58 @@ | |
| * Copyright (c) 2025 Handsoncode. All rights reserved. | ||
| */ | ||
|
|
||
| import {AbsoluteCellRange} from '../../AbsoluteCellRange' | ||
| import {ArraySize} from '../../ArraySize' | ||
| import {CellError, ErrorType} from '../../Cell' | ||
| import {ErrorMessage} from '../../error-message' | ||
| import {AstNodeType, ProcedureAst} from '../../parser' | ||
| import {Ast, AstNodeType, ProcedureAst} from '../../parser' | ||
| import {coerceScalarToBoolean} from '../ArithmeticHelper' | ||
| import {InterpreterState} from '../InterpreterState' | ||
| import {InternalScalarValue, InterpreterValue} from '../InterpreterValue' | ||
| import {getRawValue, InternalScalarValue, InterpreterValue} from '../InterpreterValue' | ||
| import {SimpleRangeValue} from '../../SimpleRangeValue' | ||
| import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' | ||
|
|
||
| /** A CHOOSECOLS index classified without evaluating a formula expression. */ | ||
| type ChooseColsLiteralIndex = | ||
| | {kind: 'value', value: number} | ||
| | {kind: 'invalid'} | ||
| | {kind: 'unresolved'} | ||
|
|
||
| export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypecheck<ArrayPlugin> { | ||
| /** | ||
| * Classifies an index literal for static CHOOSECOLS result-size prediction. | ||
| * | ||
| * @param {Ast} argument - The column-index argument to inspect without evaluating formulas. | ||
| * @returns {ChooseColsLiteralIndex} A coerced literal value, an invalid marker, or an unresolved marker. | ||
| */ | ||
| private parseChooseColsLiteralIndex(argument: Ast): ChooseColsLiteralIndex { | ||
| if (argument.type === AstNodeType.NUMBER) { | ||
| return {kind: 'value', value: Math.trunc(argument.value)} | ||
| } | ||
|
|
||
| if (argument.type === AstNodeType.STRING) { | ||
| const coercedValue = this.arithmeticHelper.coerceToMaybeNumber(argument.value) | ||
| if (coercedValue === undefined) { | ||
| return {kind: 'invalid'} | ||
| } | ||
| return {kind: 'value', value: Math.trunc(getRawValue(coercedValue))} | ||
| } | ||
|
|
||
| if (argument.type === AstNodeType.PLUS_UNARY_OP && argument.value.type === AstNodeType.NUMBER) { | ||
| return {kind: 'value', value: Math.trunc(argument.value.value)} | ||
| } | ||
|
|
||
| if (argument.type === AstNodeType.MINUS_UNARY_OP && argument.value.type === AstNodeType.NUMBER) { | ||
| return {kind: 'value', value: Math.trunc(-argument.value.value)} | ||
| } | ||
|
|
||
| if (argument.type === AstNodeType.PARENTHESIS) { | ||
| return this.parseChooseColsLiteralIndex(argument.expression) | ||
| } | ||
|
|
||
| return {kind: 'unresolved'} | ||
| } | ||
|
|
||
| public static implementedFunctions: ImplementedFunctions = { | ||
| 'ARRAYFORMULA': { | ||
| method: 'arrayformula', | ||
|
|
@@ -43,6 +84,17 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche | |
| ], | ||
| repeatLastArgs: 1, | ||
| }, | ||
| 'CHOOSECOLS': { | ||
| method: 'choosecols', | ||
| sizeOfResultArrayMethod: 'choosecolsArraySize', | ||
| enableArrayArithmeticForArguments: true, | ||
| parameters: [ | ||
| {argumentType: FunctionArgumentType.RANGE}, | ||
| {argumentType: FunctionArgumentType.NUMBER}, | ||
| ], | ||
| repeatLastArgs: 1, | ||
| vectorizationForbidden: true, | ||
| }, | ||
| 'VSTACK': { | ||
| method: 'vstack', | ||
| sizeOfResultArrayMethod: 'vstackArraySize', | ||
|
|
@@ -166,6 +218,120 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche | |
| return new ArraySize(width, height) | ||
| } | ||
|
|
||
| /** | ||
| * Corresponds to CHOOSECOLS(array, col_num1, [col_num2], ...). | ||
| * | ||
| * Returns the requested source columns in argument order. Positive indexes | ||
| * count from the left, negative indexes count from the right, and duplicate | ||
| * indexes duplicate their columns in the result. | ||
| * | ||
| * @param {ProcedureAst} ast - The parsed function-call AST node. | ||
| * @param {InterpreterState} state - The current interpreter evaluation state. | ||
| */ | ||
| public choosecols(ast: ProcedureAst, state: InterpreterState): InterpreterValue { | ||
| return this.runFunction(ast.args, state, this.metadata('CHOOSECOLS'), | ||
| (range: SimpleRangeValue, ...columnNumbers: number[]) => { | ||
| const sourceWidth = range.width() | ||
| const sourceHeight = range.height() | ||
|
|
||
| if (sourceHeight === 0 || sourceWidth === 0) { | ||
| return new CellError(ErrorType.NA, ErrorMessage.EmptyRange) | ||
| } | ||
|
|
||
| const columnIndexes = columnNumbers.map(columnNumber => Math.trunc(columnNumber)) | ||
|
|
||
| if (columnIndexes.some(columnIndex => | ||
| !Number.isFinite(columnIndex) || columnIndex === 0 || Math.abs(columnIndex) > sourceWidth | ||
| )) { | ||
| return new CellError(ErrorType.VALUE, ErrorMessage.IndexBounds) | ||
| } | ||
|
|
||
| const zeroBasedColumnIndexes = columnIndexes.map(columnIndex => | ||
| columnIndex > 0 ? columnIndex - 1 : sourceWidth + columnIndex | ||
| ) | ||
|
|
||
| const sourceRange = range.range | ||
| if ( | ||
| sourceRange !== undefined | ||
| && !Number.isFinite(sourceRange.height()) | ||
| && (sourceRange.sheet !== state.formulaAddress.sheet || state.formulaAddress.row !== 0) | ||
| ) { | ||
| return new CellError(ErrorType.SPILL, ErrorMessage.NoSpaceForArrayResult) | ||
| } | ||
|
|
||
| if (sourceRange !== undefined) { | ||
| const selectedColumns = zeroBasedColumnIndexes.map(columnIndex => { | ||
| const columnRange = AbsoluteCellRange.spanFrom( | ||
| sourceRange.getAddress(columnIndex, 0), | ||
| 1, | ||
| sourceHeight, | ||
| ) | ||
| return SimpleRangeValue.onlyRange(columnRange, this.dependencyGraph).data | ||
| }) | ||
| const result = Array.from({length: sourceHeight}, (_, row) => | ||
| selectedColumns.map(column => column[row][0]) | ||
| ) | ||
| return SimpleRangeValue.onlyValues(result) | ||
| } | ||
|
|
||
| const result = range.data.map(row => | ||
| zeroBasedColumnIndexes.map(columnIndex => row[columnIndex]) | ||
| ) | ||
| return SimpleRangeValue.onlyValues(result) | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
| ) | ||
| } | ||
|
|
||
| /** | ||
| * Predicts the CHOOSECOLS spill size from the source height and index count. | ||
| * | ||
| * Invalid literals and unsupported unbounded result heights are rejected | ||
| * before spill allocation so a neighboring cell cannot mask a statically | ||
| * known error. Same-sheet whole-column results can retain their unbounded | ||
| * height when anchored in the first row. | ||
| * | ||
| * @param {ProcedureAst} ast - The parsed function-call AST node. | ||
| * @param {InterpreterState} state - The current interpreter evaluation state. | ||
| */ | ||
| public choosecolsArraySize(ast: ProcedureAst, state: InterpreterState): ArraySize { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same finding as on #1722's TAKE:
|
||
| if (ast.args.length < 2) { | ||
| return ArraySize.error() | ||
| } | ||
|
|
||
| const metadata = this.metadata('CHOOSECOLS') | ||
| const sourceSize = this.arraySizeForAst( | ||
| ast.args[0], | ||
| new InterpreterState(state.formulaAddress, state.arraysFlag || (metadata?.enableArrayArithmeticForArguments ?? false)), | ||
| ) | ||
|
|
||
| const sourceRange = ast.args[0].type === AstNodeType.COLUMN_RANGE | ||
| ? AbsoluteCellRange.fromAstOrUndef(ast.args[0], state.formulaAddress) | ||
| : undefined | ||
| const isSupportedWholeColumnResult = sourceSize.height === Number.POSITIVE_INFINITY | ||
| && sourceRange?.sheet === state.formulaAddress.sheet | ||
| && state.formulaAddress.row === 0 | ||
|
|
||
| if ((!Number.isFinite(sourceSize.height) && !isSupportedWholeColumnResult) || sourceSize.height < 1) { | ||
| return ArraySize.error() | ||
| } | ||
|
|
||
| for (const argument of ast.args.slice(1)) { | ||
| const index = this.parseChooseColsLiteralIndex(argument) | ||
| if ( | ||
| index.kind === 'invalid' | ||
| || (index.kind === 'value' && ( | ||
| !Number.isFinite(index.value) | ||
| || index.value === 0 | ||
| || Math.abs(index.value) > sourceSize.width | ||
| )) | ||
| ) { | ||
| return ArraySize.error() | ||
| } | ||
| } | ||
|
|
||
| return new ArraySize(ast.args.length - 1, sourceSize.height) | ||
| } | ||
|
|
||
| /** | ||
| * Corresponds to VSTACK(array1, [array2], ...) | ||
| * | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
codecov/patchis red because of exactly two untested lines: thePLUS_UNARY_OPbranch (e.g.=CHOOSECOLS(A1:D3,+2)) and thePARENTHESISbranch (e.g.(2)). One or two literal-index tests for these should close it completely.