From 862914b06ea9ef74c4b58a9a4164a10a38273638 Mon Sep 17 00:00:00 2001 From: undergroundwires Date: Mon, 6 Sep 2021 21:02:41 +0100 Subject: [PATCH] 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. --- docs/collection-files.md | 21 +++ .../Parser/CompositeExpressionParser.ts | 2 + .../Expressions/SyntaxParsers/WithParser.ts | 24 +++ src/application/collections/windows.yaml | 34 +++-- src/domain/ScriptCode.ts | 28 +++- .../ParameterSubstitutionParser.spec.ts | 83 +++++------ .../SyntaxParsers/SyntaxParserTestsRunner.ts | 49 ++++++ .../SyntaxParsers/WithParser.spec.ts | 141 ++++++++++++++++++ tests/unit/domain/ScriptCode.spec.ts | 6 +- 9 files changed, 321 insertions(+), 67 deletions(-) create mode 100644 src/application/Parser/Script/Compiler/Expressions/SyntaxParsers/WithParser.ts create mode 100644 tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/SyntaxParserTestsRunner.ts create mode 100644 tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/WithParser.spec.ts diff --git a/docs/collection-files.md b/docs/collection-files.md index 9654460c..e8855dec 100644 --- a/docs/collection-files.md +++ b/docs/collection-files.md @@ -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` diff --git a/src/application/Parser/Script/Compiler/Expressions/Parser/CompositeExpressionParser.ts b/src/application/Parser/Script/Compiler/Expressions/Parser/CompositeExpressionParser.ts index 6e41d646..fd0b447c 100644 --- a/src/application/Parser/Script/Compiler/Expressions/Parser/CompositeExpressionParser.ts +++ b/src/application/Parser/Script/Compiler/Expressions/Parser/CompositeExpressionParser.ts @@ -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 { diff --git a/src/application/Parser/Script/Compiler/Expressions/SyntaxParsers/WithParser.ts b/src/application/Parser/Script/Compiler/Expressions/SyntaxParsers/WithParser.ts new file mode 100644 index 00000000..4afb3b1a --- /dev/null +++ b/src/application/Parser/Script/Compiler/Expressions/SyntaxParsers/WithParser.ts @@ -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; + }, + }; + } +} diff --git a/src/application/collections/windows.yaml b/src/application/collections/windows.yaml index a9d97f3f..6fa8d339 100644 --- a/src/application/collections/windows.yaml +++ b/src/application/collections/windows.yaml @@ -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) - 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 - }; " + call: + function: RunPowerShell + parameters: + code: + $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 + }; - 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 }} diff --git a/src/domain/ScriptCode.ts b/src/domain/ScriptCode.ts index d9698831..96e8ba08 100644 --- a/src/domain/ScriptCode.ts +++ b/src/domain/ScriptCode.ts @@ -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)); diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser.spec.ts index faabcd92..e383db36 100644 --- a/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser.spec.ts @@ -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)], + 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)], + 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 = [ { - name: 'single parameter', - code: '{{ $parameter }}', - args: new FunctionCallArgumentCollectionStub() - .withArgument('parameter', 'Hello world'), - expected: [ 'Hello world' ], - }, - { - name: 'different parameters', - code: '{{ $firstParameter }} {{ $secondParameter }}!', - args: new FunctionCallArgumentCollectionStub() - .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); - }); - } + runner.expectResults( + { + name: 'single parameter', + code: '{{ $parameter }}', + args: (args) => args + .withArgument('parameter', 'Hello world'), + expected: [ 'Hello world' ], + }, + { + name: 'different parameters', + code: '{{ $firstParameter }} {{ $secondParameter }}!', + args: (args) => args + .withArgument('firstParameter', 'Hello') + .withArgument('secondParameter', 'World'), + expected: [ 'Hello', 'World' ], + }, + { + 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' ], + }, + ); }); }); - diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/SyntaxParserTestsRunner.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/SyntaxParserTestsRunner.ts new file mode 100644 index 00000000..19ac5848 --- /dev/null +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/SyntaxParserTestsRunner.ts @@ -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[]; +} diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/WithParser.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/WithParser.spec.ts new file mode 100644 index 00000000..b88944a5 --- /dev/null +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/WithParser.spec.ts @@ -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!' ], + }, + ); + }); +}); diff --git a/tests/unit/domain/ScriptCode.spec.ts b/tests/unit/domain/ScriptCode.spec.ts index e3f7ca47..0503b968 100644 --- a/tests/unit/domain/ScriptCode.spec.ts +++ b/tests/unit/domain/ScriptCode.spec.ts @@ -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