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