Skip to content

Commit 51b3cd5

Browse files
committed
Custom model editor: Add auto-complete also outside of conditions
1 parent 2a0f8ea commit 51b3cd5

File tree

5 files changed

+388
-39
lines changed

5 files changed

+388
-39
lines changed

web-bundle/src/main/js/custom-model-editor/src/complete.test.js

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { complete } from './complete';
1+
import {complete} from './complete';
22

33
const categories = {
4-
'a': {type: 'enum', values: ['a1a', 'a1b', 'a2a', 'a2b']},
5-
'b': {type: 'enum', values: ['b1', 'b2']}
4+
'a': {type: 'enum', values: ['a1a', 'a1b', 'a2a', 'a2b']},
5+
'b': {type: 'enum', values: ['b1', 'b2']},
6+
'c': {type: 'numeric'}
67
};
78
const areas = ['pqr', 'xyz'];
89

@@ -11,12 +12,12 @@ describe("complete", () => {
1112
test_complete('a == ', 5, ['a1a', 'a1b', 'a2a', 'a2b'], [5, 5]);
1213
test_complete('b == ', 5, ['b1', 'b2'], [5, 5]);
1314
test_complete('b == ', 5, ['b1', 'b2'], [5, 5]);
14-
test_complete('', 12, ['a', 'b', 'in_area_pqr', 'in_area_xyz', 'true', 'false'], [12, 12]);
15-
test_complete(' ', 12, ['a', 'b', 'in_area_pqr', 'in_area_xyz', 'true', 'false'], [12, 12]);
16-
test_complete('\t\n', 12, ['a', 'b', 'in_area_pqr', 'in_area_xyz', 'true', 'false'], [12, 12]);
17-
test_complete(' ', 0, ['a', 'b', 'in_area_pqr', 'in_area_xyz', 'true', 'false'], [0, 0]);
18-
test_complete(' ', 1, ['a', 'b', 'in_area_pqr', 'in_area_xyz', 'true', 'false'], [1, 1]);
19-
test_complete(' ', 2, ['a', 'b', 'in_area_pqr', 'in_area_xyz', 'true', 'false'], [2, 2]);
15+
test_complete('', 12, ['a', 'b', 'c', 'in_area_pqr', 'in_area_xyz', 'true', 'false'], [12, 12]);
16+
test_complete(' ', 12, ['a', 'b', 'c', 'in_area_pqr', 'in_area_xyz', 'true', 'false'], [12, 12]);
17+
test_complete('\t\n', 12, ['a', 'b', 'c', 'in_area_pqr', 'in_area_xyz', 'true', 'false'], [12, 12]);
18+
test_complete(' ', 0, ['a', 'b', 'c', 'in_area_pqr', 'in_area_xyz', 'true', 'false'], [0, 0]);
19+
test_complete(' ', 1, ['a', 'b', 'c', 'in_area_pqr', 'in_area_xyz', 'true', 'false'], [1, 1]);
20+
test_complete(' ', 2, ['a', 'b', 'c', 'in_area_pqr', 'in_area_xyz', 'true', 'false'], [2, 2]);
2021
test_complete('b == ', 4, ['b1', 'b2'], [4, 4]);
2122
test_complete('b ==', 4, ['b1', 'b2'], [4, 4]);
2223
test_complete('b ==', 9, ['b1', 'b2'], [9, 9]);
@@ -54,7 +55,7 @@ describe("complete", () => {
5455
});
5556

5657
test("complete at token within expression", () => {
57-
test_complete('a == a1a && b != b1', 0, ['a', 'b', 'in_area_pqr', 'in_area_xyz', 'true', 'false'], [0, 1]);
58+
test_complete('a == a1a && b != b1', 0, ['a', 'b', 'c', 'in_area_pqr', 'in_area_xyz', 'true', 'false'], [0, 1]);
5859
test_complete('a == a1a && b != b2', 2, ['==', '!='], [2, 4]);
5960
test_complete('a == a1b && b == b1', 5, ['a1a', 'a1b', 'a2a', 'a2b'], [5, 8]);
6061
test_complete('a == a2a && b == b2', 6, ['a1a', 'a1b', 'a2a', 'a2b'], [5, 8]);
@@ -72,8 +73,17 @@ describe("complete", () => {
7273
});
7374

7475
test("complete areas", () => {
75-
test_complete('in_ && a == a1a', 2, ['in_area_pqr', 'in_area_xyz'], [0, 3]);
76-
test_complete('in_area_x && a == a1a', 9, ['in_area_xyz'], [0, 9]);
76+
test_complete('in_ && a == a1a', 2, ['in_area_pqr', 'in_area_xyz'], [0, 3]);
77+
test_complete('in_area_x && a == a1a', 9, ['in_area_xyz'], [0, 9]);
78+
});
79+
80+
test("complete update", () => {
81+
test_complete(`c < 9`, 4, [`__hint__type a number`], [4, 5]);
82+
// 'continuing' a number does not work because the __hint__ suggestion is filtered when it is compared with
83+
// the existing digits. should we change this?
84+
test_complete(`c < 9 || b == b1`, 5, [], null);
85+
test_complete(`c < 9 `, 5, [], null);
86+
test_complete(`c < 9`, 5, [], null);
7787
});
7888
});
7989

web-bundle/src/main/js/custom-model-editor/src/custom_model_editor.js

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import YAML from "yaml";
88
import {validate} from "./validate.js";
99
import {complete} from "./complete.js";
1010
import {parse} from "./parse.js";
11+
import {completeYaml} from "./yaml_complete";
1112

1213

1314
class CustomModelEditor {
@@ -42,8 +43,8 @@ class CustomModelEditor {
4243
this.cm.on("cursorActivity", (e) => {
4344
if (!this._yaml)
4445
return;
45-
// in case the auto-complete popup is active already we update it (this allows filtering values while typing
46-
// with an open popup)
46+
// we update the auto complete window to allows filtering values while typing with an open popup)
47+
this.cm.closeHint();
4748
if (this.cm.state.completionActive) {
4849
this.showAutoCompleteSuggestions();
4950
}
@@ -155,22 +156,40 @@ class CustomModelEditor {
155156
showAutoCompleteSuggestions = () => {
156157
const validateResult = validate(this.cm.getValue());
157158
const cursor = this.cm.indexFromPos(this.cm.getCursor());
158-
validateResult.conditionRanges
159-
.map(cr => {
160-
const condition = this.cm.getValue().substring(cr[0], cr[1]);
161-
const offset = cr[0];
162-
// note that we allow the cursor to be at the end (inclusive!) of the range
163-
if (cursor >= offset && cursor <= cr[1]) {
164-
const completeRes = complete(condition, cursor - offset, this._categories, validateResult.areas);
165-
if (completeRes.suggestions.length > 0) {
166-
const range = [
167-
this.cm.posFromIndex(completeRes.range[0] + offset),
168-
this.cm.posFromIndex(completeRes.range[1] + offset),
169-
];
170-
this._suggest(range, completeRes.suggestions);
171-
}
159+
const completeRes = completeYaml(this.cm.getValue(), cursor);
160+
if (completeRes.suggestions.length > 0) {
161+
if (completeRes.suggestions.length === 1 && completeRes.suggestions[0] === `__hint__type a condition`) {
162+
// if the yaml completion suggests entering a condition we run the condition completion on the found
163+
// condition range instead
164+
const condition = this.cm.getValue().substring(completeRes.range[0], completeRes.range[1]);
165+
const offset = completeRes.range[0];
166+
const completeConditionRes = complete(condition, cursor - offset, this._categories, validateResult.areas);
167+
if (completeConditionRes.suggestions.length > 0) {
168+
const range = [
169+
this.cm.posFromIndex(completeConditionRes.range[0] + offset),
170+
this.cm.posFromIndex(completeConditionRes.range[1] + offset)
171+
];
172+
this._suggest(range, completeConditionRes.suggestions);
172173
}
173-
});
174+
} else {
175+
// limit the replacement range to the current line and do not include the new line character at the
176+
// end of the line. otherwise auto-complete messes up the following lines.
177+
const currLineStart = this.cm.indexFromPos({line: this.cm.getCursor().line, ch: 0});
178+
const currLineEnd = this.cm.indexFromPos({line: this.cm.getCursor().line + 1, ch: 0});
179+
const start = Math.max(currLineStart, completeRes.range[0]);
180+
let stop = Math.min(currLineEnd, completeRes.range[1]);
181+
if (stop > start && /\r\n|\r|\n/g.test(this.cm.getValue()[stop - 1]))
182+
stop--;
183+
const range = [
184+
this.cm.posFromIndex(start),
185+
this.cm.posFromIndex(stop)
186+
];
187+
// filter suggestions based on existing value
188+
const suggestions = completeRes.suggestions.filter(s =>
189+
startsWith(s, this.cm.getValue().substring(start, stop).trim()));
190+
this._suggest(range, suggestions);
191+
}
192+
}
174193
}
175194

176195
_suggest = (range, suggestions) => {
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import YAML from "yaml";
2+
3+
/**
4+
* Returns auto-complete suggestions for a yaml string and a given character position
5+
*/
6+
export function completeYaml(content, pos) {
7+
// if the cursor is not positioned on top of a token we insert a dummy character
8+
const yamlString = (pos >= content.length || (isWhitespace(content[pos]) && (pos === 0 || content[pos - 1] !== ':')))
9+
? content.substring(0, pos) + '…' + content.substring(pos)
10+
: content;
11+
const yamlPath = getYamlPath(yamlString, pos);
12+
13+
// remove the last element (the actual node) and only consider the ancestors
14+
const ancestorSignature = yamlPath.signature;
15+
ancestorSignature.pop();
16+
const signatureString = ancestorSignature.join('-');
17+
const ancestorPath = yamlPath.path;
18+
ancestorPath.pop();
19+
if (
20+
// the signature of root elements is root-x or root-map-pair-x
21+
/^root$/.test(signatureString) ||
22+
/^root-map-pair$/.test(signatureString)
23+
) {
24+
const suggestions = ['speed', 'priority', 'distance_influence', 'areas']
25+
.filter(s => ancestorPath.length === 1 || !keyAlreadyExistsInOtherPairs(ancestorPath[1].items, ancestorPath[2], s));
26+
return {
27+
suggestions,
28+
range: yamlPath.tokenRange
29+
}
30+
} else if (
31+
/^root-map-pair\[distance_influence]$/.test(signatureString)
32+
) {
33+
return {
34+
suggestions: [`__hint__type a number`],
35+
range: yamlPath.tokenRange
36+
}
37+
} else if (
38+
// the signature of statements is root-map-pair[speed/priority]-list[i]-x or root-map-pair[speed/priority]-list[i]-map-pair-x
39+
/^root-map-pair\[(speed|priority)]-list\[[0-9]+]$/.test(signatureString) ||
40+
/^root-map-pair\[(speed|priority)]-list\[[0-9]+]-map-pair$/.test(signatureString)
41+
) {
42+
const clauses = [`if`, `else_if`, `else`];
43+
const operators = [`limit_to`, `multiply_by`];
44+
const hasClause = ancestorPath.length === 6 && keysAlreadyExistInOtherPairs(ancestorPath[4].items, ancestorPath[5], clauses);
45+
const hasOperator = ancestorPath.length === 6 && keysAlreadyExistInOtherPairs(ancestorPath[4].items, ancestorPath[5], operators);
46+
let suggestions = [];
47+
if (!hasClause)
48+
suggestions.push(...clauses);
49+
if (!hasOperator)
50+
suggestions.push(...operators);
51+
return {
52+
suggestions,
53+
range: yamlPath.tokenRange
54+
}
55+
} else if (
56+
/^root-map-pair\[(speed|priority)]-list\[[0-9]+]-map-pair\[(if|else_if|else)]$/.test(signatureString)
57+
) {
58+
return {
59+
suggestions: [`__hint__type a condition`],
60+
range: yamlPath.tokenRange
61+
}
62+
} else if (
63+
/^root-map-pair\[(speed|priority)]-list\[[0-9]+]-map-pair\[(limit_to|multiply_by)]$/.test(signatureString)
64+
) {
65+
return {
66+
suggestions: [`__hint__type a number`],
67+
range: yamlPath.tokenRange
68+
}
69+
} else if (
70+
/^root-map-pair\[areas]$/.test(signatureString) ||
71+
/^root-map-pair\[areas]-map-pair$/.test(signatureString)
72+
) {
73+
return {
74+
suggestions: [`__hint__type an area name`],
75+
range: yamlPath.tokenRange
76+
}
77+
} else if (
78+
/^root-map-pair\[areas]-map-pair\[[a-zA-Z0-9_]*]$/.test(signatureString) ||
79+
/^root-map-pair\[areas]-map-pair\[[a-zA-Z0-9_]*]-map-pair$/.test(signatureString)
80+
) {
81+
const suggestions = [`geometry`, `type`]
82+
.filter(s => !(ancestorPath.length === 7 && keyAlreadyExistsInOtherPairs(ancestorPath[5].items, ancestorPath[6], s)));
83+
return {
84+
suggestions,
85+
range: yamlPath.tokenRange
86+
}
87+
} else if (
88+
/^root-map-pair\[areas]-map-pair\[[a-zA-Z0-9_]*]-map-pair\[type]$/.test(signatureString)
89+
) {
90+
return {
91+
suggestions: [`Feature`],
92+
range: yamlPath.tokenRange
93+
}
94+
} else if (
95+
/^root-map-pair\[areas]-map-pair\[[a-zA-Z0-9_]*]-map-pair\[geometry]$/.test(signatureString) ||
96+
/^root-map-pair\[areas]-map-pair\[[a-zA-Z0-9_]*]-map-pair\[geometry]-map-pair$/.test(signatureString)
97+
) {
98+
const suggestions = [`type`, `coordinates`]
99+
.filter(s => !(ancestorPath.length === 9 && keyAlreadyExistsInOtherPairs(ancestorPath[7].items, ancestorPath[8], s)));
100+
return {
101+
suggestions,
102+
range: yamlPath.tokenRange
103+
}
104+
} else if (
105+
/^root-map-pair\[areas]-map-pair\[[a-zA-Z0-9_]*]-map-pair\[geometry]-map-pair\[type]$/.test(signatureString)
106+
) {
107+
return {
108+
suggestions: [`Polygon`],
109+
range: yamlPath.tokenRange
110+
}
111+
} else {
112+
return {
113+
suggestions: [],
114+
range: []
115+
}
116+
}
117+
}
118+
119+
/**
120+
* Returns the YAML path and a special string representation (the 'signature') for the given yaml string and position.
121+
* The returned object contains the path as array, its 'signature' as string array and the token range of the token at
122+
* pos.
123+
*/
124+
export function getYamlPath(content, pos) {
125+
const doc = YAML.parseDocument(content);
126+
const result = {
127+
path: [],
128+
signature: [],
129+
tokenRange: []
130+
};
131+
YAML.visit(doc, {
132+
Scalar(key, node, path) {
133+
// we use the end of the range inclusively!
134+
if (pos >= node.range[0] && pos <= node.range[1]) {
135+
result.path = path.concat([node]);
136+
result.signature = path.map((n, i) => nodeToPathElement(n, i + 1 < path.length ? path[i + 1] : node));
137+
result.signature.push(nodeToPathElement(node, null));
138+
result.tokenRange = node.range;
139+
return YAML.visit.BREAK;
140+
}
141+
}
142+
});
143+
return result;
144+
}
145+
146+
function nodeToPathElement(node, child) {
147+
if (node.type === 'DOCUMENT') {
148+
return 'root';
149+
} else if (node.type === 'MAP' || node.type === 'FLOW_MAP') {
150+
return 'map';
151+
} else if (node.type === 'PAIR') {
152+
if (child !== null && node.value === child) {
153+
return `pair[${node.key ? node.key.value : node.key}]`
154+
} else {
155+
return `pair`;
156+
}
157+
} else if (node.type === 'SEQ' || node.type === 'FLOW_SEQ') {
158+
if (child !== null) {
159+
return `list[${node.items.indexOf(child)}]`;
160+
} else {
161+
return `list`;
162+
}
163+
} else if (node.type === 'PLAIN') {
164+
return node.value;
165+
} else {
166+
return `unknown[${node.type}]`;
167+
}
168+
}
169+
170+
function keyAlreadyExistsInOtherPairs(allPairs, thisPair, key) {
171+
return allPairs.some(p => p !== thisPair && p.key && p.key.value === key);
172+
}
173+
174+
function keysAlreadyExistInOtherPairs(allPairs, thisPair, keys) {
175+
return allPairs.some(p => p !== thisPair && p.key && keys.indexOf(p.key.value) >= 0);
176+
}
177+
178+
function isWhitespace(str) {
179+
return str.trim() === '';
180+
}

0 commit comments

Comments
 (0)