Skip to content

Commit 71c409c

Browse files
committed
Support broader syntax for when clause evaluation
1 parent 9cbdbbd commit 71c409c

File tree

2 files changed

+259
-15
lines changed

2 files changed

+259
-15
lines changed

src/utils/configUtils.ts

Lines changed: 158 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,32 +47,31 @@ export async function loadRunConfig(testItems: TestItem[], workspaceFolder: Work
4747
}
4848

4949
function filterCandidateConfigItems(configItems: IExecutionConfig[], testItems: TestItem[]): IExecutionConfig[] {
50-
const whenClausePattern: RegExp = /(?<contextKey>.+)\s*~=\s*\/(?<pattern>.+)\//;
5150
return configItems.filter((config: IExecutionConfig) => {
5251
const whenClause: string | undefined = config.when?.trim();
5352

54-
if (!whenClause)
55-
return true;
53+
if (whenClause) {
54+
const context: WhenClauseEvaluationContext = new WhenClauseEvaluationContext(whenClause);
5655

57-
const groups: Record<string, string> | undefined = whenClause.match(whenClausePattern)?.groups;
56+
try {
57+
return checkTestItems(testItems, context);
58+
} catch (e) {
59+
if (e instanceof Error)
60+
window.showWarningMessage(e.message);
61+
}
62+
}
5863

59-
if (!groups)
60-
return true;
64+
return true;
6165

62-
switch (groups.contextKey) {
63-
case 'testItem':
64-
const testItemPattern: RegExp = new RegExp(groups.pattern);
65-
return checkTestItems(testItems, testItemPattern);
66-
default:
67-
return true;
68-
}
6966
});
7067
}
7168

