diff --git a/src/components/composer/composer/abstract_composer_store.ts b/src/components/composer/composer/abstract_composer_store.ts index f9a730b5d3..cd08a5ca4a 100644 --- a/src/components/composer/composer/abstract_composer_store.ts +++ b/src/components/composer/composer/abstract_composer_store.ts @@ -535,11 +535,14 @@ export abstract class AbstractComposerStore extends SpreadsheetStore { if (isNewCurrentContent || this.editionMode !== "inactive") { const locale = this.getters.getLocale(); this.currentTokens = isFormula(text) ? composerTokenize(text, locale) : []; - if (this.currentTokens.length > 100) { + const nonSpaceTokensCount = this.currentTokens.filter( + (token) => token.type !== "SPACE" + ).length; + if (nonSpaceTokensCount > 1000) { if (raise) { this.notificationStore.raiseError( _t( - "This formula has over 100 parts. It can't be processed properly, consider splitting it into multiple cells" + "This formula has over 1000 parts. It can't be processed properly, consider splitting it into multiple cells" ) ); } diff --git a/src/components/composer/composer/cell_composer_store.ts b/src/components/composer/composer/cell_composer_store.ts index 8199e92daf..2e6339ce34 100644 --- a/src/components/composer/composer/cell_composer_store.ts +++ b/src/components/composer/composer/cell_composer_store.ts @@ -1,3 +1,5 @@ +import { prettify } from "../../../formulas/formula_formatter"; +import { parseTokens } from "../../../formulas/parser"; import { isMultipleElementMatrix, toScalar } from "../../../functions/helper_matrices"; import { parseLiteral } from "../../../helpers/cells"; import { @@ -202,7 +204,10 @@ export class CellComposerStore extends AbstractComposerStore { const locale = this.getters.getLocale(); const cell = this.getters.getCell(position); if (cell?.isFormula) { - return localizeFormula(cell.content, locale); + const prettifiedContent = cell.compiledFormula.isBadExpression + ? cell.content + : prettify(parseTokens(cell.compiledFormula.tokens), 80); + return localizeFormula(prettifiedContent, locale); } const spreader = this.model.getters.getArrayFormulaSpreadingOn(position); if (spreader) { diff --git a/src/components/composer/composer/composer.ts b/src/components/composer/composer/composer.ts index 675f8a4041..f60924ce06 100644 --- a/src/components/composer/composer/composer.ts +++ b/src/components/composer/composer/composer.ts @@ -61,6 +61,8 @@ css/* scss */ ` padding-right: 3px; outline: none; + tab-size: 4; + p { margin-bottom: 0px; diff --git a/src/components/composer/top_bar_composer/top_bar_composer.ts b/src/components/composer/top_bar_composer/top_bar_composer.ts index 59c421392c..fb9afcea75 100644 --- a/src/components/composer/top_bar_composer/top_bar_composer.ts +++ b/src/components/composer/top_bar_composer/top_bar_composer.ts @@ -14,7 +14,7 @@ import { CellComposerStore } from "../composer/cell_composer_store"; import { Composer } from "../composer/composer"; import { ComposerFocusStore, ComposerInterface } from "../composer_focus_store"; -const COMPOSER_MAX_HEIGHT = 100; +const COMPOSER_MAX_HEIGHT = 300; /* svg free of use from https://uxwing.com/formula-fx-icon/ */ const FX_SVG = /*xml*/ ` diff --git a/src/components/spreadsheet/spreadsheet.ts b/src/components/spreadsheet/spreadsheet.ts index 8215fee290..582181f354 100644 --- a/src/components/spreadsheet/spreadsheet.ts +++ b/src/components/spreadsheet/spreadsheet.ts @@ -318,7 +318,6 @@ css/* scss */ ` } } - .o-spreadsheet-topbar-wrapper, .o-spreadsheet-bottombar-wrapper { z-index: ${ComponentsImportance.ScrollBar + 1}; } diff --git a/src/formulas/formula_formatter.ts b/src/formulas/formula_formatter.ts new file mode 100644 index 0000000000..4459a6cb8a --- /dev/null +++ b/src/formulas/formula_formatter.ts @@ -0,0 +1,353 @@ +import { memoize } from "../helpers"; +import { AST, ASTOperation, ASTUnaryOperation, OP_PRIORITY } from "./parser"; + +const ASSOCIATIVE_OPERATORS = ["*", "+", "&"]; + +/** + * Pretty-prints formula ASTs into readable formulas. + * + * Implements a Wadler-inspired pretty printer: + * it converts an AST into a `Doc` structure, + * and then chooses between compact (flat) or expanded (with + * line breaks and indentation) layouts depending on space. + * + * References: + * - https://lik.ai/blog/how-a-pretty-printer-works/ + * - Wadler, "A prettier printer": https://homepages.inf.ed.ac.uk/wadler/papers/prettier/prettier.pdf + */ +export function prettify(ast: AST, width = 60): string { + return "=" + print(astToDoc(ast), width - 1); // width-1 because of the leading '=' +} + +// --------------------------------------- +// Doc structure +// --------------------------------------- + +/** + * A `Doc` represents alternative layouts (tree of layouts) for pretty-printing. + * The printer chooses the layout that fits best within the available width. + */ +type Doc = string | ChooseBetween | Concat | Nest | InsertLine; + +type ChooseBetween = { type: "chooseBetween"; doc1: Doc; doc2: Doc }; +type Concat = { type: "concat"; docs: Doc[] }; +type Nest = { type: "nest"; indentLevel: number; doc: Doc }; +type InsertLine = { type: "insertLine" }; + +/** + * A possible line break. + * Printed as either space or newline. + */ +function line(): InsertLine { + return { type: "insertLine" }; +} + +/** + * Increase indentation for a nested block. + */ +function nest(indentLevel: number, doc: Doc): Nest { + return { type: "nest", indentLevel, doc }; +} + +/** + * Combines multiple docs into a single doc, concatenating them side by side + * without any line breaks or indentation. + */ +function concat(docs: Doc[]): Concat { + return { type: "concat", docs }; +} + +/** + * Marks a document as a unit to be printed "flat" (all on one line) + * if it fits within the width, otherwise with line breaks. + */ +function group(doc: Doc): ChooseBetween { + return chooseBetween(flatten(doc), doc); +} + +/** + * Creates a choice between two alternative layouts. + * The formatter tries `doc1`; if it does not fit within + * the line width, it falls back to `doc2`. + */ +function chooseBetween(doc1: Doc, doc2: Doc): ChooseBetween { + return { type: "chooseBetween", doc1, doc2 }; +} + +/** + * Flattens a doc into its single-line form. + */ +function flatten(doc: Doc): Doc { + if (typeof doc === "string") { + return doc; + } + if (doc.type === "chooseBetween") { + // Normally should be "chooseBetween(flatten(doc.doc1), flatten(doc.doc2))", + // but this is simplified for performance reasons. + return flatten(doc.doc1); + } + if (doc.type === "concat") { + return concat(doc.docs.map(flatten)); + } + if (doc.type === "nest") { + return { + type: "nest", + indentLevel: doc.indentLevel, + doc: flatten(doc.doc), + }; + } + if (doc.type === "insertLine") { + return ""; + } + return doc; +} + +// --------------------------------------- +// Printer part +// --------------------------------------- + +/** + * A linked list for string segments. + * Used to avoid large string concatenations during layout selection. + */ +interface LinkedString { + subString: string; + next: LinkedString | null; +} + +const getIndentationString = memoize(function getIndentationString(indentLevel: number): string { + return "\n" + "\t".repeat(indentLevel); +}); + +/** + * Converts a `Doc` into a string representation that fits within + * the specified width. + */ +function print(doc: Doc, width: number): string { + return stringify(selectBestLayout(width, doc)); +} + +/** + * Join all segments of a LinkedString into the final string. + */ +function stringify(linkedString: LinkedString | null): string { + let result = ""; + while (linkedString) { + result += linkedString.subString; + linkedString = linkedString.next; + } + return result; +} + +/** + * Layout selection for a `Doc` that fits within the given width. + */ +function selectBestLayout(width: number, doc: Doc): LinkedString | null { + const head: RestToFitNode = { + indentLevel: 0, + doc, + next: null, + }; + return _selectBestLayout(width, 0, head); +} + +/** + * A specialized linked list node for tracking the remaining `Doc` to fit. + */ +interface RestToFitNode { + indentLevel: number; + doc: Doc; + next: RestToFitNode | null; +} + +function _selectBestLayout( + width: number, + currentIndentLevel: number, + head: RestToFitNode | null +): LinkedString | null { + if (head === null) { + return null; + } + + const { indentLevel, doc, next } = head; + + if (typeof doc === "string") { + return { + subString: doc, + next: _selectBestLayout(width, currentIndentLevel + doc.length, next), + }; + } + if (doc.type === "concat") { + let newHead = next; + for (let i = doc.docs.length - 1; i >= 0; i--) { + newHead = { indentLevel, doc: doc.docs[i], next: newHead }; + } + return _selectBestLayout(width, currentIndentLevel, newHead); + } + if (doc.type === "nest") { + return _selectBestLayout(width, currentIndentLevel, { + indentLevel: indentLevel + doc.indentLevel, + doc: doc.doc, + next, + }); + } + if (doc.type === "insertLine") { + return { + subString: getIndentationString(indentLevel), + next: _selectBestLayout(width, indentLevel, next), + }; + } + if (doc.type === "chooseBetween") { + const head1 = { indentLevel, doc: doc.doc1, next }; + const possibleLinkedString = _selectBestLayout(width, currentIndentLevel, head1); + if (fits(width - currentIndentLevel, possibleLinkedString)) { + return possibleLinkedString; + } + + const head2 = { indentLevel, doc: doc.doc2, next }; + return _selectBestLayout(width, currentIndentLevel, head2); + } + return null; +} + +/** + * Check if a layout fits on a single line within width. + */ +function fits(width: number, linkedString: LinkedString | null): boolean { + while (linkedString) { + if (linkedString.subString[0] === "\n") { + return true; + } + width -= linkedString.subString.length; + if (width < 0) return false; + linkedString = linkedString.next; + } + return true; +} + +function astToDoc(ast: AST): Doc { + switch (ast.type) { + case "NUMBER": + return String(ast.value); + + case "STRING": + return `"${ast.value}"`; + + case "BOOLEAN": + return ast.value ? "TRUE" : "FALSE"; + + case "REFERENCE": + return ast.value; + + case "FUNCALL": + const docs = ast.args.map(astToDoc); + return wrapInParentheses( + concat(docs.map((doc, i) => (i < 1 ? doc : concat([", ", line(), doc])))), + ast.value + ); + + case "UNARY_OPERATION": + const operandDoc = astToDoc(ast.operand); + const needParenthesis = ast.postfix + ? leftOperandNeedsParenthesis(ast) + : rightOperandNeedsParenthesis(ast); + const finalOperandDoc = needParenthesis ? wrapInParentheses(operandDoc) : operandDoc; + + return ast.postfix + ? concat([finalOperandDoc, ast.value]) + : concat([ast.value, finalOperandDoc]); + + case "BIN_OPERATION": { + const leftDoc = astToDoc(ast.left); + const needParenthesisLeftDoc = leftOperandNeedsParenthesis(ast); + const finalLeftDoc = needParenthesisLeftDoc ? wrapInParentheses(leftDoc) : leftDoc; + + const rightDoc = astToDoc(ast.right); + const needParenthesisRightDoc = rightOperandNeedsParenthesis(ast); + const finalRightDoc = needParenthesisRightDoc ? wrapInParentheses(rightDoc) : rightDoc; + + const operator = `${ast.value}`; + return group(concat([finalLeftDoc, operator, nest(1, concat([line(), finalRightDoc]))])); + } + + case "SYMBOL": + return ast.value; + + case "EMPTY": + return ""; + } +} + +/** + * Wraps a `Doc` in parentheses (with optional function name). + */ +function wrapInParentheses(doc: Doc, functionName: undefined | string = undefined): Doc { + const docToConcat = ["(", nest(1, concat([line(), doc])), line(), ")"]; + if (functionName) { + docToConcat.unshift(functionName); + } + return group(concat(docToConcat)); +} + +/** + * Converts an ast formula to the corresponding string + */ +export function astToFormula(ast: AST): string { + switch (ast.type) { + case "FUNCALL": + const args = ast.args.map((arg) => astToFormula(arg)); + return `${ast.value}(${args.join(",")})`; + case "NUMBER": + return ast.value.toString(); + case "REFERENCE": + return ast.value; + case "STRING": + return `"${ast.value}"`; + case "BOOLEAN": + return ast.value ? "TRUE" : "FALSE"; + case "UNARY_OPERATION": + if (ast.postfix) { + const leftOperand = leftOperandNeedsParenthesis(ast) + ? `(${astToFormula(ast.operand)})` + : astToFormula(ast.operand); + return leftOperand + ast.value; + } + const rightOperand = rightOperandNeedsParenthesis(ast) + ? `(${astToFormula(ast.operand)})` + : astToFormula(ast.operand); + return ast.value + rightOperand; + case "BIN_OPERATION": + const leftOperation = leftOperandNeedsParenthesis(ast) + ? `(${astToFormula(ast.left)})` + : astToFormula(ast.left); + const rightOperation = rightOperandNeedsParenthesis(ast) + ? `(${astToFormula(ast.right)})` + : astToFormula(ast.right); + return leftOperation + ast.value + rightOperation; + default: + return ast.value; + } +} + +function leftOperandNeedsParenthesis(operationAST: ASTOperation | ASTUnaryOperation): boolean { + const mainOperator = operationAST.value; + const leftOperation = "left" in operationAST ? operationAST.left : operationAST.operand; + const leftOperator = leftOperation.value; + return ( + leftOperation.type === "BIN_OPERATION" && OP_PRIORITY[leftOperator] < OP_PRIORITY[mainOperator] + ); +} + +function rightOperandNeedsParenthesis(operationAST: ASTOperation | ASTUnaryOperation): boolean { + const mainOperator = operationAST.value; + const rightOperation = "right" in operationAST ? operationAST.right : operationAST.operand; + const rightPriority = OP_PRIORITY[rightOperation.value]; + const mainPriority = OP_PRIORITY[mainOperator]; + if (rightOperation.type !== "BIN_OPERATION") { + return false; + } + if (rightPriority < mainPriority) { + return true; + } + return rightPriority === mainPriority && !ASSOCIATIVE_OPERATORS.includes(mainOperator); +} diff --git a/src/formulas/parser.ts b/src/formulas/parser.ts index 3504c87e5d..54eb7f1aea 100644 --- a/src/formulas/parser.ts +++ b/src/formulas/parser.ts @@ -10,8 +10,6 @@ const functionRegex = /[a-zA-Z0-9\_]+(\.[a-zA-Z0-9\_]+)*/; const UNARY_OPERATORS_PREFIX = ["-", "+"]; const UNARY_OPERATORS_POSTFIX = ["%"]; -const ASSOCIATIVE_OPERATORS = ["*", "+", "&"]; - interface RichToken extends Token { tokenIndex: number; } @@ -68,14 +66,14 @@ interface ASTBoolean extends ASTBase { value: boolean; } -interface ASTUnaryOperation extends ASTBase { +export interface ASTUnaryOperation extends ASTBase { type: "UNARY_OPERATION"; value: any; operand: AST; postfix?: boolean; // needed to rebuild string from ast } -interface ASTOperation extends ASTBase { +export interface ASTOperation extends ASTBase { type: "BIN_OPERATION"; value: any; left: AST; @@ -109,9 +107,9 @@ export type AST = | ASTReference | ASTEmpty; -const OP_PRIORITY = { +export const OP_PRIORITY = { + "%": 40, "^": 30, - "%": 30, "*": 20, "/": 20, "+": 15, @@ -414,63 +412,3 @@ export function mapAst( return ast; } } - -/** - * Converts an ast formula to the corresponding string - */ -export function astToFormula(ast: AST): string { - switch (ast.type) { - case "FUNCALL": - const args = ast.args.map((arg) => astToFormula(arg)); - return `${ast.value}(${args.join(",")})`; - case "NUMBER": - return ast.value.toString(); - case "REFERENCE": - return ast.value; - case "STRING": - return `"${ast.value}"`; - case "BOOLEAN": - return ast.value ? "TRUE" : "FALSE"; - case "UNARY_OPERATION": - return ast.postfix - ? leftOperandToFormula(ast) + ast.value - : ast.value + rightOperandToFormula(ast); - case "BIN_OPERATION": - return leftOperandToFormula(ast) + ast.value + rightOperandToFormula(ast); - default: - return ast.value; - } -} - -/** - * Convert the left operand of a binary operation to the corresponding string - * and enclose the result inside parenthesis if necessary. - */ -function leftOperandToFormula(operationAST: ASTOperation | ASTUnaryOperation): string { - const mainOperator = operationAST.value; - const leftOperation = "left" in operationAST ? operationAST.left : operationAST.operand; - const leftOperator = leftOperation.value; - const needParenthesis = - leftOperation.type === "BIN_OPERATION" && OP_PRIORITY[leftOperator] < OP_PRIORITY[mainOperator]; - return needParenthesis ? `(${astToFormula(leftOperation)})` : astToFormula(leftOperation); -} - -/** - * Convert the right operand of a binary or unary operation to the corresponding string - * and enclose the result inside parenthesis if necessary. - */ -function rightOperandToFormula(operationAST: ASTOperation | ASTUnaryOperation): string { - const mainOperator = operationAST.value; - const rightOperation = "right" in operationAST ? operationAST.right : operationAST.operand; - const rightPriority = OP_PRIORITY[rightOperation.value]; - const mainPriority = OP_PRIORITY[mainOperator]; - let needParenthesis = false; - if (rightOperation.type !== "BIN_OPERATION") { - needParenthesis = false; - } else if (rightPriority < mainPriority) { - needParenthesis = true; - } else if (rightPriority === mainPriority && !ASSOCIATIVE_OPERATORS.includes(mainOperator)) { - needParenthesis = true; - } - return needParenthesis ? `(${astToFormula(rightOperation)})` : astToFormula(rightOperation); -} diff --git a/src/index.ts b/src/index.ts index 871fa09c18..612a54606d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -213,13 +213,8 @@ export { tokenColors } from "./components/composer/composer/abstract_composer_st export { Spreadsheet } from "./components/index"; export { setDefaultSheetViewSize } from "./constants"; export { compile, compileTokens, functionCache } from "./formulas/compiler"; -export { - astToFormula, - convertAstNodes, - iterateAstNodes, - parse, - parseTokens, -} from "./formulas/parser"; +export { astToFormula } from "./formulas/formula_formatter"; +export { convertAstNodes, iterateAstNodes, parse, parseTokens } from "./formulas/parser"; export { tokenize } from "./formulas/tokenizer"; export { AbstractChart } from "./helpers/figures/charts"; export { findCellInNewZone } from "./helpers/zones"; diff --git a/src/plugins/ui_core_views/pivot_ui.ts b/src/plugins/ui_core_views/pivot_ui.ts index 164341c82c..0353aea4e0 100644 --- a/src/plugins/ui_core_views/pivot_ui.ts +++ b/src/plugins/ui_core_views/pivot_ui.ts @@ -1,5 +1,5 @@ import { Token } from "../../formulas"; -import { astToFormula } from "../../formulas/parser"; +import { astToFormula } from "../../formulas/formula_formatter"; import { toScalar } from "../../functions/helper_matrices"; import { toBoolean } from "../../functions/helpers"; import { getUniqueText } from "../../helpers"; diff --git a/src/xlsx/functions/cells.ts b/src/xlsx/functions/cells.ts index 0fd10cd8b8..e1ab14cb9c 100644 --- a/src/xlsx/functions/cells.ts +++ b/src/xlsx/functions/cells.ts @@ -1,11 +1,5 @@ -import { - AST, - ASTFuncall, - ASTString, - astToFormula, - convertAstNodes, - parse, -} from "../../formulas/parser"; +import { astToFormula } from "../../formulas/formula_formatter"; +import { AST, ASTFuncall, ASTString, convertAstNodes, parse } from "../../formulas/parser"; import { functionRegistry } from "../../functions"; import { formatValue, isNumber } from "../../helpers"; import { mdyDateRegexp, parseDateTime, timeRegexp, ymdDateRegexp } from "../../helpers/dates"; diff --git a/tests/__snapshots__/top_bar_component.test.ts.snap b/tests/__snapshots__/top_bar_component.test.ts.snap index b2e7d6c8bd..8d698d985b 100644 --- a/tests/__snapshots__/top_bar_component.test.ts.snap +++ b/tests/__snapshots__/top_bar_component.test.ts.snap @@ -688,7 +688,7 @@ exports[`TopBar component can set cell format 1`] = ` contenteditable="true" inputmode="text" spellcheck="false" - style="padding:5px 0px 5px 8px; max-height:100px; line-height:24px; height:34px; " + style="padding:5px 0px 5px 8px; max-height:300px; line-height:24px; height:34px; " tabindex="1" /> @@ -1807,7 +1807,7 @@ exports[`TopBar component simple rendering 1`] = ` contenteditable="true" inputmode="text" spellcheck="false" - style="padding:5px 0px 5px 8px; max-height:100px; line-height:24px; height:34px; " + style="padding:5px 0px 5px 8px; max-height:300px; line-height:24px; height:34px; " tabindex="1" /> diff --git a/tests/composer/composer_store.test.ts b/tests/composer/composer_store.test.ts index 2aa791179c..dc9f883f18 100644 --- a/tests/composer/composer_store.test.ts +++ b/tests/composer/composer_store.test.ts @@ -721,8 +721,7 @@ describe("edition", () => { const notificationStore = container.get(NotificationStore); const spyNotify = jest.spyOn(notificationStore, "raiseError"); composerStore.startEdition(); - const content = // 101 tokens - "=1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1"; + const content = "=" + "+1".repeat(500); // 1001 characters composerStore.setCurrentContent(content); composerStore.stopEdition(); @@ -1185,4 +1184,36 @@ describe("edition", () => { expect(store.editionMode).toBe("inactive"); }); }); + + test("Prettify the content depending on the length of the formula", () => { + setCellContent( + model, + "A1", + "=SUM(11111111,22222222,33333333,44444444,55555555,66666666,77777777)" + ); + composerStore.startEdition(); + expect(composerStore.currentContent).toBe( + "=SUM(11111111, 22222222, 33333333, 44444444, 55555555, 66666666, 77777777)" + ); + + setCellContent( + model, + "A1", + "=SUM(11111111,22222222,33333333,44444444,55555555,66666666,77777777,88888888)" + ); + composerStore.startEdition(); + expect(composerStore.currentContent).toBe( + // prettier-ignore + "=SUM(\n" + + "\t11111111, \n" + + "\t22222222, \n" + + "\t33333333, \n" + + "\t44444444, \n" + + "\t55555555, \n" + + "\t66666666, \n" + + "\t77777777, \n" + + "\t88888888\n" + + ")" + ); + }); }); diff --git a/tests/evaluation/evaluation.test.ts b/tests/evaluation/evaluation.test.ts index 8f383010a8..fd4f6ef290 100644 --- a/tests/evaluation/evaluation.test.ts +++ b/tests/evaluation/evaluation.test.ts @@ -352,6 +352,7 @@ describe("evaluateCells", () => { setCellContent(model, "A5", "= - 1 + - 2 * - 3"); setCellContent(model, "A6", "=1 & 8 + 2"); setCellContent(model, "A7", "=1 & 10 - 2"); + setCellContent(model, "A8", "=2^100%"); expect(getEvaluatedCell(model, "A1").value).toBe(7); expect(getEvaluatedCell(model, "A2").value).toBe(4); @@ -360,6 +361,7 @@ describe("evaluateCells", () => { expect(getEvaluatedCell(model, "A5").value).toBe(5); expect(getEvaluatedCell(model, "A6").value).toBe("110"); expect(getEvaluatedCell(model, "A7").value).toBe("18"); + expect(getEvaluatedCell(model, "A8").value).toBe(2); }); test("& operator", () => { diff --git a/tests/evaluation/formula_formatter.test.ts b/tests/evaluation/formula_formatter.test.ts new file mode 100644 index 0000000000..a6f4693c09 --- /dev/null +++ b/tests/evaluation/formula_formatter.test.ts @@ -0,0 +1,108 @@ +import { parse } from "../../src"; +import { prettify } from "../../src/formulas/formula_formatter"; + +function prettifyContent(content: string): string { + return prettify(parse(content)); +} + +describe("formula formatter", () => { + test("remove extra spaces around operators", () => { + expect(prettifyContent("= A2 + A3 ")).toBe("=A2+A3"); + }); + + test("remove extra spaces around formula arguments ", () => { + expect(prettifyContent("=SUM( 1 , 2 , 3 )")).toBe("=SUM(1, 2, 3)"); + }); + + test("remove the extra parentheses", () => { + // should not be. But we loose the parentheses information during the parsing into an AST + expect(prettifyContent("=(2*(((A2)+A3)))")).toBe("=2*(A2+A3)"); + expect(prettifyContent("=1+(2+(3+4))")).toBe("=1+2+3+4"); + expect(prettifyContent("=2^3%")).toBe("=2^3%"); + }); + + test("keep parentheses who against priority order", () => { + expect(prettifyContent("=((2+3))*4")).toBe("=(2+3)*4"); + expect(prettifyContent("=1-((2-(3+4)))")).toBe("=1-(2-(3+4))"); + expect(prettifyContent("=2^((3^4))")).toBe("=2^(3^4)"); + expect(prettifyContent("=2/((3/4))")).toBe("=2/(3/4)"); + }); + + test("nested functions are properly indented", () => { + expect(prettifyContent("=SUM(AVERAGE(1,2,3,4), MAX(5,6,7,8), MIN(10,11,12,13))")).toBe( + "=SUM(\n" + + "\tAVERAGE(1, 2, 3, 4), \n" + + "\tMAX(5, 6, 7, 8), \n" + + "\tMIN(10, 11, 12, 13)\n" + + ")" + ); + }); + + test("long nested functions are properly indented with sub-lvls", () => { + expect( + prettifyContent( + "=SUM(AVERAGE(COUNT(4,5,6,7),COUNT(10,11,12,13),COUNT(14,15,16,17)), MAX(COUNT(4,5,6,7),COUNT(10,11,12,13),COUNT(14,15,16,17)))" + ) + ).toBe( + "=SUM(\n" + + "\tAVERAGE(\n" + + "\t\tCOUNT(4, 5, 6, 7), \n" + + "\t\tCOUNT(10, 11, 12, 13), \n" + + "\t\tCOUNT(14, 15, 16, 17)\n" + + "\t), \n" + + "\tMAX(\n" + + "\t\tCOUNT(4, 5, 6, 7), \n" + + "\t\tCOUNT(10, 11, 12, 13), \n" + + "\t\tCOUNT(14, 15, 16, 17)\n" + + "\t)\n" + + ")" + ); + }); + + test("too long binary operation series are split in multiple lines and indented", () => { + expect( + prettifyContent( + "=SUM(1111 + 2222 + 3333 + 4444 + 5555 + 6666 + 7777 + 8888 + 9999 + 11111 + 22222 + 33333 + 44444)" + ) + ).toBe( + //prettier-ignore + "=SUM(\n" + + "\t1111+2222+3333+4444+5555+6666+7777+8888+9999+11111+22222+\n" + + "\t\t33333+\n" + + "\t\t44444\n" + + ")" + ); + }); + + test("during binary operations, keep priority operations on the same line", () => { + expect( + prettifyContent( + "=SUM(1111 + 2222 + 3333 + 4444 + 5555 + 6666 + 7777 + 8888 + 9999 + 11111 + 22222 + 33333 * 44444 - 55555 + 66666 / 77777 )" + ) + ).toBe( + "=SUM(\n" + + "\t1111+2222+3333+4444+5555+6666+7777+8888+9999+11111+22222+\n" + + "\t\t33333*44444-\n" + + "\t\t55555+\n" + + "\t\t66666/77777\n" + + ")" + ); + }); + + test("long functions with nested parenthesis for mathematical operation are properly indented with sub-lvls", () => { + expect( + prettifyContent( + "=1*(2-2-2-2-2-2-2-(3+3+3+3+3+3+3+3+3-(4+4+4+4+4+4+4+4+4/(4+5+6+7+5+6+7+8+9))))" + ) + ).toBe( + "=1*\n" + + "\t(\n" + + "\t\t2-2-2-2-2-2-2-\n" + + "\t\t\t(\n" + + "\t\t\t\t3+3+3+3+3+3+3+3+3-\n" + + "\t\t\t\t\t(4+4+4+4+4+4+4+4+4/(4+5+6+7+5+6+7+8+9))\n" + + "\t\t\t)\n" + + "\t)" + ); + }); +}); diff --git a/tests/evaluation/parser.test.ts b/tests/evaluation/parser.test.ts index ef9b022c61..71f000624f 100644 --- a/tests/evaluation/parser.test.ts +++ b/tests/evaluation/parser.test.ts @@ -1,4 +1,5 @@ -import { astToFormula, parse, tokenize } from "../../src"; +import { parse, tokenize } from "../../src"; +import { astToFormula } from "../../src/formulas/formula_formatter"; import { CellErrorType } from "../../src/types/errors"; describe("parser", () => { diff --git a/tests/spreadsheet/__snapshots__/spreadsheet_component.test.ts.snap b/tests/spreadsheet/__snapshots__/spreadsheet_component.test.ts.snap index 47456b0408..0d5af91624 100644 --- a/tests/spreadsheet/__snapshots__/spreadsheet_component.test.ts.snap +++ b/tests/spreadsheet/__snapshots__/spreadsheet_component.test.ts.snap @@ -691,7 +691,7 @@ exports[`Simple Spreadsheet Component simple rendering snapshot 1`] = ` contenteditable="true" inputmode="text" spellcheck="false" - style="padding:5px 0px 5px 8px; max-height:100px; line-height:24px; height:34px; " + style="padding:5px 0px 5px 8px; max-height:300px; line-height:24px; height:34px; " tabindex="1" />