Skip to content

Commit c6f90e7

Browse files
committed
[IMP] composer: prettify formula when content is too long.
Prettify formula depending on the content width. task: 4735172
1 parent 3e938c1 commit c6f90e7

File tree

9 files changed

+468
-44
lines changed

9 files changed

+468
-44
lines changed

src/components/composer/composer/abstract_composer_store.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -535,11 +535,14 @@ export abstract class AbstractComposerStore extends SpreadsheetStore {
535535
if (isNewCurrentContent || this.editionMode !== "inactive") {
536536
const locale = this.getters.getLocale();
537537
this.currentTokens = isFormula(text) ? composerTokenize(text, locale) : [];
538-
if (this.currentTokens.length > 100) {
538+
const nonSpaceTokensCount = this.currentTokens.filter(
539+
(token) => token.type !== "SPACE"
540+
).length;
541+
if (nonSpaceTokensCount > 200) {
539542
if (raise) {
540543
this.notificationStore.raiseError(
541544
_t(
542-
"This formula has over 100 parts. It can't be processed properly, consider splitting it into multiple cells"
545+
"This formula has over 200 parts. It can't be processed properly, consider splitting it into multiple cells"
543546
)
544547
);
545548
}

src/components/composer/composer/cell_composer_store.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { parseTokens } from "../../../formulas/parser";
12
import { parseLiteral } from "../../../helpers/cells";
23
import {
34
formatValue,
@@ -26,6 +27,7 @@ import {
2627
isMatrix,
2728
} from "../../../types";
2829
import { AbstractComposerStore } from "./abstract_composer_store";
30+
import { prettify } from "./prettifier_content";
2931

3032
const CELL_DELETED_MESSAGE = _t("The cell you are trying to edit has been deleted.");
3133

@@ -201,7 +203,10 @@ export class CellComposerStore extends AbstractComposerStore {
201203
const locale = this.getters.getLocale();
202204
const cell = this.getters.getCell(position);
203205
if (cell?.isFormula) {
204-
return localizeFormula(cell.content, locale);
206+
const pretifiedContent = cell.compiledFormula.isBadExpression
207+
? cell.content
208+
: prettify(parseTokens(cell.compiledFormula.tokens));
209+
return localizeFormula(pretifiedContent, locale);
205210
}
206211
const spreader = this.model.getters.getArrayFormulaSpreadingOn(position);
207212
if (spreader) {

src/components/composer/composer/composer.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ css/* scss */ `
6161
padding-right: 3px;
6262
outline: none;
6363
64+
tab-size: 4;
65+
6466
p {
6567
margin-bottom: 0px;
6668
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import {
2+
AST,
3+
leftOperandNeedsParenthesis,
4+
rightOperandNeedsParenthesis,
5+
} from "../../../formulas/parser";
6+
7+
// ---------------------------------------
8+
// Prettify possibility structure
9+
// ---------------------------------------
10+
11+
// The `PrettifyPossibility` structure represents all possible ways to format an Abstract Syntax Tree (AST) into a human-readable string.
12+
// It includes formatting elements such as line breaks, indentation, and token grouping.
13+
//
14+
// Once created, this structure is used to determine the best formatting path based on the available width,
15+
// allowing dynamic selection of the most suitable layout for the given constraints.
16+
17+
type PrettifyPossibility = string | ChooseBetween | Join | Nest | Line;
18+
type ChooseBetween = { type: "chooseBetween"; p1: PrettifyPossibility; p2: PrettifyPossibility };
19+
type Join = { type: "join"; rules: PrettifyPossibility[] };
20+
type Nest = { type: "nest"; indentLvl: number; p: PrettifyPossibility };
21+
type Line = { type: "line" };
22+
23+
/** Useful for indicating where to insert a new line. Placed in a group, it will be used if there is insufficient space*/
24+
function line(): Line {
25+
return { type: "line" };
26+
}
27+
28+
/** Useful for indicating where to insert a new line with a specific indentation level.
29+
* Should be placed before a line. Placed in a group, it will be used if there is insufficient space*/
30+
function nest(indentLvl: number, p: PrettifyPossibility): Nest {
31+
return { type: "nest", indentLvl, p };
32+
}
33+
34+
/** Useful for join few rules into a single rule.
35+
* Note that, this is the only way to melt strings (depending the AST value)
36+
* with other rules (ChooseBetween | Concat | Nest | Line)
37+
*/
38+
function join(pps: PrettifyPossibility[]): Join {
39+
return { type: "join", rules: pps };
40+
}
41+
42+
/** Useful for indicating where to print a rule into a single line depending on the available space.*/
43+
function group(pp: PrettifyPossibility): ChooseBetween {
44+
return chooseBetween(flatten(pp), pp);
45+
}
46+
47+
/** Used exclusively for `group`, indicating that we can choose between two rules depending on the available space.*/
48+
function chooseBetween(p1: PrettifyPossibility, p2: PrettifyPossibility): ChooseBetween {
49+
return { type: "chooseBetween", p1, p2 };
50+
}
51+
52+
/** Recursive function used exclusively for `group`, to indicate how to flatten the rules.*/
53+
function flatten(pp: PrettifyPossibility): PrettifyPossibility {
54+
if (typeof pp === "string") {
55+
return pp;
56+
}
57+
if (pp.type === "chooseBetween") {
58+
// normally should be "chooseBetween(flatten(x.a), flatten(x.b))" but compute time is too high
59+
return flatten(pp.p1);
60+
}
61+
if (pp.type === "join") {
62+
return join(pp.rules.map(flatten));
63+
}
64+
if (pp.type === "nest") {
65+
return {
66+
type: "nest",
67+
indentLvl: pp.indentLvl,
68+
p: flatten(pp.p),
69+
};
70+
}
71+
if (pp.type === "line") {
72+
return " ";
73+
}
74+
return pp;
75+
}
76+
77+
// ---------------------------------------
78+
// printer part
79+
// ---------------------------------------
80+
81+
/**
82+
* The `SubRule` represents a structured representation of an only way to
83+
* format an AST into a human-readable string. This is a reduced version of
84+
* the `PrettifyPossibility` structure,
85+
*/
86+
type SubRule = subLine | subText | null;
87+
88+
type subLine = {
89+
type: "subLine";
90+
indent: number;
91+
rule: SubRule;
92+
};
93+
type subText = {
94+
type: "subText";
95+
text: string;
96+
rule: SubRule;
97+
};
98+
99+
/**
100+
* `RestToFit` is a stateful representation of the remaining `PrettifyPossibility` to check for fitting knowing an indentation level already used.
101+
* It is a tuple where the first element is the current indentation level and the second element is the `PrettifyPossibility` to check for fitting.
102+
*/
103+
type RestToFit = [number, PrettifyPossibility];
104+
105+
/**
106+
* Print use the `PrettifyPossibility` structure to create a layout that fits within a given width.
107+
*/
108+
function print(prettifyPossibility: PrettifyPossibility, width: number): string {
109+
return layout(best(width, prettifyPossibility));
110+
}
111+
112+
/**
113+
* Recursively formats a given `SubRule` object into a human-readable string representation.
114+
* This function is the final step of the prettifier process, converting the "one way" structure
115+
* (reduced version of `PrettifyPossibility`) into a comprehensible string.
116+
*/
117+
function layout(x: SubRule): string {
118+
if (x && "type" in x) {
119+
if (x.type === "subLine") {
120+
return "\n" + "\t".repeat(x.indent) + layout(x.rule);
121+
}
122+
if (x.type === "subText") {
123+
return x.text + layout(x.rule);
124+
}
125+
}
126+
return "";
127+
}
128+
129+
/**
130+
* Determines the best sub-rule based on the given width, indentation level `k`, and the `PrettifyPossibility`.
131+
* This is the function that reduces the PrettifyPossibility to a SubRule
132+
*/
133+
function best(width: number, prettifyPossibility: PrettifyPossibility): SubRule {
134+
return _best(width, 0, [[0, prettifyPossibility]]);
135+
}
136+
137+
function _best(width: number, currentIndentLvl: number, restsToFit: RestToFit[]): SubRule {
138+
if (restsToFit.length === 0) return null;
139+
const [firstPpToFit, ...rests] = restsToFit;
140+
const [indentLvl, pp] = firstPpToFit;
141+
if (typeof pp === "string") {
142+
return {
143+
type: "subText",
144+
text: pp,
145+
rule: _best(width, currentIndentLvl + pp.length, rests),
146+
};
147+
}
148+
if (pp.type === "join") {
149+
const restsJoinToFit: RestToFit[] = pp.rules.map((j) => [indentLvl, j]);
150+
const res = _best(width, currentIndentLvl, [...restsJoinToFit, ...rests]);
151+
return res;
152+
}
153+
if (pp.type === "nest") {
154+
return _best(width, currentIndentLvl, [[indentLvl + pp.indentLvl, pp.p], ...rests]);
155+
}
156+
if (pp.type === "line") {
157+
return {
158+
type: "subLine",
159+
indent: indentLvl,
160+
rule: _best(width, indentLvl, rests),
161+
};
162+
}
163+
if (pp.type === "chooseBetween") {
164+
const a = _best(width, currentIndentLvl, [[indentLvl, pp.p1], ...rests]);
165+
const b = _best(width, currentIndentLvl, [[indentLvl, pp.p2], ...rests]);
166+
return fits(width - currentIndentLvl, a) ? a : b;
167+
}
168+
return null;
169+
}
170+
171+
/**
172+
* Determines whether a given width can accommodate a specific `SubRule`.
173+
*/
174+
function fits(width: number, x: SubRule): boolean {
175+
if (width < 0) return false;
176+
if (x === null) {
177+
return true;
178+
} else if (x.type === "subLine") {
179+
return true;
180+
} else if (x.type === "subText") {
181+
return fits(width - x.text.length, x.rule);
182+
}
183+
return false;
184+
}
185+
186+
// ---------------------------------------
187+
// AST part
188+
// ---------------------------------------
189+
190+
export function prettify(ast: AST) {
191+
return "= " + print(astToPp(ast), 38); // 38 but 40 with the `= ` at the beginning
192+
}
193+
194+
/** transform an AST composed of sub-ASTs into a PrettifyPossibility composed of sub-PrettifyPossibility.*/
195+
function astToPp(ast: AST): PrettifyPossibility {
196+
switch (ast.type) {
197+
case "NUMBER":
198+
return String(ast.value);
199+
200+
case "STRING":
201+
return `"${ast.value}"`;
202+
203+
case "BOOLEAN":
204+
return ast.value ? "TRUE" : "FALSE";
205+
206+
case "REFERENCE":
207+
return ast.value;
208+
209+
case "FUNCALL":
210+
const pps = ast.args.map(astToPp);
211+
return splitParenthesesContent(
212+
join(pps.map((pp, i) => (i < 1 ? pp : join([",", line(), pp])))),
213+
ast.value
214+
);
215+
216+
case "UNARY_OPERATION":
217+
const operandPp = astToPp(ast.operand);
218+
const needParenthesis = ast.postfix
219+
? leftOperandNeedsParenthesis(ast)
220+
: rightOperandNeedsParenthesis(ast);
221+
const finalOperandPp = needParenthesis ? splitParenthesesContent(operandPp) : operandPp;
222+
223+
return ast.postfix ? join([finalOperandPp, ast.value]) : join([ast.value, finalOperandPp]);
224+
225+
case "BIN_OPERATION": {
226+
const leftPp = astToPp(ast.left);
227+
const needParenthesisLeftPp = leftOperandNeedsParenthesis(ast);
228+
const finalLeftPp = needParenthesisLeftPp ? splitParenthesesContent(leftPp) : leftPp;
229+
230+
const rightPp = astToPp(ast.right);
231+
const needParenthesisRightPp = rightOperandNeedsParenthesis(ast);
232+
const finalRightPp = needParenthesisRightPp ? splitParenthesesContent(rightPp) : rightPp;
233+
234+
const operator = ` ${ast.value}`;
235+
return group(join([finalLeftPp, operator, nest(1, join([line(), finalRightPp]))]));
236+
}
237+
238+
case "SYMBOL":
239+
return ast.value;
240+
241+
case "EMPTY":
242+
return "";
243+
}
244+
}
245+
246+
function splitParenthesesContent(
247+
pp: PrettifyPossibility,
248+
functionName: undefined | string = undefined
249+
): PrettifyPossibility {
250+
const astToJoinGroup = ["(", nest(1, join([line(), pp])), line(), ")"];
251+
if (functionName) {
252+
astToJoinGroup.unshift(functionName);
253+
}
254+
return group(join(astToJoinGroup));
255+
}

src/formulas/parser.ts

Lines changed: 33 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,9 @@ export type AST =
109109
| ASTReference
110110
| ASTEmpty;
111111

112-
const OP_PRIORITY = {
112+
export const OP_PRIORITY = {
113+
"%": 40,
113114
"^": 30,
114-
"%": 30,
115115
"*": 20,
116116
"/": 20,
117117
"+": 15,
@@ -432,45 +432,52 @@ export function astToFormula(ast: AST): string {
432432
case "BOOLEAN":
433433
return ast.value ? "TRUE" : "FALSE";
434434
case "UNARY_OPERATION":
435-
return ast.postfix
436-
? leftOperandToFormula(ast) + ast.value
437-
: ast.value + rightOperandToFormula(ast);
435+
if (ast.postfix) {
436+
const leftOperand = leftOperandNeedsParenthesis(ast)
437+
? `(${astToFormula(ast.operand)})`
438+
: astToFormula(ast.operand);
439+
return leftOperand + ast.value;
440+
}
441+
const rightOperand = rightOperandNeedsParenthesis(ast)
442+
? `(${astToFormula(ast.operand)})`
443+
: astToFormula(ast.operand);
444+
return ast.value + rightOperand;
438445
case "BIN_OPERATION":
439-
return leftOperandToFormula(ast) + ast.value + rightOperandToFormula(ast);
446+
const leftOperation = leftOperandNeedsParenthesis(ast)
447+
? `(${astToFormula(ast.left)})`
448+
: astToFormula(ast.left);
449+
const rightOperation = rightOperandNeedsParenthesis(ast)
450+
? `(${astToFormula(ast.right)})`
451+
: astToFormula(ast.right);
452+
return leftOperation + ast.value + rightOperation;
440453
default:
441454
return ast.value;
442455
}
443456
}
444457

445-
/**
446-
* Convert the left operand of a binary operation to the corresponding string
447-
* and enclose the result inside parenthesis if necessary.
448-
*/
449-
function leftOperandToFormula(operationAST: ASTOperation | ASTUnaryOperation): string {
458+
export function leftOperandNeedsParenthesis(
459+
operationAST: ASTOperation | ASTUnaryOperation
460+
): boolean {
450461
const mainOperator = operationAST.value;
451462
const leftOperation = "left" in operationAST ? operationAST.left : operationAST.operand;
452463
const leftOperator = leftOperation.value;
453-
const needParenthesis =
454-
leftOperation.type === "BIN_OPERATION" && OP_PRIORITY[leftOperator] < OP_PRIORITY[mainOperator];
455-
return needParenthesis ? `(${astToFormula(leftOperation)})` : astToFormula(leftOperation);
464+
return (
465+
leftOperation.type === "BIN_OPERATION" && OP_PRIORITY[leftOperator] < OP_PRIORITY[mainOperator]
466+
);
456467
}
457468

458-
/**
459-
* Convert the right operand of a binary or unary operation to the corresponding string
460-
* and enclose the result inside parenthesis if necessary.
461-
*/
462-
function rightOperandToFormula(operationAST: ASTOperation | ASTUnaryOperation): string {
469+
export function rightOperandNeedsParenthesis(
470+
operationAST: ASTOperation | ASTUnaryOperation
471+
): boolean {
463472
const mainOperator = operationAST.value;
464473
const rightOperation = "right" in operationAST ? operationAST.right : operationAST.operand;
465474
const rightPriority = OP_PRIORITY[rightOperation.value];
466475
const mainPriority = OP_PRIORITY[mainOperator];
467-
let needParenthesis = false;
468476
if (rightOperation.type !== "BIN_OPERATION") {
469-
needParenthesis = false;
470-
} else if (rightPriority < mainPriority) {
471-
needParenthesis = true;
472-
} else if (rightPriority === mainPriority && !ASSOCIATIVE_OPERATORS.includes(mainOperator)) {
473-
needParenthesis = true;
477+
return false;
478+
}
479+
if (rightPriority < mainPriority) {
480+
return true;
474481
}
475-
return needParenthesis ? `(${astToFormula(rightOperation)})` : astToFormula(rightOperation);
482+
return rightPriority === mainPriority && !ASSOCIATIVE_OPERATORS.includes(mainOperator);
476483
}

0 commit comments

Comments
 (0)