72-
function checkTestItems(testItems: TestItem[], pattern: RegExp): boolean {
69+
function checkTestItems(testItems: TestItem[], context: WhenClauseEvaluationContext): boolean {
7370
return testItems.every((testItem: TestItem) => {
7471
const fullName: string | undefined = dataCache.get(testItem)?.fullName;
75-
return fullName && pattern.test(fullName);
72+
73+
context.addContextKey('testItem', fullName);
74+
return context.evaluate();
7675
});
7776
}
7877

@@ -136,3 +135,147 @@ async function askPreferenceForConfig(configs: IExecutionConfig[], selectedConfi
136135
export function randomSequence(): string {
137136
return crypto.randomBytes(3).toString('hex');
138137
}
138+
139+
type ApplyOperator = (value1: unknown, value2?: unknown) => boolean
140+
141+
interface Token {
142+
stringValue: string
143+
getValue: () => unknown
144+
}
145+
146+
interface ResultToken extends Token {
147+
getValue: () => boolean
148+
}
149+
150+
export class WhenClauseEvaluationContext {
151+
152+
private static readonly OPERATORS: Record<string, ApplyOperator> = {
153+
// logical
154+
'!': (value: unknown) => !value,
155+
'&&': (value1: unknown, value2: unknown) => !!(value1 && value2),
156+
'||': (value1: unknown, value2: unknown) => !!(value1 || value2),
157+
158+
// equality
159+
'==': (value1: unknown, value2: unknown) => value1 === value2,
160+
'===': (value1: unknown, value2: unknown) => value1 === value2,
161+
'!=': (value1: unknown, value2: unknown) => value1 !== value2,
162+
'!==': (value1: unknown, value2: unknown) => value1 !== value2,
163+
164+
// comparison
165+
'>': (value1: number, value2: number) => value1 > value2,
166+
'>=': (value1: number, value2: number) => value1 >= value2,
167+
'<': (value1: number, value2: number) => value1 < value2,
168+
'<=': (value1: number, value2: number) => value1 <= value2,
169+
170+
// match
171+
'=~': (value: string, pattern: RegExp) => pattern.test(value),
172+
}
173+
174+
private readonly context: Record<string, unknown> = {};
175+
176+
public constructor(readonly clause: string) {}
177+
178+
private tokenize(): Token[] {
179+
const tokens: string[] = this.clause.split(/\s+/)
180+
.flatMap((token: string) => token.split(/([\(\)]|!(?!=))/))
181+
.filter(Boolean);
182+
183+
return tokens.map((token: string) => ({
184+
stringValue: token,
185+
getValue: () => this.parse(token),
186+
}));
187+
}
188+
189+
private parse(token: string) {
190+
const quotedStringMatch: RegExpMatchArray | null = token.match(/['"](.*)['"]/);
191+
if (!_.isNull(quotedStringMatch))
192+
return quotedStringMatch[1];
193+
194+
const regexMatch: RegExpMatchArray | null = token.match(/\/(.*)\//);
195+
if (!_.isNull(regexMatch))
196+
return new RegExp(regexMatch[1]);
197+
198+
const number: number = Number(token);
199+
if (!isNaN(number))
200+
return number;
201+
202+
const booleanMatch: RegExpMatchArray | null = token.match(/^true|false$/);
203+
if (!_.isNull(booleanMatch))
204+
return booleanMatch[0] === 'true';
205+
206+
if (token === typeof undefined)
207+
return;
208+
209+
if (!(token in this.context)) {
210+
window.showWarningMessage(`Context key not found in evaluation context: ${token}`);
211+
return;
212+
}
213+
214+
return this.context[token];
215+
}
216+
217+
private evaluateTokens(tokens: Token[], start?: number, end?: number) {
218+
start ||= 0;
219+
end ||= tokens.length;
220+
221+
const currentTokens: Token[] = tokens.slice(start, end);
222+
223+
while (currentTokens.length > 1) {
224+
const stringTokens: string[] = currentTokens.map((token: Token) => token.stringValue);
225+
226+
const parenthesesStart: number = stringTokens.lastIndexOf('(');
227+
const parenthesesEnd: number = (() => {
228+
const relativeEnd: number = stringTokens.slice(parenthesesStart).indexOf(')');
229+
return relativeEnd >= 0 ? parenthesesStart + relativeEnd : -1;
230+
})();
231+
232+
if (parenthesesEnd < parenthesesStart)
233+
throw new SyntaxError('Mismatched parentheses in expression');
234+
235+
if (parenthesesEnd > parenthesesStart) {
236+
const resultToken: Token = this.evaluateTokens(currentTokens, parenthesesStart + 1, parenthesesEnd);
237+
currentTokens.splice(parenthesesStart, parenthesesEnd - parenthesesStart + 1, resultToken);
238+
239+
continue;
240+
}
241+
242+
for (const [operator, applyOperator] of Object.entries(WhenClauseEvaluationContext.OPERATORS)) {
243+
const operatorIndex: number = currentTokens.findIndex((token: Token) => token.stringValue === operator);
244+
245+
if (operatorIndex === -1)
246+
continue;
247+
248+
const leftOperand: Token = currentTokens[operatorIndex - 1];
249+
const rightOperand: Token = currentTokens[operatorIndex + 1];
250+
251+
const value: boolean = applyOperator.length === 1
252+
? applyOperator(rightOperand.getValue())
253+
: applyOperator(leftOperand.getValue(), rightOperand.getValue());
254+
255+
const operationStart: number = operatorIndex - (applyOperator.length - 1);
256+
const operationLength: number = applyOperator.length + 1;
257+
258+
currentTokens.splice(operationStart, operationLength, {
259+
stringValue: value.toString(),
260+
getValue: () => value,
261+
});
262+
263+
break;
264+
}
265+
}
266+
267+
return currentTokens[0] as ResultToken;
268+
}
269+
270+
addContextKey(key: string, value: unknown): void {
271+
this.context[key] = value;
272+
}
273+
274+
evaluate(): boolean {
275+
const tokens: Token[] = this.tokenize();
276+
const result: ResultToken = this.evaluateTokens(tokens);
277+
278+
return result.getValue();
279+
}
280+
281+
}

test/suite/configUtils.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
4+
'use strict';
5+
6+
import * as assert from 'assert';
7+
import { WhenClauseEvaluationContext } from '../../src/utils/configUtils';
8+
9+
suite('ConfigUtils Tests', () => {
10+
11+
[
12+
{ clause: '!false', expectedResult: true },
13+
{ clause: '!true', expectedResult: false },
14+
{ clause: 'false && false', expectedResult: false },
15+
{ clause: 'false && true', expectedResult: false },
16+
{ clause: 'true && false', expectedResult: false },
17+
{ clause: 'true && true', expectedResult: true },
18+
{ clause: 'false || false', expectedResult: false },
19+
{ clause: 'false || true', expectedResult: true },
20+
{ clause: 'true || false', expectedResult: true },
21+
{ clause: 'true || true', expectedResult: true },
22+
{ clause: 'false == false', expectedResult: true },
23+
{ clause: 'false == true', expectedResult: false },
24+
{ clause: 'true == false', expectedResult: false },
25+
{ clause: 'true == true', expectedResult: true },
26+
{ clause: 'false === false', expectedResult: true },
27+
{ clause: 'false === true', expectedResult: false },
28+
{ clause: 'true === false', expectedResult: false },
29+
{ clause: 'true === true', expectedResult: true },
30+
{ clause: 'false != false', expectedResult: false },
31+
{ clause: 'false != true', expectedResult: true },
32+
{ clause: 'true != false', expectedResult: true },
33+
{ clause: 'true != true', expectedResult: false },
34+
{ clause: 'false !== false', expectedResult: false },
35+
{ clause: 'false !== true', expectedResult: true },
36+
{ clause: 'true !== false', expectedResult: true },
37+
{ clause: 'true !== true', expectedResult: false },
38+
{ clause: '0 > 0', expectedResult: false },
39+
{ clause: '0 > 1', expectedResult: false },
40+
{ clause: '1 > 0', expectedResult: true },
41+
{ clause: '1 > 1', expectedResult: false },
42+
{ clause: '0 >= 0', expectedResult: true },
43+
{ clause: '0 >= 1', expectedResult: false },
44+
{ clause: '1 >= 0', expectedResult: true },
45+
{ clause: '1 >= 1', expectedResult: true },
46+
{ clause: '0 < 0', expectedResult: false },
47+
{ clause: '0 < 1', expectedResult: true },
48+
{ clause: '1 < 0', expectedResult: false },
49+
{ clause: '1 < 1', expectedResult: false },
50+
{ clause: '0 <= 0', expectedResult: true },
51+
{ clause: '0 <= 1', expectedResult: true },
52+
{ clause: '1 <= 0', expectedResult: false },
53+
{ clause: '1 <= 1', expectedResult: true },
54+
{ clause: '"foo" =~ /foo/', expectedResult: true },
55+
{ clause: '\'foo\' =~ /foo/', expectedResult: true },
56+
{ clause: '"foo" =~ /bar/', expectedResult: false },
57+
{ clause: '"foo" =~ /^foo$/', expectedResult: true },
58+
{ clause: '"foo" =~ /\\w+/', expectedResult: true },
59+
{ clause: '"foo" =~ //', expectedResult: true },
60+
{ clause: 'false && true && true || !false', expectedResult: true },
61+
{ clause: 'false && true && (true || !false)', expectedResult: false },
62+
{ clause: '(false && true) && (true || !false)', expectedResult: false },
63+
{ clause: 'false && (true && (true || !false))', expectedResult: false },
64+
].forEach(({ clause, expectedResult }) => test(`Evaluate when clause - basic: ${clause}`, () => {
65+
const context = new WhenClauseEvaluationContext(clause);
66+
const result = context.evaluate();
67+
68+
assert.equal(result, expectedResult);
69+
}));
70+
71+
[
72+
{ clause: 'test == undefined', expectedResult: false },
73+
{ clause: 'test == "foo"', expectedResult: true },
74+
{ clause: 'test == "bar"', expectedResult: false },
75+
{ clause: 'test != "bar"', expectedResult: true },
76+
{ clause: 'test =~ /foo/', expectedResult: true },
77+
{ clause: 'test =~ /bar/', expectedResult: false },
78+
].forEach(({ clause, expectedResult }) => test(`Evaluate when clause - context key: ${clause}`, () => {
79+
const context = new WhenClauseEvaluationContext(clause);
80+
context.addContextKey('test', 'foo');
81+
82+
const result = context.evaluate();
83+
84+
assert.equal(result, expectedResult);
85+
}));
86+
87+
[
88+
{ clause: 'test == undefined', expectedResult: true },
89+
{ clause: 'test == "foo"', expectedResult: false },
90+
{ clause: 'test == "bar"', expectedResult: false },
91+
{ clause: 'test != "bar"', expectedResult: true },
92+
{ clause: 'test =~ /foo/', expectedResult: false },
93+
{ clause: 'test =~ /bar/', expectedResult: false },
94+
].forEach(({ clause, expectedResult }) => test(`Evaluate when clause - missing context key: ${clause}`, () => {
95+
const context = new WhenClauseEvaluationContext(clause);
96+
const result = context.evaluate();
97+
98+
assert.equal(result, expectedResult);
99+
}));
100+
101+
});

0 commit comments

Comments
 (0)