Add "with" expression for templating #53

Allows optionally rendering content if an argument is given. The
expression is designed to be used with `optional` parameters.

Goal is to allow using `RunPowerShell` function on every function that
consists of PowerShell code. Before this commit, they were all required
to provide revertCode, or none of them could be able to have it. It
would not work because some scripts can be reverted, meanwhile some are
one-way scripts that cannot be reverted (such as cleaning scripts). In
this case a way to optionally render revertCode was required. `with`
expression give each callee script ability to turn off `revertCode` if
not needed, therefore enables using `RunPowerShell` everywhere.

This commit also improves error message for script code for better
debugging and refactors parser tests for more code reuse. It also adds
more tests to parameter substitution, and renames some tests of both
expressions for consistency.
This commit is contained in:
undergroundwires
2021-09-06 21:02:41 +01:00
parent 6c3c2e6709
commit 862914b06e
9 changed files with 321 additions and 67 deletions

View File

@@ -108,6 +108,7 @@
#### Expressions
- Expressions are defined inside mustaches (double brackets, `{{` and `}}`)
- Expression syntax is inspired by [Go Templates](https://pkg.go.dev/text/template)
##### Parameter substitution
@@ -148,6 +149,25 @@ A function can call other functions such as:
code: Hello {{ $argument }} !
```
##### with
- Skips the block if the variable is absent or empty.
- Binds its context (`.`) value of provided argument for the parameter only if its value is provided.
- A block is defined as `{{ with $parameterName }} Parameter value is {{ . }} here {{ end }}`
- The parameters used for `with` condition should be declared as optional, otherwise `with` block becomes redundant.
- Example:
```yaml
function: FunctionThatOutputsConditionally
parameters:
- name: 'argument'
optional: true
code: |-
{{ with $argument }}
$argument's value is: {{ . }}
{{ end }}
```
#### `Function` syntax
- `name`: *`string`* (**required**)
@@ -188,6 +208,7 @@ A function can call other functions such as:
- Otherwise it throws.
- 💡 Set it to `true` if a parameter is used conditionally;
- Or else set it to `false` for verbosity or do not define it as default value is `false` anyway.
- 💡 Can be used in conjunction with [`with` expression](#with).
### `ScriptingDefinition`

View File

@@ -1,9 +1,11 @@
import { IExpression } from '../Expression/IExpression';
import { IExpressionParser } from './IExpressionParser';
import { ParameterSubstitutionParser } from '../SyntaxParsers/ParameterSubstitutionParser';
import { WithParser } from '../SyntaxParsers/WithParser';
const Parsers = [
new ParameterSubstitutionParser(),
new WithParser(),
];
export class CompositeExpressionParser implements IExpressionParser {

View File

@@ -0,0 +1,24 @@
import { RegexParser, IPrimitiveExpression } from '../Parser/RegexParser';
import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameter';
export class WithParser extends RegexParser {
protected readonly regex = /{{\s*with\s+\$([^}| ]+)\s*}}\s*([^)]+?)\s*{{\s*end\s*}}/g;
protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression {
const parameterName = match[1];
const innerText = match[2];
return {
parameters: [ new FunctionParameter(parameterName, true) ],
evaluator: (args) => {
const argumentValue = args.hasArgument(parameterName) ?
args.getArgument(parameterName).argumentValue
: undefined;
if (!argumentValue) {
return '';
}
const substitutionRegex = /{{\s*.\s*}}/g;
const newText = innerText.replace(substitutionRegex, argumentValue);
return newText;
},
};
}
}

View File

@@ -389,7 +389,15 @@ actions:
code: net user defaultuser0 /delete 2>nul
-
name: Empty trash bin
code: Powershell -Command "$bin = (New-Object -ComObject Shell.Application).NameSpace(10);$bin.items() | ForEach { Write-Host "Deleting $($_.Name) from Recycle Bin"; Remove-Item $_.Path -Recurse -Force}"
call:
function: RunPowerShell
parameters:
code:
$bin = (New-Object -ComObject Shell.Application).NameSpace(10);
$bin.items() | ForEach {
Write-Host "Deleting $($_.Name) from Recycle Bin";
Remove-Item $_.Path -Recurse -Force
}
-
name: Enable Reset Base in Dism Component Store
recommend: standard
@@ -3803,14 +3811,16 @@ actions:
code: reg delete "HKCU\Environment" /v "OneDrive" /f
-
name: Uninstall Edge (chromium-based)
call:
function: RunPowerShell
parameters:
code:
PowerShell -ExecutionPolicy Unrestricted -Command "
$installer = (Get-ChildItem \"$env:ProgramFiles*\Microsoft\Edge\Application\*\Installer\setup.exe\");
if (!$installer) {
Write-Host Could not find the installer;
} else {
& $installer.FullName -uninstall -system-level -verbose-logging -force-uninstall
}; "
& $installer.FullName -Uninstall -System-Level -Verbose-Logging -Force-Uninstall
};
-
category: Disable built-in Windows features
children:
@@ -4522,5 +4532,9 @@ functions:
parameters:
- name: code
- name: revertCode
optional: true
code: PowerShell -ExecutionPolicy Unrestricted -Command "{{ $code }}"
revertCode: PowerShell -ExecutionPolicy Unrestricted -Command "{{ $revertCode }}"
revertCode: |-
{{ with $revertCode }}
PowerShell -ExecutionPolicy Unrestricted -Command "{{ . }}"
{{ end }}

View File

@@ -39,23 +39,37 @@ function validateCode(code: string, syntax: ILanguageSyntax): void {
}
function ensureNoEmptyLines(code: string): void {
if (code.split('\n').some((line) => line.trim().length === 0)) {
throw Error(`script has empty lines`);
const lines = code.split(/\r\n|\r|\n/);
if (lines.some((line) => line.trim().length === 0)) {
throw Error(`Script has empty lines:\n${lines.map((part, index) => `\n (${index}) ${part || '❌'}`).join('')}`);
}
}
function ensureCodeHasUniqueLines(code: string, syntax: ILanguageSyntax): void {
const lines = code.split('\n')
.filter((line) => !shouldIgnoreLine(line, syntax));
if (lines.length === 0) {
const allLines = code.split(/\r\n|\r|\n/);
const checkedLines = allLines.filter((line) => !shouldIgnoreLine(line, syntax));
if (checkedLines.length === 0) {
return;
}
const duplicateLines = lines.filter((e, i, a) => a.indexOf(e) !== i);
const duplicateLines = checkedLines.filter((e, i, a) => a.indexOf(e) !== i);
if (duplicateLines.length !== 0) {
throw Error(`Duplicates detected in script:\n${duplicateLines.map((line, index) => `(${index}) - ${line}`).join('\n')}`);
throw Error(`Duplicates detected in script:\n${printDuplicatedLines(allLines)}`);
}
}
function printDuplicatedLines(allLines: string[]) {
return allLines
.map((line, index) => {
const occurrenceIndices = allLines
.map((e, i) => e === line ? i : '')
.filter(String);
const isDuplicate = occurrenceIndices.length > 1;
const indicator = isDuplicate ? `❌ (${occurrenceIndices.join(',')})\t` : '✅ ';
return `${indicator}[${index}] ${line}`;
})
.join('\n');
}
function shouldIgnoreLine(codeLine: string, syntax: ILanguageSyntax): boolean {
codeLine = codeLine.toLowerCase();
const isCommentLine = () => syntax.commentDelimiters.some((delimiter) => codeLine.startsWith(delimiter));

View File

@@ -1,25 +1,25 @@
import 'mocha';
import { expect } from 'chai';
import { ParameterSubstitutionParser } from '@/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser';
import { ExpressionPosition } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition';
import { FunctionCallArgumentCollectionStub } from '@tests/unit/stubs/FunctionCallArgumentCollectionStub';
import { SyntaxParserTestsRunner } from './SyntaxParserTestsRunner';
describe('ParameterSubstitutionParser', () => {
describe('finds at expected positions', () => {
// arrange
const testCases = [
const sut = new ParameterSubstitutionParser();
const runner = new SyntaxParserTestsRunner(sut);
describe('finds as expected', () => {
runner.expectPosition(
{
name: 'matches single parameter',
name: 'single parameter',
code: '{{ $parameter }}!',
expected: [ new ExpressionPosition(0, 16) ],
},
{
name: 'matches different parameters',
name: 'different parameters',
code: 'He{{ $firstParameter }} {{ $secondParameter }}!!',
expected: [ new ExpressionPosition(2, 23), new ExpressionPosition(24, 46) ],
},
{
name: 'tolerates spaces around brackets',
name: 'tolerates lack of spaces around brackets',
code: 'He{{$firstParameter}}!!',
expected: [new ExpressionPosition(2, 21) ],
},
@@ -28,44 +28,33 @@ describe('ParameterSubstitutionParser', () => {
code: 'He{{ $ firstParameter }}!!',
expected: [ ],
},
];
for (const testCase of testCases) {
it(testCase.name, () => {
const sut = new ParameterSubstitutionParser();
// act
const expressions = sut.findExpressions(testCase.code);
// assert
const actual = expressions.map((e) => e.position);
expect(actual).to.deep.equal(testCase.expected);
});
}
);
});
describe('evaluates as expected', () => {
const testCases = [ {
runner.expectResults(
{
name: 'single parameter',
code: '{{ $parameter }}',
args: new FunctionCallArgumentCollectionStub()
args: (args) => args
.withArgument('parameter', 'Hello world'),
expected: [ 'Hello world' ],
},
{
name: 'different parameters',
code: '{{ $firstParameter }} {{ $secondParameter }}!',
args: new FunctionCallArgumentCollectionStub()
args: (args) => args
.withArgument('firstParameter', 'Hello')
.withArgument('secondParameter', 'World'),
expected: [ 'Hello', 'World' ],
}];
for (const testCase of testCases) {
it(testCase.name, () => {
const sut = new ParameterSubstitutionParser();
// act
const expressions = sut.findExpressions(testCase.code);
// assert
const actual = expressions.map((e) => e.evaluate(testCase.args));
expect(actual).to.deep.equal(testCase.expected);
});
}
},
{
name: 'same parameters used twice',
code: '{{ $letterH }}e{{ $letterL }}{{ $letterL }}o Wor{{ $letterL }}d!',
args: (args) => args
.withArgument('letterL', 'l')
.withArgument('letterH', 'H'),
expected: [ 'H', 'l', 'l', 'l' ],
},
);
});
});

View File

@@ -0,0 +1,49 @@
import 'mocha';
import { expect } from 'chai';
import { ExpressionPosition } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition';
import { IExpressionParser } from '@/application/Parser/Script/Compiler/Expressions/Parser/IExpressionParser';
import { FunctionCallArgumentCollectionStub } from '@tests/unit/stubs/FunctionCallArgumentCollectionStub';
export class SyntaxParserTestsRunner {
constructor(private readonly sut: IExpressionParser) {
}
public expectPosition(...testCases: IExpectPositionTestCase[]) {
for (const testCase of testCases) {
it(testCase.name, () => {
// act
const expressions = this.sut.findExpressions(testCase.code);
// assert
const actual = expressions.map((e) => e.position);
expect(actual).to.deep.equal(testCase.expected);
});
}
return this;
}
public expectResults(...testCases: IExpectResultTestCase[]) {
for (const testCase of testCases) {
it(testCase.name, () => {
// arrange
const args = testCase.args(new FunctionCallArgumentCollectionStub());
// act
const expressions = this.sut.findExpressions(testCase.code);
// assert
const actual = expressions.map((e) => e.evaluate(args));
expect(actual).to.deep.equal(testCase.expected);
});
}
return this;
}
}
interface IExpectResultTestCase {
name: string;
code: string;
args: (builder: FunctionCallArgumentCollectionStub) => FunctionCallArgumentCollectionStub;
expected: readonly string[];
}
interface IExpectPositionTestCase {
name: string;
code: string;
expected: readonly ExpressionPosition[];
}

View File

@@ -0,0 +1,141 @@
import 'mocha';
import { ExpressionPosition } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition';
import { WithParser } from '@/application/Parser/Script/Compiler/Expressions/SyntaxParsers/WithParser';
import { SyntaxParserTestsRunner } from './SyntaxParserTestsRunner';
describe('WithParser', () => {
const sut = new WithParser();
const runner = new SyntaxParserTestsRunner(sut);
describe('finds as expected', () => {
runner.expectPosition(
{
name: 'when no scope is not used',
code: 'hello {{ with $parameter }}no usage{{ end }} here',
expected: [ new ExpressionPosition(6, 44) ],
},
{
name: 'when scope is used',
code: 'used here ({{ with $parameter }}value: {{ . }}{{ end }})',
expected: [ new ExpressionPosition(11, 55) ],
},
{
name: 'when used twice',
code: 'first: {{ with $parameter }}value: {{ . }}{{ end }}, second: {{ with $parameter }}no usage{{ end }}',
expected: [ new ExpressionPosition(7, 51), new ExpressionPosition(61, 99) ],
},
{
name: 'tolerates lack of spaces around brackets',
code: 'no whitespaces {{with $parameter}}value: {{.}}{{end}}',
expected: [ new ExpressionPosition(15, 53) ],
},
{
name: 'does not tolerate space after dollar sign',
code: 'used here ({{ with $ parameter }}value: {{ . }}{{ end }})',
expected: [ ],
},
);
});
describe('ignores when syntax is unexpected', () => {
runner.expectPosition(
{
name: 'does not tolerate whitespace after with',
code: '{{with $ parameter}}value: {{ . }}{{ end }}',
expected: [ ],
},
{
name: 'does not tolerate whitespace before dollar',
code: '{{ with$parameter}}value: {{ . }}{{ end }}',
expected: [ ],
},
);
});
describe('ignores trailing and leading whitespaces and newlines inside scope', () => {
runner.expectResults(
{
name: 'does not render trailing whitespace after value',
code: '{{ with $parameter }}{{ . }}! {{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello world'),
expected: [ 'Hello world!' ],
},
{
name: 'does not render trailing newline after value',
code: '{{ with $parameter }}{{ . }}!\r\n{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello world'),
expected: [ 'Hello world!' ],
},
{
name: 'does not render leading newline before value',
code: '{{ with $parameter }}\r\n{{ . }}!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello world'),
expected: [ 'Hello world!' ],
},
{
name: 'does not render leading whitespace before value',
code: '{{ with $parameter }} {{ . }}!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello world'),
expected: [ 'Hello world!' ],
},
);
});
describe('does not render scope if argument is undefined', () => {
runner.expectResults(
{
name: 'does not render when value is undefined',
code: '{{ with $parameter }}dark{{ end }} ',
args: (args) => args
.withArgument('parameter', undefined),
expected: [ '' ],
},
{
name: 'does not render when value is empty',
code: '{{ with $parameter }}dark {{.}}{{ end }}',
args: (args) => args
.withArgument('parameter', ''),
expected: [ '' ],
},
{
name: 'does not render when argument is not provided',
code: '{{ with $parameter }}dark{{ end }}',
args: (args) => args,
expected: [ '' ],
},
);
});
describe('renders scope as expected', () => {
runner.expectResults(
{
name: 'renders scope even if value is not used',
code: '{{ with $parameter }}Hello world!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello'),
expected: [ 'Hello world!' ],
},
{
name: 'renders value when it has value',
code: '{{ with $parameter }}{{ . }} world!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello'),
expected: [ 'Hello world!' ],
},
{
name: 'renders value when whitespaces around brackets are missing',
code: '{{ with $parameter }}{{.}} world!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello'),
expected: [ 'Hello world!' ],
},
{
name: 'renders value multiple times when it\'s used multiple times',
code: '{{ with $letterL }}He{{ . }}{{ . }}o wor{{ . }}d!{{ end }}',
args: (args) => args
.withArgument('letterL', 'l'),
expected: [ 'Hello world!' ],
},
);
});
});

View File

@@ -52,13 +52,13 @@ describe('ScriptCode', () => {
const testCases = [
{
testName: 'cannot construct with duplicate lines',
code: 'duplicate\nduplicate\ntest\nduplicate',
expectedMessage: 'Duplicates detected in script:\n(0) - duplicate\n(1) - duplicate',
code: 'duplicate\nduplicate\nunique\nduplicate',
expectedMessage: 'Duplicates detected in script:\n❌ (0,1,3)\t[0] duplicate\n❌ (0,1,3)\t[1] duplicate\n✅ [2] unique\n❌ (0,1,3)\t[3] duplicate',
},
{
testName: 'cannot construct with empty lines',
code: 'line1\n\n\nline2',
expectedMessage: 'script has empty lines',
expectedMessage: 'Script has empty lines:\n\n (0) line1\n (1) ❌\n (2) ❌\n (3) line2',
},
];
// act