prettier-plugin-xml-salesforce 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +100 -0
- package/package.json +44 -0
- package/src/plugin.js +77 -0
- package/src/preprocessors/flow-tag.js +22 -0
- package/src/preprocessors/formula.js +474 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2019-present Kevin Newton
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# prettier-plugin-xml-salesforce
|
|
2
|
+
|
|
3
|
+
<a href="https://www.npmjs.com/package/prettier-plugin-xml-salesforce">
|
|
4
|
+
<img alt="NPM Version" src="https://img.shields.io/npm/v/prettier-plugin-xml-salesforce.svg?style=flat-square">
|
|
5
|
+
</a>
|
|
6
|
+
<a href="https://github.com/prettier/prettier#badge">
|
|
7
|
+
<img alt="code style: prettier" src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square">
|
|
8
|
+
</a>
|
|
9
|
+
|
|
10
|
+
This plugin extends the functionality of the plugin `@prettier/plugin-xml`, providing custom formatting for Salesforce XML files, specifically handling `<flow>` elements and formula tags.
|
|
11
|
+
|
|
12
|
+
This plugin also support (re-exports) all the standard features of `@prettier/plugin-xml`: [see documentation](https://github.com/prettier/plugin-xml#readme).
|
|
13
|
+
|
|
14
|
+
## Problem Statement
|
|
15
|
+
|
|
16
|
+
### Flow Tags
|
|
17
|
+
|
|
18
|
+
Salesforce Permission Set XML files may contain `<flow>` tags which grant access to specific flows. These tags require to be formatted as single-line elements to maintain proper XML structure by design. Unfortunately, the default `@prettier/plugin-xml` does not handle this correctly, often breaking the intended single-line format.
|
|
19
|
+
|
|
20
|
+
#### Example:
|
|
21
|
+
|
|
22
|
+
```xml
|
|
23
|
+
<PermissionSet>
|
|
24
|
+
<flow>SomeFlowAccess</flow>
|
|
25
|
+
</PermissionSet>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
easily becomes:
|
|
29
|
+
|
|
30
|
+
```xml
|
|
31
|
+
<PermissionSet>
|
|
32
|
+
<flow>
|
|
33
|
+
SomeFlowAccess;
|
|
34
|
+
</flow>
|
|
35
|
+
</PermissionSet>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
This breaks the deployment of the `*.permissionset-meta.xml` files.
|
|
39
|
+
|
|
40
|
+
### Formulas
|
|
41
|
+
|
|
42
|
+
Salesforce Formula Fields and Validation Rules often contain complex formula expressions. These formulas need to be formatted in a canonical way to ensure readability and maintainability. Unfortunately, there is no default formatting for these formulas, so they must be formatted manually or retriaved and left as-is by default.
|
|
43
|
+
|
|
44
|
+
#### Example:
|
|
45
|
+
|
|
46
|
+
```xml
|
|
47
|
+
<CustomField>
|
|
48
|
+
<formula>AND(
|
|
49
|
+
ISPICKVAL ( Status__c , "quot;Active"quot;),
|
|
50
|
+
NOT( ISBLANK( End_Date__c) ))
|
|
51
|
+
</formula>
|
|
52
|
+
</CustomField>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
This plugin formats Salesforce formula tags in a canonical way:
|
|
56
|
+
|
|
57
|
+
```xml
|
|
58
|
+
<CustomField>
|
|
59
|
+
<formula>
|
|
60
|
+
AND(
|
|
61
|
+
ISPICKVAL(Status__c, "Active"),
|
|
62
|
+
NOT(ISBLANK(End_Date__c))
|
|
63
|
+
)
|
|
64
|
+
</formula>
|
|
65
|
+
</CustomField>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
This allows easier readability and maintainability of complex Salesforce formulas within XML files.
|
|
69
|
+
|
|
70
|
+
#### Salesforce Limitations
|
|
71
|
+
|
|
72
|
+
Formula fields in Salesforce have a character limit, and overly complex formulas may exceed this limit when formatted with additional line breaks and indentation. Developers should be aware of this limitation when using the plugin to format formula tags.
|
|
73
|
+
|
|
74
|
+
| Limit | Value |
|
|
75
|
+
| --------------- | --------------- |
|
|
76
|
+
| Formula Field | 4000 characters |
|
|
77
|
+
| Validation Rule | 3900 characters |
|
|
78
|
+
|
|
79
|
+
## Usage
|
|
80
|
+
|
|
81
|
+
### Prettier Configuration
|
|
82
|
+
|
|
83
|
+
In the Prettier configuration file (e.g., `.prettierrc`), you can enable the plugin and its custom options as follows:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"plugins": ["prettier-plugin-xml-salesforce"],
|
|
88
|
+
"xmlFlowSingleLine": true,
|
|
89
|
+
"xmlFormulaFormat": true
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
This plugin should be used instead of the default `@prettier/plugin-xml` when working with Salesforce XML files to ensure proper formatting of `<flow>` tags (in permission sets) and formula tags.
|
|
94
|
+
|
|
95
|
+
### Prettier Options
|
|
96
|
+
|
|
97
|
+
| Option | Type | Default | Description |
|
|
98
|
+
| ----------------- | ------- | ------- | ---------------------------------------------------------------- |
|
|
99
|
+
| xmlFlowSingleLine | boolean | true | Format `<flow>...</flow>` as a single line and drop trailing `;` |
|
|
100
|
+
| xmlFormulaFormat | boolean | false | Canonically format Salesforce formula tags |
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "prettier-plugin-xml-salesforce",
|
|
3
|
+
"description": "A Prettier plugin for formatting XML files in Salesforce projects",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Dmitry Kovalev",
|
|
7
|
+
"email": "kov.dmm@gmail.com",
|
|
8
|
+
"url": "https://github.com/kovdmm"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"files": [
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/kovdmm/prettier-plugin-xml-salesforce.git"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"salesforce",
|
|
20
|
+
"formula-fields",
|
|
21
|
+
"validation-rules",
|
|
22
|
+
"prettier",
|
|
23
|
+
"xml",
|
|
24
|
+
"format",
|
|
25
|
+
"formatting"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"format": "prettier --write .",
|
|
29
|
+
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
|
|
30
|
+
},
|
|
31
|
+
"type": "module",
|
|
32
|
+
"exports": "./src/plugin.js",
|
|
33
|
+
"types": "./types/plugin.d.ts",
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@prettier/plugin-xml": "^3.4.2"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"prettier": "^3.0.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"jest": "^30.5.2",
|
|
42
|
+
"prettier": "^3.9.8"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/plugin.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as xmlPlugin from "@prettier/plugin-xml";
|
|
2
|
+
import { isFlowElement, normalizeFlowTagsInSource } from "./preprocessors/flow-tag.js";
|
|
3
|
+
import { normalizeFormulasInSource } from "./preprocessors/formula.js";
|
|
4
|
+
|
|
5
|
+
const xml = xmlPlugin.default ?? xmlPlugin;
|
|
6
|
+
const baseXmlParser = xml.parsers?.xml;
|
|
7
|
+
const baseXmlPrinter = xml.printers?.xml;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Redefines the preprocess function to handle <flow> and formula tags according to the plugin's custom options.
|
|
11
|
+
* @param {string} text The source XML text to preprocess.
|
|
12
|
+
* @param {*} opts The Prettier options object.
|
|
13
|
+
* @returns {string} The preprocessed XML text.
|
|
14
|
+
*/
|
|
15
|
+
const preprocess = (text, opts) => {
|
|
16
|
+
const basePreprocess = baseXmlParser.preprocess;
|
|
17
|
+
let preprocessedText = typeof basePreprocess === "function" ? basePreprocess(text, opts) : text;
|
|
18
|
+
if (opts?.xmlFlowSingleLine) {
|
|
19
|
+
preprocessedText = normalizeFlowTagsInSource(preprocessedText);
|
|
20
|
+
}
|
|
21
|
+
if (opts?.xmlFormulaFormat) {
|
|
22
|
+
preprocessedText = normalizeFormulasInSource(preprocessedText, opts.printWidth);
|
|
23
|
+
}
|
|
24
|
+
return preprocessedText;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Redefines the embed function for <flow> elements to prevent the JS "flow" parser from being used.
|
|
29
|
+
*
|
|
30
|
+
* @prettier/plugin-xml embeds the element content using a Prettier parser that matches the tag name. For <flow> tags
|
|
31
|
+
* this accidentally triggers the built-in JS "flow" parser, which adds `;` and newlines, which breaks the intended
|
|
32
|
+
* single-line formatting for <flow> tags.
|
|
33
|
+
*
|
|
34
|
+
* @param {*} path The AST path for the current node. Should be an element node representing a <flow> tag.
|
|
35
|
+
* @param {*} opts The Prettier options object.
|
|
36
|
+
* @returns The embedded content for the <flow> element, or null to prevent the default embedding.
|
|
37
|
+
*/
|
|
38
|
+
const embed = (path, opts) => {
|
|
39
|
+
return opts?.xmlFlowSingleLine && isFlowElement(path.getValue()) ? null : baseXmlPrinter.embed?.(path, opts);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Exports the languages supported by the plugin, extending the default @prettier/plugin-xml languages. */
|
|
43
|
+
export const languages = xml.languages;
|
|
44
|
+
|
|
45
|
+
/** Extends the default @prettier/plugin-xml options with the plugin's custom XML options. */
|
|
46
|
+
export const options = {
|
|
47
|
+
...xml.options,
|
|
48
|
+
xmlFlowSingleLine: {
|
|
49
|
+
type: "boolean",
|
|
50
|
+
category: "XML",
|
|
51
|
+
default: true,
|
|
52
|
+
description: "Format <flow>...</flow> as a single line and drop trailing ';'. (Project-specific)",
|
|
53
|
+
},
|
|
54
|
+
xmlFormulaFormat: {
|
|
55
|
+
type: "boolean",
|
|
56
|
+
category: "XML",
|
|
57
|
+
default: false,
|
|
58
|
+
description: "Canonically format Salesforce formula tags (AND/OR expansion). (Project-specific)",
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Extends the default @prettier/plugin-xml options with the plugin's custom XML options. */
|
|
63
|
+
export const defaultOptions = {
|
|
64
|
+
...xml.defaultOptions,
|
|
65
|
+
xmlFlowSingleLine: true,
|
|
66
|
+
xmlFormulaFormat: false,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Redefines the XML parser to use the custom preprocess function for handling <flow> and formula tags. */
|
|
70
|
+
export const parsers = {
|
|
71
|
+
xml: { ...baseXmlParser, astFormat: "xml-salesforce", preprocess },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Redefines the XML printer to use the custom embed function for handling <flow> elements. */
|
|
75
|
+
export const printers = {
|
|
76
|
+
"xml-salesforce": { ...baseXmlPrinter, embed },
|
|
77
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Project-specific handling for Salesforce <flow> tags in XML metadata.
|
|
2
|
+
// In SF metadata <flow> contains a short plain-text value (e.g. a flow name) with no nested XML,
|
|
3
|
+
// so it should be rendered on a single line without a trailing ';'.
|
|
4
|
+
|
|
5
|
+
export const isFlowElement = (node) => {
|
|
6
|
+
return node?.name === "element" && typeof node.Name === "string" && node.Name.toLowerCase() === "flow";
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Collapses every <flow>...</flow> tag onto a single line and drops a trailing ';'.
|
|
11
|
+
* Runs on the raw source BEFORE parsing/printing, so it never affects other tags.
|
|
12
|
+
* @param {string} sourceText The full XML metadata source.
|
|
13
|
+
* @returns {string} The source with <flow> tags normalized.
|
|
14
|
+
*/
|
|
15
|
+
export const normalizeFlowTagsInSource = (sourceText) => {
|
|
16
|
+
return sourceText.replaceAll(/<flow>\s*([^<]*?)\s*<\/flow>/g, (_, inner) => {
|
|
17
|
+
const cleaned = String(inner)
|
|
18
|
+
.replaceAll(/\s+|;\s*$/g, " ")
|
|
19
|
+
.trim();
|
|
20
|
+
return `<flow>${cleaned}</flow>`;
|
|
21
|
+
});
|
|
22
|
+
};
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
// Canonical formatter for Salesforce formula expressions used inside XML metadata
|
|
2
|
+
// (<errorConditionFormula>, <formula>, <validationFormula>).
|
|
3
|
+
//
|
|
4
|
+
// Style rules (project convention):
|
|
5
|
+
// - AND(...) / OR(...) are ALWAYS expanded: one argument per line, 4-space indent.
|
|
6
|
+
// - Infix boolean operators && / || are converted to AND() / OR().
|
|
7
|
+
// - The concat operator & is left untouched (NOT rewritten to +); long & / +
|
|
8
|
+
// chains wrap one operand per line when they exceed printWidth.
|
|
9
|
+
// - Other function calls stay on a single line unless they exceed printWidth.
|
|
10
|
+
// - Entities are unescaped to their literal characters, EXCEPT the structurally
|
|
11
|
+
// significant < > & which stay escaped.
|
|
12
|
+
|
|
13
|
+
const FORMULA_TAGS = ["errorConditionFormula", "formula", "validationFormula"];
|
|
14
|
+
const INDENT_UNIT = " ";
|
|
15
|
+
const DEFAULT_PRINT_WIDTH = 120;
|
|
16
|
+
|
|
17
|
+
//#region Entities
|
|
18
|
+
const decodeEntities = (text) => {
|
|
19
|
+
return text.replaceAll(/&(lt|gt|amp|quot|apos|#\d+|#x[0-9a-fA-F]+);/g, (match, entity) => {
|
|
20
|
+
switch (entity) {
|
|
21
|
+
case "lt":
|
|
22
|
+
return "<";
|
|
23
|
+
case "gt":
|
|
24
|
+
return ">";
|
|
25
|
+
case "amp":
|
|
26
|
+
return "&";
|
|
27
|
+
case "quot":
|
|
28
|
+
return '"';
|
|
29
|
+
case "apos":
|
|
30
|
+
return "'";
|
|
31
|
+
default: {
|
|
32
|
+
const codePoint =
|
|
33
|
+
entity[1] === "x" || entity[1] === "X"
|
|
34
|
+
? Number.parseInt(entity.slice(2), 16)
|
|
35
|
+
: Number.parseInt(entity.slice(1), 10);
|
|
36
|
+
return Number.isNaN(codePoint) ? match : String.fromCodePoint(codePoint);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Re-escape only the characters that must remain entities in XML text content.
|
|
43
|
+
const encodeForXml = (text) => {
|
|
44
|
+
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
45
|
+
};
|
|
46
|
+
//#endregion
|
|
47
|
+
|
|
48
|
+
//#region Tokenizer
|
|
49
|
+
const TWO_CHAR_OPERATORS = new Set(["&&", "||", "<=", ">=", "<>", "!=", "=="]);
|
|
50
|
+
const SINGLE_CHAR_OPERATORS = new Set(["=", "<", ">", "+", "-", "*", "/", "^", "&", "!"]);
|
|
51
|
+
|
|
52
|
+
const tokenize = (input) => {
|
|
53
|
+
const tokens = [];
|
|
54
|
+
let index = 0;
|
|
55
|
+
|
|
56
|
+
while (index < input.length) {
|
|
57
|
+
const char = input[index];
|
|
58
|
+
|
|
59
|
+
if (/\s/.test(char)) {
|
|
60
|
+
index++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (char === "'" || char === '"') {
|
|
65
|
+
const quote = char;
|
|
66
|
+
let value = quote;
|
|
67
|
+
let cursor = index + 1;
|
|
68
|
+
while (cursor < input.length) {
|
|
69
|
+
if (input[cursor] === "\\" && cursor + 1 < input.length) {
|
|
70
|
+
value += input[cursor] + input[cursor + 1];
|
|
71
|
+
cursor += 2;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
value += input[cursor];
|
|
75
|
+
if (input[cursor] === quote) {
|
|
76
|
+
cursor++;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
cursor++;
|
|
80
|
+
}
|
|
81
|
+
tokens.push({ type: "string", value });
|
|
82
|
+
index = cursor;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (/[0-9]/.test(char) || (char === "." && /[0-9]/.test(input[index + 1] ?? ""))) {
|
|
87
|
+
let value = "";
|
|
88
|
+
while (index < input.length && /[0-9.]/.test(input[index])) {
|
|
89
|
+
value += input[index];
|
|
90
|
+
index++;
|
|
91
|
+
}
|
|
92
|
+
tokens.push({ type: "number", value });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (/[A-Za-z_$]/.test(char)) {
|
|
97
|
+
let value = "";
|
|
98
|
+
while (index < input.length && /[\w$.:]/.test(input[index])) {
|
|
99
|
+
value += input[index];
|
|
100
|
+
index++;
|
|
101
|
+
}
|
|
102
|
+
tokens.push({ type: "ident", value });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const twoChar = input.slice(index, index + 2);
|
|
107
|
+
if (TWO_CHAR_OPERATORS.has(twoChar)) {
|
|
108
|
+
tokens.push({ type: "op", value: twoChar });
|
|
109
|
+
index += 2;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (char === "(") {
|
|
114
|
+
tokens.push({ type: "lparen" });
|
|
115
|
+
index++;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (char === ")") {
|
|
119
|
+
tokens.push({ type: "rparen" });
|
|
120
|
+
index++;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (char === ",") {
|
|
124
|
+
tokens.push({ type: "comma" });
|
|
125
|
+
index++;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (SINGLE_CHAR_OPERATORS.has(char)) {
|
|
129
|
+
tokens.push({ type: "op", value: char });
|
|
130
|
+
index++;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
throw new Error(`Unexpected character '${char}' at position ${index}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return tokens;
|
|
138
|
+
};
|
|
139
|
+
//#endregion
|
|
140
|
+
|
|
141
|
+
//#region Parser
|
|
142
|
+
const PRECEDENCE = {
|
|
143
|
+
"||": 1,
|
|
144
|
+
"&&": 2,
|
|
145
|
+
"=": 3,
|
|
146
|
+
"==": 3,
|
|
147
|
+
"<>": 3,
|
|
148
|
+
"!=": 3,
|
|
149
|
+
"<": 3,
|
|
150
|
+
">": 3,
|
|
151
|
+
"<=": 3,
|
|
152
|
+
">=": 3,
|
|
153
|
+
"&": 4,
|
|
154
|
+
"+": 5,
|
|
155
|
+
"-": 5,
|
|
156
|
+
"*": 6,
|
|
157
|
+
"/": 6,
|
|
158
|
+
"^": 7,
|
|
159
|
+
};
|
|
160
|
+
const RIGHT_ASSOCIATIVE = new Set(["^"]);
|
|
161
|
+
|
|
162
|
+
const parse = (tokens) => {
|
|
163
|
+
let position = 0;
|
|
164
|
+
const peek = () => tokens[position];
|
|
165
|
+
const next = () => tokens[position++];
|
|
166
|
+
const expect = (type) => {
|
|
167
|
+
const token = next();
|
|
168
|
+
if (!token || token.type !== type) {
|
|
169
|
+
throw new Error(`Expected ${type}`);
|
|
170
|
+
}
|
|
171
|
+
return token;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const parseExpression = (minPrecedence) => {
|
|
175
|
+
let left = parseUnary();
|
|
176
|
+
while (true) {
|
|
177
|
+
const token = peek();
|
|
178
|
+
if (!token || token.type !== "op" || !(token.value in PRECEDENCE)) {
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
const precedence = PRECEDENCE[token.value];
|
|
182
|
+
if (precedence < minPrecedence) {
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
next();
|
|
186
|
+
const nextMinPrecedence = RIGHT_ASSOCIATIVE.has(token.value) ? precedence : precedence + 1;
|
|
187
|
+
const right = parseExpression(nextMinPrecedence);
|
|
188
|
+
left = { type: "Binary", op: token.value, left, right };
|
|
189
|
+
}
|
|
190
|
+
return left;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const parseUnary = () => {
|
|
194
|
+
const token = peek();
|
|
195
|
+
if (token && token.type === "op" && (token.value === "-" || token.value === "!" || token.value === "+")) {
|
|
196
|
+
next();
|
|
197
|
+
return { type: "Unary", op: token.value, operand: parseUnary() };
|
|
198
|
+
}
|
|
199
|
+
return parsePrimary();
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const parsePrimary = () => {
|
|
203
|
+
const token = peek();
|
|
204
|
+
if (!token) {
|
|
205
|
+
throw new Error("Unexpected end of formula");
|
|
206
|
+
}
|
|
207
|
+
if (token.type === "lparen") {
|
|
208
|
+
next();
|
|
209
|
+
const expr = parseExpression(0);
|
|
210
|
+
expect("rparen");
|
|
211
|
+
return { type: "Group", expr };
|
|
212
|
+
}
|
|
213
|
+
if (token.type === "number") {
|
|
214
|
+
next();
|
|
215
|
+
return { type: "Number", value: token.value };
|
|
216
|
+
}
|
|
217
|
+
if (token.type === "string") {
|
|
218
|
+
next();
|
|
219
|
+
return { type: "String", value: token.value };
|
|
220
|
+
}
|
|
221
|
+
if (token.type === "ident") {
|
|
222
|
+
next();
|
|
223
|
+
if (peek() && peek().type === "lparen") {
|
|
224
|
+
next();
|
|
225
|
+
const args = [];
|
|
226
|
+
if (peek() && peek().type !== "rparen") {
|
|
227
|
+
args.push(parseExpression(0));
|
|
228
|
+
while (peek() && peek().type === "comma") {
|
|
229
|
+
next();
|
|
230
|
+
args.push(parseExpression(0));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
expect("rparen");
|
|
234
|
+
return { type: "Call", name: token.value, args };
|
|
235
|
+
}
|
|
236
|
+
return { type: "Ident", value: token.value };
|
|
237
|
+
}
|
|
238
|
+
throw new Error(`Unexpected token ${JSON.stringify(token)}`);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const ast = parseExpression(0);
|
|
242
|
+
if (position !== tokens.length) {
|
|
243
|
+
throw new Error("Unexpected trailing tokens");
|
|
244
|
+
}
|
|
245
|
+
return ast;
|
|
246
|
+
};
|
|
247
|
+
//#endregion
|
|
248
|
+
|
|
249
|
+
//#region Transform (&& -> AND, || -> OR)
|
|
250
|
+
const ATOMIC_TYPES = new Set(["Call", "Ident", "Number", "String"]);
|
|
251
|
+
|
|
252
|
+
const flattenBoolean = (node, name, out) => {
|
|
253
|
+
if (node.type === "Call" && node.fromOperator && node.name === name) {
|
|
254
|
+
out.push(...node.args);
|
|
255
|
+
} else {
|
|
256
|
+
out.push(node);
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// Used only by the disabled structural-IF simplification below; re-enable together.
|
|
261
|
+
// const isBooleanLiteral = (node, value) => node.type === "Ident" && node.value === value;
|
|
262
|
+
|
|
263
|
+
// Bare identifiers that are language constants and get normalized to upper case.
|
|
264
|
+
const UPPERCASE_CONSTANTS = new Set(["TRUE", "FALSE", "NULL"]);
|
|
265
|
+
|
|
266
|
+
const transform = (node) => {
|
|
267
|
+
switch (node.type) {
|
|
268
|
+
case "Binary": {
|
|
269
|
+
const left = transform(node.left);
|
|
270
|
+
const right = transform(node.right);
|
|
271
|
+
if (node.op === "&&" || node.op === "||") {
|
|
272
|
+
const name = node.op === "&&" ? "AND" : "OR";
|
|
273
|
+
const args = [];
|
|
274
|
+
flattenBoolean(left, name, args);
|
|
275
|
+
flattenBoolean(right, name, args);
|
|
276
|
+
return { type: "Call", name, args, fromOperator: true };
|
|
277
|
+
}
|
|
278
|
+
// Normalize operators to the project's preferred spelling.
|
|
279
|
+
// The concat operator & is kept as-is: rewriting it to + would change
|
|
280
|
+
// null handling for formulas with formulaTreatBlanksAs = BlankAsBlank.
|
|
281
|
+
const op = node.op === "<>" ? "!=" : node.op;
|
|
282
|
+
return { type: "Binary", op, left, right };
|
|
283
|
+
}
|
|
284
|
+
case "Unary": {
|
|
285
|
+
const operand = transform(node.operand);
|
|
286
|
+
// Logical negation ! becomes the NOT() function.
|
|
287
|
+
if (node.op === "!") {
|
|
288
|
+
return { type: "Call", name: "NOT", args: [operand] };
|
|
289
|
+
}
|
|
290
|
+
return { type: "Unary", op: node.op, operand };
|
|
291
|
+
}
|
|
292
|
+
case "Group": {
|
|
293
|
+
let expr = transform(node.expr);
|
|
294
|
+
// Collapse redundant nested parentheses: ((X)) -> (X), (((X))) -> (X).
|
|
295
|
+
while (expr.type === "Group") {
|
|
296
|
+
expr = expr.expr;
|
|
297
|
+
}
|
|
298
|
+
// Parentheses around an atomic node are redundant once operators are calls.
|
|
299
|
+
return ATOMIC_TYPES.has(expr.type) ? expr : { type: "Group", expr };
|
|
300
|
+
}
|
|
301
|
+
case "Call": {
|
|
302
|
+
const args = node.args.map(transform);
|
|
303
|
+
const name = node.name.toUpperCase();
|
|
304
|
+
// FUTURE EXTENSION: structural IF simplification, disabled for now.
|
|
305
|
+
// IF(x, TRUE, FALSE) -> x and IF(x, FALSE, TRUE) -> NOT(x) are only
|
|
306
|
+
// safe when x can never evaluate to null: IF() treats a null condition
|
|
307
|
+
// as false, whereas a bare x / NOT(x) propagates the null. The formatter
|
|
308
|
+
// can't prove x is non-null, so it leaves these expressions untouched.
|
|
309
|
+
// To re-enable, restore isBooleanLiteral above and this block:
|
|
310
|
+
//
|
|
311
|
+
// if (name === "IF" && args.length === 3) {
|
|
312
|
+
// const [condition, thenBranch, elseBranch] = args;
|
|
313
|
+
// if (isBooleanLiteral(thenBranch, "TRUE") && isBooleanLiteral(elseBranch, "FALSE")) {
|
|
314
|
+
// return condition;
|
|
315
|
+
// }
|
|
316
|
+
// if (isBooleanLiteral(thenBranch, "FALSE") && isBooleanLiteral(elseBranch, "TRUE")) {
|
|
317
|
+
// return { type: "Call", name: "NOT", args: [condition] };
|
|
318
|
+
// }
|
|
319
|
+
// }
|
|
320
|
+
return { type: "Call", name, args };
|
|
321
|
+
}
|
|
322
|
+
case "Ident": {
|
|
323
|
+
const upper = node.value.toUpperCase();
|
|
324
|
+
return UPPERCASE_CONSTANTS.has(upper) ? { type: "Ident", value: upper } : node;
|
|
325
|
+
}
|
|
326
|
+
default:
|
|
327
|
+
return node;
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
//#endregion
|
|
331
|
+
|
|
332
|
+
//#region Printer
|
|
333
|
+
const renderInline = (node) => {
|
|
334
|
+
switch (node.type) {
|
|
335
|
+
case "Call":
|
|
336
|
+
return `${node.name}(${node.args.map(renderInline).join(", ")})`;
|
|
337
|
+
case "Binary":
|
|
338
|
+
return `${renderInline(node.left)} ${node.op} ${renderInline(node.right)}`;
|
|
339
|
+
case "Unary":
|
|
340
|
+
return `${node.op}${renderInline(node.operand)}`;
|
|
341
|
+
case "Group":
|
|
342
|
+
return `(${renderInline(node.expr)})`;
|
|
343
|
+
default:
|
|
344
|
+
return node.value;
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const containsBooleanCall = (node) => {
|
|
349
|
+
switch (node.type) {
|
|
350
|
+
case "Call":
|
|
351
|
+
return node.name === "AND" || node.name === "OR" || node.args.some(containsBooleanCall);
|
|
352
|
+
case "Binary":
|
|
353
|
+
return containsBooleanCall(node.left) || containsBooleanCall(node.right);
|
|
354
|
+
case "Unary":
|
|
355
|
+
return containsBooleanCall(node.operand);
|
|
356
|
+
case "Group":
|
|
357
|
+
return containsBooleanCall(node.expr);
|
|
358
|
+
default:
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const BREAKABLE_BINARY_OPS = new Set(["+", "&"]);
|
|
364
|
+
|
|
365
|
+
const flattenBinaryChain = (node) => {
|
|
366
|
+
const { op } = node;
|
|
367
|
+
const parts = [];
|
|
368
|
+
const walk = (current) => {
|
|
369
|
+
if (current.type === "Binary" && current.op === op) {
|
|
370
|
+
walk(current.left);
|
|
371
|
+
parts.push(current.right);
|
|
372
|
+
} else {
|
|
373
|
+
parts.push(current);
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
walk(node);
|
|
377
|
+
return parts;
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const createRenderer = (baseIndent, printWidth) => {
|
|
381
|
+
const indentAt = (level) => baseIndent + INDENT_UNIT.repeat(level);
|
|
382
|
+
const columnAt = (level) => baseIndent.length + level * INDENT_UNIT.length;
|
|
383
|
+
|
|
384
|
+
const render = (node, level, startColumnOverride) => {
|
|
385
|
+
const startColumn = startColumnOverride ?? columnAt(level);
|
|
386
|
+
|
|
387
|
+
if (node.type === "Call") {
|
|
388
|
+
const inline = renderInline(node);
|
|
389
|
+
const isBoolean = node.name === "AND" || node.name === "OR";
|
|
390
|
+
const mustBreak =
|
|
391
|
+
isBoolean || node.args.some(containsBooleanCall) || startColumn + inline.length > printWidth;
|
|
392
|
+
if (!mustBreak) {
|
|
393
|
+
return inline;
|
|
394
|
+
}
|
|
395
|
+
const args = node.args.map((arg) => indentAt(level + 1) + render(arg, level + 1)).join(",\n");
|
|
396
|
+
return `${node.name}(\n${args}\n${indentAt(level)})`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (node.type === "Binary") {
|
|
400
|
+
const inline = renderInline(node);
|
|
401
|
+
const fits = !containsBooleanCall(node) && startColumn + inline.length <= printWidth;
|
|
402
|
+
if (fits) {
|
|
403
|
+
return inline;
|
|
404
|
+
}
|
|
405
|
+
if (BREAKABLE_BINARY_OPS.has(node.op)) {
|
|
406
|
+
const parts = flattenBinaryChain(node);
|
|
407
|
+
return parts
|
|
408
|
+
.map((part, index) => {
|
|
409
|
+
const rendered = render(part, level, index === 0 ? startColumn : undefined);
|
|
410
|
+
const prefix = index === 0 ? "" : indentAt(level);
|
|
411
|
+
const suffix = index < parts.length - 1 ? ` ${node.op}` : "";
|
|
412
|
+
return `${prefix}${rendered}${suffix}`;
|
|
413
|
+
})
|
|
414
|
+
.join("\n");
|
|
415
|
+
}
|
|
416
|
+
// Non-breakable operator that still overflows: recurse so nested chains can expand.
|
|
417
|
+
const left = render(node.left, level, startColumn);
|
|
418
|
+
const right = render(node.right, level);
|
|
419
|
+
return `${left} ${node.op} ${right}`;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return renderInline(node);
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
return render;
|
|
426
|
+
};
|
|
427
|
+
//#endregion
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Formats a single formula expression string according to the project convention.
|
|
431
|
+
* @param {string} source Decoded formula text (no XML entities for < > &).
|
|
432
|
+
* @param {string} baseIndent Leading indentation of the owning tag.
|
|
433
|
+
* @param {number} printWidth Maximum line width before non-boolean calls break.
|
|
434
|
+
* @param {number} [topStartColumn] Column where the top-level expression begins (tag-aware).
|
|
435
|
+
* @returns {string} The formatted formula (single- or multi-line, without tags).
|
|
436
|
+
*/
|
|
437
|
+
export const formatFormulaExpression = (source, baseIndent, printWidth, topStartColumn) => {
|
|
438
|
+
const ast = transform(parse(tokenize(source)));
|
|
439
|
+
const render = createRenderer(baseIndent, printWidth);
|
|
440
|
+
return render(ast, 0, topStartColumn);
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Rewrites every formula tag in the given XML source with a canonically formatted body.
|
|
445
|
+
* Parse failures leave the original tag untouched so malformed input is never corrupted.
|
|
446
|
+
* @param {string} source The full XML metadata source.
|
|
447
|
+
* @param {number} [printWidth] Maximum line width (defaults to 120).
|
|
448
|
+
* @returns {string} The source with formula tags reformatted.
|
|
449
|
+
*/
|
|
450
|
+
export const normalizeFormulasInSource = (source, printWidth = DEFAULT_PRINT_WIDTH) => {
|
|
451
|
+
const tagPattern = new RegExp(`^([ \\t]*)<(${FORMULA_TAGS.join("|")})>([\\s\\S]*?)</\\2>`, "gm");
|
|
452
|
+
return source.replace(tagPattern, (fullMatch, baseIndent, tag, rawInner) => {
|
|
453
|
+
try {
|
|
454
|
+
const decoded = decodeEntities(rawInner.trim());
|
|
455
|
+
if (decoded === "") {
|
|
456
|
+
return fullMatch;
|
|
457
|
+
}
|
|
458
|
+
const openTagLength = tag.length + 2; // "<" + tag + ">"
|
|
459
|
+
const topStartColumn = baseIndent.length + openTagLength;
|
|
460
|
+
const body = formatFormulaExpression(decoded, baseIndent, printWidth, topStartColumn);
|
|
461
|
+
const encoded = encodeForXml(body);
|
|
462
|
+
// Multi-line formulas get a blank-of-newline before and after the body for readability;
|
|
463
|
+
// single-line formulas stay inline. xmlWhitespaceSensitivity:preserve keeps these newlines.
|
|
464
|
+
if (encoded.includes("\n")) {
|
|
465
|
+
return `${baseIndent}<${tag}>\n${baseIndent}${encoded}\n${baseIndent}</${tag}>`;
|
|
466
|
+
}
|
|
467
|
+
return `${baseIndent}<${tag}>${encoded}</${tag}>`;
|
|
468
|
+
} catch {
|
|
469
|
+
return fullMatch;
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
export const __testables = { decodeEntities, encodeForXml, tokenize, parse, transform };
|