From e45cf4f47acf97b6607d21c4c53d40c2da8dddc7 Mon Sep 17 00:00:00 2001 From: undergroundwires Date: Mon, 5 Aug 2024 17:39:02 +0200 Subject: [PATCH] Fix PowerShell code block inlining in compiler This commit enhances the compiler's ability to inline PowerShell code blocks. Previously, the compiler attempted to inline all lines ending with brackets (`}` and `{`) using semicolons, which leads to syntax errors. This improvement allows for more flexible PowerShell code writing with reliable outcomes. Key Changes: - Update InlinePowerShell pipe to handle code blocks specifically - Extend unit tests for the InlinePowerShell pipe Other supporting changes: - Refactor InlinePowerShell tests for improved scalability - Enhance pipe unit test running with regex support - Expand test coverage for various PowerShell syntax used in privacy.sexy - Update related interfaces to align with new code conventions, dropping `I` prefix - Optimize line merging to skip lines already ending with semicolons --- .../Expressions/Pipes/{IPipe.ts => Pipe.ts} | 2 +- .../PipeDefinitions/EscapeDoubleQuotes.ts | 6 +- .../Pipes/PipeDefinitions/InlinePowerShell.ts | 42 +- .../Compiler/Expressions/Pipes/PipeFactory.ts | 12 +- .../EscapeDoubleQuotes.spec.ts | 14 +- .../PipeDefinitions/InlinePowerShell.spec.ts | 487 ++---------------- .../CommonInlinePowerShellTestUtilities.ts | 26 + .../CreateAbsentCodeTests.ts | 28 + .../CreateCommentedCodeTests.ts | 122 +++++ .../CreateDoUntilTests.ts | 407 +++++++++++++++ .../CreateDoWhileTests.ts | 281 ++++++++++ .../CreateForLoopTests.ts | 214 ++++++++ .../CreateForeachTests.ts | 283 ++++++++++ .../CreateFunctionTests.ts | 229 ++++++++ .../CreateHereStringTests.ts | 240 +++++++++ .../CreateIfStatementTests.ts | 426 +++++++++++++++ .../CreateLineContinuationBacktickTests.ts | 61 +++ .../CreateNewlineTests.ts | 103 ++++ .../CreateScriptBlockTests.ts | 255 +++++++++ .../CreateSwitchTests.ts | 330 ++++++++++++ .../CreateTryCatchFinallyTests.ts | 451 ++++++++++++++++ .../InlinePowerShellTests/CreateWhileTests.ts | 222 ++++++++ .../Pipes/PipeDefinitions/PipeTestRunner.ts | 70 ++- tests/unit/shared/Stubs/PipeFactoryStub.ts | 10 +- tests/unit/shared/Stubs/PipeStub.ts | 4 +- 25 files changed, 3833 insertions(+), 492 deletions(-) rename src/application/Parser/Executable/Script/Compiler/Expressions/Pipes/{IPipe.ts => Pipe.ts} (70%) create mode 100644 tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CommonInlinePowerShellTestUtilities.ts create mode 100644 tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateAbsentCodeTests.ts create mode 100644 tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateCommentedCodeTests.ts create mode 100644 tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateDoUntilTests.ts create mode 100644 tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateDoWhileTests.ts create mode 100644 tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateForLoopTests.ts create mode 100644 tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateForeachTests.ts create mode 100644 tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateFunctionTests.ts create mode 100644 tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateHereStringTests.ts create mode 100644 tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateIfStatementTests.ts create mode 100644 tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateLineContinuationBacktickTests.ts create mode 100644 tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateNewlineTests.ts create mode 100644 tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateScriptBlockTests.ts create mode 100644 tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateSwitchTests.ts create mode 100644 tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateTryCatchFinallyTests.ts create mode 100644 tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateWhileTests.ts diff --git a/src/application/Parser/Executable/Script/Compiler/Expressions/Pipes/IPipe.ts b/src/application/Parser/Executable/Script/Compiler/Expressions/Pipes/Pipe.ts similarity index 70% rename from src/application/Parser/Executable/Script/Compiler/Expressions/Pipes/IPipe.ts rename to src/application/Parser/Executable/Script/Compiler/Expressions/Pipes/Pipe.ts index 8b486c7b..deca2f59 100644 --- a/src/application/Parser/Executable/Script/Compiler/Expressions/Pipes/IPipe.ts +++ b/src/application/Parser/Executable/Script/Compiler/Expressions/Pipes/Pipe.ts @@ -1,4 +1,4 @@ -export interface IPipe { +export interface Pipe { readonly name: string; apply(input: string): string; } diff --git a/src/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/EscapeDoubleQuotes.ts b/src/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/EscapeDoubleQuotes.ts index 4f4aebee..a74ffdf6 100644 --- a/src/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/EscapeDoubleQuotes.ts +++ b/src/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/EscapeDoubleQuotes.ts @@ -1,11 +1,11 @@ -import type { IPipe } from '../IPipe'; +import type { Pipe } from '../Pipe'; -export class EscapeDoubleQuotes implements IPipe { +export class EscapeDoubleQuotes implements Pipe { public readonly name: string = 'escapeDoubleQuotes'; public apply(raw: string): string { if (!raw) { - return raw; + return ''; } return raw.replaceAll('"', '"^""'); /* eslint-disable vue/max-len */ diff --git a/src/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShell.ts b/src/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShell.ts index f586d24d..aae01683 100644 --- a/src/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShell.ts +++ b/src/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShell.ts @@ -1,7 +1,7 @@ import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines'; -import type { IPipe } from '../IPipe'; +import type { Pipe } from '../Pipe'; -export class InlinePowerShell implements IPipe { +export class InlinePowerShell implements Pipe { public readonly name: string = 'inlinePowerShell'; public apply(code: string): string { @@ -9,9 +9,11 @@ export class InlinePowerShell implements IPipe { return code; } const processor = new Array<(data: string) => string>(...[ // for broken ESlint "indent" + // Order is important inlineComments, - mergeLinesWithBacktick, mergeHereStrings, + mergeLinesWithBacktick, + mergeLinesWithBracketCodeBlocks, mergeNewLines, ]).reduce((a, b) => (data) => b(a(data))); const newCode = processor(code); @@ -105,12 +107,12 @@ function mergeHereStrings(code: string) { return quoted; }); } -interface IInlinedHereString { +interface InlinedHereString { readonly quotesAround: string; readonly escapedQuotes: string; readonly separator: string; } -function getHereStringHandler(quotes: string): IInlinedHereString { +function getHereStringHandler(quotes: string): InlinedHereString { /* We handle @' and @" differently. Single quotes are interpreted literally and doubles are expandable. @@ -155,9 +157,33 @@ function mergeLinesWithBacktick(code: string) { return code.replaceAll(/ +`\s*(?:\r\n|\r|\n)\s*/g, ' '); } +/** + * Inlines code blocks in PowerShell scripts while preserving correct syntax. + * It removes unnecessary newlines and spaces around brackets, + * inlining the code where possible. + * This prevents syntax errors like "Unexpected token '}'" when inlining brackets. + */ +function mergeLinesWithBracketCodeBlocks(code: string): string { + return code + // Opening bracket: [whitespace] Opening bracket (newline) + .replace(/(?<=.*)\s*{[\r\n][\s\r\n]*/g, ' { ') + // Closing bracket: [whitespace] Closing bracket (newline) (continuation keyword) + .replace(/\s*}[\r\n][\s\r\n]*(?=elseif|else|catch|finally|until)/g, ' } ') + .replace(/(?<=do\s*{.*)[\r\n\s]*}[\r\n][\r\n\s]*(?=while)/g, ' } '); // Do-While +} + function mergeNewLines(code: string) { - return splitTextIntoLines(code) + const nonEmptyLines = splitTextIntoLines(code) .map((line) => line.trim()) - .filter((line) => line.length > 0) - .join('; '); + .filter((line) => line.length > 0); + + return nonEmptyLines + .map((line, index) => { + const isLastLine = index === nonEmptyLines.length - 1; + if (isLastLine) { + return line; + } + return line.endsWith(';') ? line : `${line};`; + }) + .join(' '); } diff --git a/src/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeFactory.ts b/src/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeFactory.ts index 7db84133..a8796ecc 100644 --- a/src/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeFactory.ts +++ b/src/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeFactory.ts @@ -1,6 +1,6 @@ import { InlinePowerShell } from './PipeDefinitions/InlinePowerShell'; import { EscapeDoubleQuotes } from './PipeDefinitions/EscapeDoubleQuotes'; -import type { IPipe } from './IPipe'; +import type { Pipe } from './Pipe'; const RegisteredPipes = [ new EscapeDoubleQuotes(), @@ -8,19 +8,19 @@ const RegisteredPipes = [ ]; export interface IPipeFactory { - get(pipeName: string): IPipe; + get(pipeName: string): Pipe; } export class PipeFactory implements IPipeFactory { - private readonly pipes = new Map(); + private readonly pipes = new Map(); - constructor(pipes: readonly IPipe[] = RegisteredPipes) { + constructor(pipes: readonly Pipe[] = RegisteredPipes) { for (const pipe of pipes) { this.registerPipe(pipe); } } - public get(pipeName: string): IPipe { + public get(pipeName: string): Pipe { validatePipeName(pipeName); const pipe = this.pipes.get(pipeName); if (!pipe) { @@ -29,7 +29,7 @@ export class PipeFactory implements IPipeFactory { return pipe; } - private registerPipe(pipe: IPipe): void { + private registerPipe(pipe: Pipe): void { validatePipeName(pipe.name); if (this.pipes.has(pipe.name)) { throw new Error(`Pipe name must be unique: "${pipe.name}"`); diff --git a/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/EscapeDoubleQuotes.spec.ts b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/EscapeDoubleQuotes.spec.ts index ff812b7e..314f197d 100644 --- a/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/EscapeDoubleQuotes.spec.ts +++ b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/EscapeDoubleQuotes.spec.ts @@ -1,7 +1,7 @@ import { describe } from 'vitest'; import { EscapeDoubleQuotes } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/EscapeDoubleQuotes'; import { getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; -import { runPipeTests } from './PipeTestRunner'; +import { runPipeTests, type PipeTestScenario } from './PipeTestRunner'; describe('EscapeDoubleQuotes', () => { // arrange @@ -9,23 +9,23 @@ describe('EscapeDoubleQuotes', () => { // act runPipeTests(sut, [ ...getAbsentStringTestCases({ excludeNull: true, excludeUndefined: true }) - .map((testCase) => ({ - name: 'returns as it is when if input is missing', + .map((testCase): PipeTestScenario => ({ + description: `returns empty when if input is missing (${testCase.valueName})`, input: testCase.absentValue, - expectedOutput: testCase.absentValue, + expectedOutput: '', })), { - name: 'using "', + description: 'using "', input: 'hello "world"', expectedOutput: 'hello "^""world"^""', }, { - name: 'not using any double quotes', + description: 'not using any double quotes', input: 'hello world', expectedOutput: 'hello world', }, { - name: 'consecutive double quotes', + description: 'consecutive double quotes', input: '""hello world""', expectedOutput: '"^"""^""hello world"^"""^""', }, diff --git a/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShell.spec.ts b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShell.spec.ts index 9ca4ac91..070f7fce 100644 --- a/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShell.spec.ts +++ b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShell.spec.ts @@ -1,465 +1,50 @@ import { describe } from 'vitest'; import { InlinePowerShell } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShell'; -import { getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; -import { type IPipeTestCase, runPipeTests } from './PipeTestRunner'; +import { runPipeTests, type PipeTestScenario } from './PipeTestRunner'; +import { createTryCatchFinallyTests } from './InlinePowerShellTests/CreateTryCatchFinallyTests'; +import { createAbsentCodeTests } from './InlinePowerShellTests/CreateAbsentCodeTests'; +import { createCommentedCodeTests } from './InlinePowerShellTests/CreateCommentedCodeTests'; +import { createIfStatementTests } from './InlinePowerShellTests/CreateIfStatementTests'; +import { createLineContinuationBacktickCases } from './InlinePowerShellTests/CreateLineContinuationBacktickTests'; +import { createDoWhileTests } from './InlinePowerShellTests/CreateDoWhileTests'; +import { createDoUntilTests } from './InlinePowerShellTests/CreateDoUntilTests'; +import { createForeachTests } from './InlinePowerShellTests/CreateForeachTests'; +import { createWhileTests } from './InlinePowerShellTests/CreateWhileTests'; +import { createForLoopTests } from './InlinePowerShellTests/CreateForLoopTests'; +import { createSwitchTests } from './InlinePowerShellTests/CreateSwitchTests'; +import { createHereStringTests } from './InlinePowerShellTests/CreateHereStringTests'; +import { createNewlineTests } from './InlinePowerShellTests/CreateNewlineTests'; +import { createFunctionTests } from './InlinePowerShellTests/CreateFunctionTests'; +import { createScriptBlockTests } from './InlinePowerShellTests/CreateScriptBlockTests'; describe('InlinePowerShell', () => { // arrange const sut = new InlinePowerShell(); // act runPipeTests(sut, [ - ...getAbsentStringTestCases({ excludeNull: true, excludeUndefined: true }) - .map((testCase) => ({ - name: 'returns as it is when if input is missing', - input: testCase.absentValue, - expectedOutput: '', - })), - ...prefixTests('newline', getNewLineCases()), - ...prefixTests('comment', getCommentCases()), - ...prefixTests('here-string', hereStringCases()), - ...prefixTests('backtick', backTickCases()), + ...prefixTests('absent code', createAbsentCodeTests()), + ...prefixTests('newline', createNewlineTests()), + ...prefixTests('comment', createCommentedCodeTests()), + ...prefixTests('here-string', createHereStringTests()), + ...prefixTests('line continuation backtick', createLineContinuationBacktickCases()), + ...prefixTests('try-catch-finally', createTryCatchFinallyTests()), + ...prefixTests('if statement', createIfStatementTests()), + ...prefixTests('do-while loop', createDoWhileTests()), + ...prefixTests('do-until loop', createDoUntilTests()), + ...prefixTests('foreach loop', createForeachTests()), + ...prefixTests('while loop', createWhileTests()), + ...prefixTests('for loop', createForLoopTests()), + ...prefixTests('switch statement', createSwitchTests()), + ...prefixTests('function', createFunctionTests()), + ...prefixTests('script block', createScriptBlockTests()), ]); }); -function hereStringCases(): IPipeTestCase[] { - const expectLinesInDoubleQuotes = (...lines: string[]) => lines.join('`r`n'); - const expectLinesInSingleQuotes = (...lines: string[]) => lines.join('\'+"`r`n"+\''); - return [ - { - name: 'adds newlines for double quotes', - input: getWindowsLines( - '@"', - 'Lorem', - 'ipsum', - 'dolor sit amet', - '"@', - ), - expectedOutput: expectLinesInDoubleQuotes( - '"Lorem', - 'ipsum', - 'dolor sit amet"', - ), - }, - { - name: 'adds newlines for single quotes', - input: getWindowsLines( - '@\'', - 'Lorem', - 'ipsum', - 'dolor sit amet', - '\'@', - ), - expectedOutput: expectLinesInSingleQuotes( - '\'Lorem', - 'ipsum', - 'dolor sit amet\'', - ), - }, - { - name: 'does not match with character after here string header', - input: getWindowsLines( - '@" invalid syntax', - 'I will not be processed as here-string', - '"@', - ), - expectedOutput: getSingleLinedOutput( - '@" invalid syntax', - 'I will not be processed as here-string', - '"@', - ), - }, - { - name: 'does not match if there\'s character before here-string terminator', - input: getWindowsLines( - '@\'', - 'do not match here', - ' \'@', - 'character \'@', - ), - expectedOutput: getSingleLinedOutput( - '@\'', - 'do not match here', - ' \'@', - 'character \'@', - ), - }, - { - name: 'does not match with different here-string header/terminator', - input: getWindowsLines( - '@\'', - 'lorem', - '"@', - ), - expectedOutput: getSingleLinedOutput( - '@\'', - 'lorem', - '"@', - ), - }, - { - name: 'matches with inner single quoted here-string', - input: getWindowsLines( - '$hasInnerDoubleQuotedTerminator = @"', - 'inner text', - '@\'', - 'inner terminator text', - '\'@', - '"@', - ), - expectedOutput: expectLinesInDoubleQuotes( - '$hasInnerDoubleQuotedTerminator = "inner text', - '@\'', - 'inner terminator text', - '\'@"', - ), - }, - { - name: 'matches with inner double quoted string', - input: getWindowsLines( - '$hasInnerSingleQuotedTerminator = @\'', - 'inner text', - '@"', - 'inner terminator text', - '"@', - '\'@', - ), - expectedOutput: expectLinesInSingleQuotes( - '$hasInnerSingleQuotedTerminator = \'inner text', - '@"', - 'inner terminator text', - '"@\'', - ), - }, - { - name: 'matches if there\'s character after here-string terminator', - input: getWindowsLines( - '@\'', - 'lorem', - '\'@ after', - ), - expectedOutput: expectLinesInSingleQuotes( - '\'lorem\' after', - ), - }, - { - name: 'escapes double quotes inside double quotes', - input: getWindowsLines( - '@"', - 'For help, type "get-help"', - '"@', - ), - expectedOutput: '"For help, type `"get-help`""', - }, - { - name: 'escapes single quotes inside single quotes', - input: getWindowsLines( - '@\'', - 'For help, type \'get-help\'', - '\'@', - ), - expectedOutput: '\'For help, type \'\'get-help\'\'\'', - }, - { - name: 'converts when here-string header is not at line start', - input: getWindowsLines( - '$page = [XML] @"', - 'multi-lined', - 'and "quoted"', - '"@', - ), - expectedOutput: expectLinesInDoubleQuotes( - '$page = [XML] "multi-lined', - 'and `"quoted`""', - ), - }, - { - name: 'trims after here-string header', - input: getWindowsLines( - '@" \t', - 'text with whitespaces at here-string start', - '"@', - ), - expectedOutput: '"text with whitespaces at here-string start"', - }, - { - name: 'preserves whitespaces in lines', - input: getWindowsLines( - '@\'', - '\ttext with tabs around\t\t', - ' text with whitespaces around ', - '\'@', - ), - expectedOutput: expectLinesInSingleQuotes( - '\'\ttext with tabs around\t\t', - ' text with whitespaces around \'', - ), - }, - ]; -} - -function backTickCases(): IPipeTestCase[] { - return [ - { - name: 'wraps newlines with trailing backtick', - input: getWindowsLines( - 'Get-Service * `', - '| Format-Table -AutoSize', - ), - expectedOutput: 'Get-Service * | Format-Table -AutoSize', - }, - { - name: 'wraps newlines with trailing backtick and different line endings', - input: 'Get-Service `\n' - + '* `\r' - + '| Sort-Object StartType `\r\n' - + '| Format-Table -AutoSize', - expectedOutput: 'Get-Service * | Sort-Object StartType | Format-Table -AutoSize', - }, - { - name: 'trims tabs and whitespaces on next lines when wrapping with trailing backtick', - input: getWindowsLines( - 'Get-Service * `', - '\t| Sort-Object StartType `', - ' | Format-Table -AutoSize', - ), - expectedOutput: 'Get-Service * | Sort-Object StartType | Format-Table -AutoSize', - }, - { - name: 'does not wrap without whitespace before backtick', - input: getWindowsLines( - 'Get-Service *`', - '| Format-Table -AutoSize', - ), - expectedOutput: getSingleLinedOutput( - 'Get-Service *`', - '| Format-Table -AutoSize', - ), - }, - { - name: 'does not wrap with characters after', - input: getWindowsLines( - 'line start ` after', - 'should not be wrapped', - ), - expectedOutput: getSingleLinedOutput( - 'line start ` after', - 'should not be wrapped', - ), - }, - ]; -} - -function getCommentCases(): IPipeTestCase[] { - return [ - { - name: 'converts hash comments in the line end', - input: getWindowsLines( - '$text = "Hello"\t# Comment after tab', - '$text+= #Comment without space after hash', - 'Write-Host $text# Comment without space before hash', - ), - expectedOutput: getSingleLinedOutput( - '$text = "Hello"\t<# Comment after tab #>', - '$text+= <# Comment without space after hash #>', - 'Write-Host $text<# Comment without space before hash #>', - ), - }, - { - name: 'converts hash comment line', - input: getWindowsLines( - '# Comment in first line', - 'Write-Host "Hello"', - '# Comment in the middle', - 'Write-Host "World"', - '# Consecutive comments', - '# Last line comment without line ending in the end', - ), - expectedOutput: getSingleLinedOutput( - '<# Comment in first line #>', - 'Write-Host "Hello"', - '<# Comment in the middle #>', - 'Write-Host "World"', - '<# Consecutive comments #>', - '<# Last line comment without line ending in the end #>', - ), - }, - { - name: 'can convert comment with inline comment parts inside', - input: getWindowsLines( - '$text+= #Comment with < inside', - '$text+= #Comment ending with >', - '$text+= #Comment with <# inline comment #>', - ), - expectedOutput: getSingleLinedOutput( - '$text+= <# Comment with < inside #>', - '$text+= <# Comment ending with > #>', - '$text+= <# Comment with <# inline comment #> #>', - ), - }, - { - name: 'can convert comment with inline comment parts around', // Pretty uncommon - input: getWindowsLines( - 'Write-Host "hi" # Comment ending line inline comment but not one #>', - 'Write-Host "hi" #>Comment starting like inline comment end but not one', - // Following line does not compile as valid PowerShell due to missing #> for inline comment. - 'Write-Host "hi" <#Comment starting like inline comment start but not one', - ), - expectedOutput: getSingleLinedOutput( - 'Write-Host "hi" <# Comment ending line inline comment but not one #> #>', - 'Write-Host "hi" <# >Comment starting like inline comment end but not one #>', - 'Write-Host "hi" <<# Comment starting like inline comment start but not one #>', - ), - }, - { - name: 'converts empty hash comment', - input: getWindowsLines( - 'Write-Host "Comment without text" #', - 'Write-Host "Non-empty line"', - ), - expectedOutput: getSingleLinedOutput( - 'Write-Host "Comment without text" <##>', - 'Write-Host "Non-empty line"', - ), - }, - { - name: 'adds whitespaces around to match', - input: getWindowsLines( - '#Comment line with no whitespaces around', - 'Write-Host "Hello"#Comment in the end with no whitespaces around', - ), - expectedOutput: getSingleLinedOutput( - '<# Comment line with no whitespaces around #>', - 'Write-Host "Hello"<# Comment in the end with no whitespaces around #>', - ), - }, - { - name: 'trims whitespaces around comment', - input: getWindowsLines( - '# Comment with whitespaces around ', - '#\tComment with tabs around\t\t', - '#\t Comment with tabs and whitespaces around \t \t', - ), - expectedOutput: getSingleLinedOutput( - '<# Comment with whitespaces around #>', - '<# Comment with tabs around #>', - '<# Comment with tabs and whitespaces around #>', - ), - }, - { - name: 'does not convert block comments', - input: getWindowsLines( - '$text = "Hello"\t<# block comment #> + "World"', - '$text = "Hello"\t+<#comment#>"World"', - '<# Block comment in a line #>', - 'Write-Host "Hello world <# Block comment in the end of line #>', - ), - expectedOutput: getSingleLinedOutput( - '$text = "Hello"\t<# block comment #> + "World"', - '$text = "Hello"\t+<#comment#>"World"', - '<# Block comment in a line #>', - 'Write-Host "Hello world <# Block comment in the end of line #>', - ), - }, - { - name: 'does not process if there are no multi lines', - input: 'Write-Host "expected" # as it is!', - expectedOutput: 'Write-Host "expected" # as it is!', - }, - ]; -} - -function getNewLineCases(): IPipeTestCase[] { - return [ - { - name: 'no new line', - input: 'Write-Host \'Hello, World!\'', - expectedOutput: 'Write-Host \'Hello, World!\'', - }, - { - name: '\\n new line', - input: - '$things = Get-ChildItem C:\\Windows\\' - + '\nforeach ($thing in $things) {' - + '\nWrite-Host $thing.Name -ForegroundColor Magenta' - + '\n}', - expectedOutput: getSingleLinedOutput( - '$things = Get-ChildItem C:\\Windows\\', - 'foreach ($thing in $things) {', - 'Write-Host $thing.Name -ForegroundColor Magenta', - '}', - ), - }, - { - name: '\\n double empty lines are ignored', - input: - '$things = Get-ChildItem C:\\Windows\\' - + '\n\nforeach ($thing in $things) {' - + '\n\nWrite-Host $thing.Name -ForegroundColor Magenta' - + '\n\n\n}', - expectedOutput: getSingleLinedOutput( - '$things = Get-ChildItem C:\\Windows\\', - 'foreach ($thing in $things) {', - 'Write-Host $thing.Name -ForegroundColor Magenta', - '}', - ), - }, - { - name: '\\r new line', - input: - '$things = Get-ChildItem C:\\Windows\\' - + '\rforeach ($thing in $things) {' - + '\rWrite-Host $thing.Name -ForegroundColor Magenta' - + '\r}', - expectedOutput: getSingleLinedOutput( - '$things = Get-ChildItem C:\\Windows\\', - 'foreach ($thing in $things) {', - 'Write-Host $thing.Name -ForegroundColor Magenta', - '}', - ), - }, - { - name: '\\r and \\n newlines combined', - input: - '$things = Get-ChildItem C:\\Windows\\' - + '\r\nforeach ($thing in $things) {' - + '\n\rWrite-Host $thing.Name -ForegroundColor Magenta' - + '\n\r}', - expectedOutput: getSingleLinedOutput( - '$things = Get-ChildItem C:\\Windows\\', - 'foreach ($thing in $things) {', - 'Write-Host $thing.Name -ForegroundColor Magenta', - '}', - ), - }, - { - name: 'trims whitespaces on lines', - input: - ' $things = Get-ChildItem C:\\Windows\\ ' - + '\nforeach ($thing in $things) {' - + '\n\tWrite-Host $thing.Name -ForegroundColor Magenta' - + '\r \n}', - expectedOutput: getSingleLinedOutput( - '$things = Get-ChildItem C:\\Windows\\', - 'foreach ($thing in $things) {', - 'Write-Host $thing.Name -ForegroundColor Magenta', - '}', - ), - }, - ]; -} - -function prefixTests(prefix: string, tests: IPipeTestCase[]): IPipeTestCase[] { +function prefixTests(prefix: string, tests: PipeTestScenario[]): PipeTestScenario[] { return tests.map((test) => ({ - name: `[${prefix}] ${test.name}`, - input: test.input, - expectedOutput: test.expectedOutput, + ...test, + ...{ + description: `[${prefix}] ${test.description}`, + }, })); } - -function getWindowsLines(...lines: string[]) { - return lines.join('\r\n'); -} - -function getSingleLinedOutput(...lines: string[]) { - return lines.map((line) => line.trim()).join('; '); -} diff --git a/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CommonInlinePowerShellTestUtilities.ts b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CommonInlinePowerShellTestUtilities.ts new file mode 100644 index 00000000..d92fd4dd --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CommonInlinePowerShellTestUtilities.ts @@ -0,0 +1,26 @@ +import { RegexBuilder } from '../PipeTestRunner'; + +export function joinAsWindowsLines( + ...lines: string[] +): string { + return lines.join('\r\n'); +} + +/** + * Builds a relaxed regular expression pattern for matching inlined multiple lines of code + * with basic semicolon merging. + */ +export function getInlinedOutputWithSemicolons( + ...lines: string[] +): RegExp { + const trimmedLines = lines.map((line) => line.trim()); + const builder = new RegexBuilder(); + trimmedLines.forEach((line, index) => { + builder.withLiteralString(line); + builder.withOptionalSemicolon(); // Semi colon at the end compiles fine + if (index !== trimmedLines.length - 1) { + builder.withOptionalWhitespaceButNoNewline(); + } + }); + return builder.buildRegex(); +} diff --git a/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateAbsentCodeTests.ts b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateAbsentCodeTests.ts new file mode 100644 index 00000000..d7d6759b --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateAbsentCodeTests.ts @@ -0,0 +1,28 @@ +import { getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; +import type { PipeTestScenario } from '../PipeTestRunner'; + +export function createAbsentCodeTests(): PipeTestScenario[] { + return [ + ...getAbsentStringTestCases({ excludeNull: true, excludeUndefined: true }) + .map((testCase): PipeTestScenario => ({ + description: `absent string (${testCase.valueName})`, + input: testCase.absentValue, + expectedOutput: '', + })), + { + description: 'whitespace-only input', + input: ' \t\n\r\f\v\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000', + expectedOutput: '', + }, + { + description: 'newline-only input', + input: '\n\r\u2028\u2029', + expectedOutput: '', + }, + { + description: 'newline-only input', + input: ' \t\n\r\f\v\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\n\r\u2028\u2029', + expectedOutput: '', + }, + ]; +} diff --git a/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateCommentedCodeTests.ts b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateCommentedCodeTests.ts new file mode 100644 index 00000000..7ff0c8a8 --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateCommentedCodeTests.ts @@ -0,0 +1,122 @@ +import { getInlinedOutputWithSemicolons, joinAsWindowsLines } from './CommonInlinePowerShellTestUtilities'; +import type { PipeTestScenario } from '../PipeTestRunner'; + +export function createCommentedCodeTests(): PipeTestScenario[] { + // https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_comment_based_help?view=powershell-7.4 + return [ + { + description: 'converts hash comments at line end', + input: joinAsWindowsLines( + '$text = "Hello"\t# Comment after tab', + '$text+= #Comment without space after hash', + 'Write-Host $text# Comment without space before hash', + ), + expectedOutput: getInlinedOutputWithSemicolons( + '$text = "Hello"\t<# Comment after tab #>', + '$text+= <# Comment without space after hash #>', + 'Write-Host $text<# Comment without space before hash #>', + ), + }, + { + description: 'converts hash comment lines', + input: joinAsWindowsLines( + '# Comment in first line', + 'Write-Host "Hello"', + '# Comment in the middle', + 'Write-Host "World"', + '# Consecutive comments', + '# Last line comment without line ending in the end', + ), + expectedOutput: getInlinedOutputWithSemicolons( + '<# Comment in first line #>', + 'Write-Host "Hello"', + '<# Comment in the middle #>', + 'Write-Host "World"', + '<# Consecutive comments #>', + '<# Last line comment without line ending in the end #>', + ), + }, + { + description: 'converts comments with inline comment parts inside', + input: joinAsWindowsLines( + '$text+= #Comment with < inside', + '$text+= #Comment ending with >', + '$text+= #Comment with <# inline comment #>', + ), + expectedOutput: getInlinedOutputWithSemicolons( + '$text+= <# Comment with < inside #>', + '$text+= <# Comment ending with > #>', + '$text+= <# Comment with <# inline comment #> #>', + ), + }, + { + description: 'converts comments with inline comment parts around', // Pretty uncommon + input: joinAsWindowsLines( + 'Write-Host "hi" # Comment ending line inline comment but not one #>', + 'Write-Host "hi" #>Comment starting like inline comment end but not one', + // Following line does not compile as valid PowerShell due to missing #> for inline comment. + 'Write-Host "hi" <#Comment starting like inline comment start but not one', + ), + expectedOutput: getInlinedOutputWithSemicolons( + 'Write-Host "hi" <# Comment ending line inline comment but not one #> #>', + 'Write-Host "hi" <# >Comment starting like inline comment end but not one #>', + 'Write-Host "hi" <<# Comment starting like inline comment start but not one #>', + ), + }, + { + description: 'converts empty hash comments', + input: joinAsWindowsLines( + 'Write-Host "Comment without text" #', + 'Write-Host "Non-empty line"', + ), + expectedOutput: getInlinedOutputWithSemicolons( + 'Write-Host "Comment without text" <##>', + 'Write-Host "Non-empty line"', + ), + }, + { + description: 'adds whitespaces around comments', + input: joinAsWindowsLines( + '#Comment line with no whitespaces around', + 'Write-Host "Hello"#Comment in the end with no whitespaces around', + ), + expectedOutput: getInlinedOutputWithSemicolons( + '<# Comment line with no whitespaces around #>', + 'Write-Host "Hello"<# Comment in the end with no whitespaces around #>', + ), + }, + { + description: 'trims whitespaces around comments', + input: joinAsWindowsLines( + '# Comment with whitespaces around ', + '#\tComment with tabs around\t\t', + '#\t Comment with tabs and whitespaces around \t \t', + ), + expectedOutput: getInlinedOutputWithSemicolons( + '<# Comment with whitespaces around #>', + '<# Comment with tabs around #>', + '<# Comment with tabs and whitespaces around #>', + ), + }, + { + description: 'preserves block comments', + input: joinAsWindowsLines( + '$text = "Hello"\t<# block comment #> + "World"', + '$text = "Hello"\t+<#comment#>"World"', + '<# Block comment in a line #>', + 'Write-Host "Hello world <# Block comment in the end of line #>', + ), + expectedOutput: getInlinedOutputWithSemicolons( + '$text = "Hello"\t<# block comment #> + "World"', + '$text = "Hello"\t+<#comment#>"World"', + '<# Block comment in a line #>', + 'Write-Host "Hello world <# Block comment in the end of line #>', + ), + }, + { + description: 'preserves single-line input', + input: 'Write-Host "expected" # as it is!', + expectedOutput: 'Write-Host "expected" # as it is!', + }, + ]; +} diff --git a/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateDoUntilTests.ts b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateDoUntilTests.ts new file mode 100644 index 00000000..8b07b48a --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateDoUntilTests.ts @@ -0,0 +1,407 @@ +import { RegexBuilder, type PipeTestScenario } from '../PipeTestRunner'; +import { joinAsWindowsLines } from './CommonInlinePowerShellTestUtilities'; + +export function createDoUntilTests(): PipeTestScenario[] { + // https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_do?view=powershell-7.4 + return [ + { + description: 'do-until loop without newlines', + input: 'do { $i++; Write-Host $i } until ($i -ge 5)', + expectedOutput: 'do { $i++; Write-Host $i } until ($i -ge 5)', + }, + { + description: 'simple do-until loop (single line inside do block)', + input: joinAsWindowsLines( + 'do {', + ' $i++', + '} until ($i -lt 5)', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('do') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$i++') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('until') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($i -lt 5)') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'multiline do-until loop', + input: joinAsWindowsLines( + 'do {', + ' $i++', + ' Write-Host $i', + '} until ($i -ge 5)', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('do') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$i++') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host $i') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('until') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($i -ge 5)') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'nested do-until loops', + input: joinAsWindowsLines( + 'do {', + ' $outer = 0', + ' do {', + ' $inner = 0', + ' do {', + ' $inner++', + ' Write-Host "Inner: $inner"', + ' } until ($inner -ge 3)', + ' $outer++', + ' Write-Host "Outer: $outer"', + ' } until ($outer -ge 2)', + ' $mainCounter++', + ' Write-Host "Main: $mainCounter"', + '} until ($mainCounter -ge 2)', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('do') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$outer = 0') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('do') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$inner = 0') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('do') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$inner++') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Inner: $inner"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('until') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($inner -ge 3)') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$outer++') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Outer: $outer"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('until') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($outer -ge 2)') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$mainCounter++') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Main: $mainCounter"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('until') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($mainCounter -ge 2)') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'do-until loop with condition on separate line', + input: joinAsWindowsLines( + 'do {', + ' $i++', + ' Write-Host $i', + '}', + 'until ($i -ge 5)', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('do') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$i++') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host $i') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') // No semicolon after this to prevent runtime errors + .withOptionalWhitespaceButNoNewline() + .withLiteralString('until') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($i -ge 5)') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'do-until loop with complex condition', + input: joinAsWindowsLines( + 'do {', + ' $i++', + ' $j--', + '} until ( `', // Marked: inline-conditions | Merging multiline conditions is not yet supported, so using backticks + ' $i -ge 10 -or `', + ' $j -le 0 `', + ')', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('do') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$i++') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$j--') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('until') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('(') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$i -ge 10 -or') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$j -le 0') + .withOptionalWhitespaceButNoNewline() + .withLiteralString(')') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'do-until loop with nested if statement', + input: joinAsWindowsLines( + 'do {', + ' $i++', + ' if ($i % 2 -eq 0) {', + ' Write-Host "Even: $i"', + ' } else {', + ' Write-Host "Odd: $i"', + ' }', + '} until ($i -ge 5)', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('do') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$i++') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('if ($i % 2 -eq 0)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Even: $i"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('else') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Odd: $i"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('until') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($i -ge 5)') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'do-until loop with semicolon after closing brace', + input: joinAsWindowsLines( + 'do {', + ' $i++', + ' Write-Host $i', + '};', + 'until ($i -ge 5)', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('do') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$i++') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host $i') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('until') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($i -ge 5)') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: '-until loop with pipeline in condition', + input: joinAsWindowsLines( + 'do {', + ' $result = Get-Something', + ' Process-Result $result', + '} until ($result | Test-Condition)', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('do') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$result = Get-Something') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Process-Result $result') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('until') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('(') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$result | Test-Condition') + .withOptionalWhitespaceButNoNewline() + .withLiteralString(')') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'do-until loop with multiline condition', + input: joinAsWindowsLines( + 'do {', + ' $result = Get-Something', + ' Process-Result $result', + '} until ( `', // Marked: inline-conditions | Merging multiline conditions is not yet supported, so using backticks + ' $result -and (-Not $result) `', + ')', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('do') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$result = Get-Something') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Process-Result $result') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('until') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('(') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$result -and (-Not $result)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString(')') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'do-until loop with script block condition', + input: joinAsWindowsLines( + 'do {', + ' $i++', + ' Write-Host $i', + '} until ( `', // Marked: inline-conditions | Merging multiline conditions is not yet supported, so using backticks + ' & {', + ' param($val)', + ' $val -ge 5', + ' } $i `', + ')', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('do') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$i++') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host $i') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('until') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('(') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('&') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('param($val)') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$val -ge 5') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$i') + .withOptionalWhitespaceButNoNewline() + .withLiteralString(')') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'do-until after closing bracket', + input: joinAsWindowsLines( + 'switch ($value) { default { Write-Host "Default" } }', + 'do { $i++ } until ($i -ge 5)', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('switch ($value) { default { Write-Host "Default" } }') + .withSemicolon() // Semicolon here to prevent runtime errors + .withOptionalWhitespaceButNoNewline() + .withLiteralString('do { $i++ } until ($i -ge 5)') + .withOptionalSemicolon() + .buildRegex(), + }, + ]; +} diff --git a/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateDoWhileTests.ts b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateDoWhileTests.ts new file mode 100644 index 00000000..e4c13e47 --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateDoWhileTests.ts @@ -0,0 +1,281 @@ +import { RegexBuilder, type PipeTestScenario } from '../PipeTestRunner'; +import { joinAsWindowsLines } from './CommonInlinePowerShellTestUtilities'; + +export function createDoWhileTests(): PipeTestScenario[] { + // https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_do?view=powershell-7.4 + return [ + { + description: 'do-while loop without newlines', + input: 'do { $i++ } while ($i -lt 5)', + expectedOutput: 'do { $i++ } while ($i -lt 5)', + }, + { + description: 'simple do-while loop (single line inside do block)', + input: joinAsWindowsLines( + 'do {', + ' $i++', + '} while ($i -lt 5)', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('do') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$i++') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('while') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($i -lt 5)') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'multiline do-while loop', + input: joinAsWindowsLines( + 'do {', + ' $i++', + ' Write-Host $i', + '} while ($i -lt 5)', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('do') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$i++') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host $i') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('while') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($i -lt 5)') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'nested do-while loops', + input: joinAsWindowsLines( + 'do {', + ' $i++', + ' do {', + ' $j++', + ' } while ($j -lt 3)', + ' Write-Host "$i, $j"', + '} while ($i -lt 5)', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('do') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$i++') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('do') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$j++') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('while') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($j -lt 3)') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "$i, $j"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('while') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($i -lt 5)') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'do-while loop with condition on separate line', + input: joinAsWindowsLines( + 'do {', + ' $i++', + '}', + 'while ($i -lt 5)', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('do') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$i++') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') // No semicolon after this to prevent runtime errors + .withOptionalWhitespaceButNoNewline() + .withLiteralString('while') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($i -lt 5)') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'do-while loop with multiline condition', + input: joinAsWindowsLines( + 'do {', + ' $i++', + ' $j--', + '} while ( `', // Marked: inline-conditions | Merging multiline conditions is not yet supported, so using backticks + ' $i -lt 10 -and `', + ' $j -gt 0 `', + ')', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('do') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$i++') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$j--') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('while') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('(') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$i -lt 10 -and') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$j -gt 0') + .withOptionalWhitespaceButNoNewline() + .withLiteralString(')') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'do-while loop with nested if statement', + input: joinAsWindowsLines( + 'do {', + ' $i++', + ' if ($i % 2 -eq 0) {', + ' Write-Host "Even"', + ' } else {', + ' Write-Host "Odd"', + ' }', + '} while ($i -lt 5)', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('do') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$i++') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('if ($i % 2 -eq 0)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Even"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('else') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Odd"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('while') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($i -lt 5)') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'do-while loop with semicolon after closing brace', + input: joinAsWindowsLines( + 'do {', + ' $i++', + '};', + 'while ($i -lt 5)', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('do') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$i++') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('while') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($i -lt 5)') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'do-while loop with pipeline in condition', + input: joinAsWindowsLines( + 'do {', + ' $result = Get-Something', + '} while ( $result | Test-Condition )', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('do') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$result = Get-Something') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('while') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('(') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$result | Test-Condition') + .withOptionalWhitespaceButNoNewline() + .withLiteralString(')') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'do-while after closing bracket', + input: joinAsWindowsLines( + 'if ($someCondition) { $variable = "Some value" }', + 'do { $i++; Write-Host $i } while ($i -lt 5)', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('if ($someCondition) { $variable = "Some value" }') + .withSemicolon() // Semicolon here to prevent runtime errors + .withOptionalWhitespaceButNoNewline() + .withLiteralString('do { $i++; Write-Host $i } while ($i -lt 5)') + .withOptionalSemicolon() + .buildRegex(), + }, + ]; +} diff --git a/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateForLoopTests.ts b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateForLoopTests.ts new file mode 100644 index 00000000..01715719 --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateForLoopTests.ts @@ -0,0 +1,214 @@ +import { RegexBuilder, type PipeTestScenario } from '../PipeTestRunner'; +import { joinAsWindowsLines } from './CommonInlinePowerShellTestUtilities'; + +export function createForLoopTests(): PipeTestScenario[] { + // https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_for?view=powershell-7.4 + // Known limitations: + // - Multiline for loop with sections without semicolon, e.g. `for(\n$i =0\n$i - l5\n$i++\n)` + return [ + { + description: 'for loop without newlines', + input: 'for ($i = 0; $i -lt 5; $i++) { Write-Host $i }', + expectedOutput: 'for ($i = 0; $i -lt 5; $i++) { Write-Host $i }', + }, + { + description: 'simple for loop (single line inside code block)', + input: joinAsWindowsLines( + 'for ($i = 0; $i -lt 5; $i++) {', + ' Write-Host $i', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('for') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($i = 0; $i -lt 5; $i++)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host $i') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'multiline for loop', + input: joinAsWindowsLines( + 'for ($i = 0; $i -lt 5; $i++) {', + ' Write-Host "Current value: $i"', + ' $result += $i', + ' Do-SomethingWith $i', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('for') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($i = 0; $i -lt 5; $i++)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Current value: $i"') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$result += $i') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Do-SomethingWith $i') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'nested for loops', + input: joinAsWindowsLines( + 'for ($i = 0; $i -lt 3; $i++) {', + ' for ($j = 0; $j -lt 2; $j++) {', + ' Write-Host "i: $i, j: $j"', + ' }', + ' Write-Host "Outer loop: $i"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('for') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($i = 0; $i -lt 3; $i++)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('for') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($j = 0; $j -lt 2; $j++)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "i: $i, j: $j"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Outer loop: $i"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'for loop without spaces in header', + input: joinAsWindowsLines( + 'for($i=0;$i-lt5;$i++){', + ' Write-Host $i', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('for') + .withLiteralString('($i=0;$i-lt5;$i++)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host $i') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'for loop with empty sections', + input: joinAsWindowsLines( + 'for (;;) {', + ' $i++', + ' if ($i -ge 5) { break }', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('for') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('(;;)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$i++') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('if ($i -ge 5) { break }') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'for loop with multiple statements in each section', + input: joinAsWindowsLines( + 'for ($i = 0, $j = 10; $i -lt 5 -and $j -gt 0; $i++, $j--) {', + ' Write-Host "i: $i, j: $j"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('for') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($i = 0, $j = 10; $i -lt 5 -and $j -gt 0; $i++, $j--)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "i: $i, j: $j"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'for loop with multiline sections', + input: joinAsWindowsLines( + 'for ( `', // Marked: inline-conditions | Merging multiline conditions is not yet supported, so using backticks + ' $i = 0; `', + ' $i -lt 5; `', + ' $i++ `', + ') {', + ' Write-Host $i', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('for') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('(') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$i = 0;') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$i -lt 5;') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$i++') + .withOptionalWhitespaceButNoNewline() + .withLiteralString(')') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host $i') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'for after closing bracket', + input: joinAsWindowsLines( + '$scriptBlock = { Write-Host "Inside script block" }', + 'for ($i = 0; $i -lt 5; $i++) { Write-Host $i }', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('$scriptBlock = { Write-Host "Inside script block" }') + .withSemicolon() // Semicolon here to prevent runtime errors + .withOptionalWhitespaceButNoNewline() + .withLiteralString('for ($i = 0; $i -lt 5; $i++) { Write-Host $i }') + .withOptionalSemicolon() + .buildRegex(), + }, + ]; +} diff --git a/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateForeachTests.ts b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateForeachTests.ts new file mode 100644 index 00000000..c397ea53 --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateForeachTests.ts @@ -0,0 +1,283 @@ +import { RegexBuilder, type PipeTestScenario } from '../PipeTestRunner'; +import { joinAsWindowsLines } from './CommonInlinePowerShellTestUtilities'; + +export function createForeachTests(): PipeTestScenario[] { + return [ + { + description: 'foreach loop without newlines', + input: 'foreach ($item in $collection) { Write-Host $item }', + expectedOutput: 'foreach ($item in $collection) { Write-Host $item }', + }, + { + description: 'simple foreach loop (single line inside code block)', + input: joinAsWindowsLines( + 'foreach ($item in $collection) {', + ' Write-Host $item', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('foreach') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($item in $collection)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host $item') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'multiline foreach loop', + input: joinAsWindowsLines( + 'foreach ($item in $collection) {', + ' $processedItem = $item.ToUpper()', + ' Write-Host "Processing: $processedItem"', + ' $result += $processedItem', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('foreach') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($item in $collection)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$processedItem = $item.ToUpper()') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Processing: $processedItem"') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$result += $processedItem') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'nested foreach loops', + input: joinAsWindowsLines( + 'foreach ($outer in $outerCollection) {', + ' Write-Host "Outer: $outer"', + ' foreach ($inner in $innerCollection) {', + ' Write-Host " Inner: $inner"', + ' $result = "$outer-$inner"', + ' $combinedResults += $result', + ' }', + ' Write-Host "Completed inner loop for $outer"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('foreach') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($outer in $outerCollection)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Outer: $outer"') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('foreach') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($inner in $innerCollection)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host " Inner: $inner"') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$result = "$outer-$inner"') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$combinedResults += $result') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Completed inner loop for $outer"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'foreach loop with multiline condition', + input: joinAsWindowsLines( + 'foreach ( `', // Marked: inline-conditions | Merging multiline conditions is not yet supported, so using backticks + ' $item `', + ' in `', + ' $collection `', + ') {', + ' Write-Host $item', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('foreach') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('(') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$item') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('in') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$collection') + .withOptionalWhitespaceButNoNewline() + .withLiteralString(')') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host $item') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'foreach loop with pipeline in collection', + input: joinAsWindowsLines( + 'foreach ($item in Get-Process | Where-Object { $_.CPU -gt 50 }) {', + ' Write-Host $item.Name', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('foreach') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($item in Get-Process |') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Where-Object { $_.CPU -gt 50 })') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host $item.Name') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'foreach loop with complex collection expression', + input: joinAsWindowsLines( + 'foreach ($item in ( `', + ' $array1 + `', // Marked: inline-conditions | Merging multiline conditions is not yet supported, so using backticks + ' $array2 | `', + ' Where-Object { $_ -ne $null } `', + ')) {', + ' Write-Host $item', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('foreach') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($item in (') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$array1 +') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$array2 |') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Where-Object { $_ -ne $null }') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('))') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host $item') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'foreach loop with script block in collection', + input: joinAsWindowsLines( + 'foreach ($item in & {', + ' param($start, $end)', + ' $start..$end', + '} 1 10) {', + ' Write-Host $item', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('foreach') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($item in &') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('param($start, $end)') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$start..$end') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('1 10)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host $item') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'foreach loop with closing brace on same line as last statement', + input: joinAsWindowsLines( + 'foreach ($item in $collection) {', + ' Write-Host $item', + ' Process-Item $item }', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('foreach') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($item in $collection)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host $item') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Process-Item $item') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'foreach after closing bracket', + input: joinAsWindowsLines( + 'function Test-Function { Write-Host "Test" }', + 'foreach ($item in @(1,2,3)) { Write-Host $item }', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('function Test-Function { Write-Host "Test" }') + .withSemicolon() // Semicolon here to prevent runtime errors + .withOptionalWhitespaceButNoNewline() + .withLiteralString('foreach ($item in @(1,2,3)) { Write-Host $item }') + .withOptionalSemicolon() + .buildRegex(), + }, + ]; +} diff --git a/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateFunctionTests.ts b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateFunctionTests.ts new file mode 100644 index 00000000..b166abfd --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateFunctionTests.ts @@ -0,0 +1,229 @@ +import { RegexBuilder, type PipeTestScenario } from '../PipeTestRunner'; +import { joinAsWindowsLines } from './CommonInlinePowerShellTestUtilities'; + +export function createFunctionTests(): PipeTestScenario[] { + // https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_functions?view=powershell-7.4 + // Known limitations: + // - Functions with advanced parameters are not yet supported. + return [ + { + description: 'function without newlines', + input: 'function Get-Name { param($FirstName, $LastName) Write-Output "$FirstName $LastName" }', + expectedOutput: 'function Get-Name { param($FirstName, $LastName) Write-Output "$FirstName $LastName" }', + }, + { + description: 'simple function (single line inside code block)', + input: joinAsWindowsLines( + 'function Say-Hello {', + ' Write-Host "Hello, World!"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('function') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Say-Hello') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Hello, World!"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'function with multiple statements', + input: joinAsWindowsLines( + 'function Do-Something {', + ' $result = Get-Something', + ' Process-Result $result', + ' Write-Output "Done processing"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('function') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Do-Something') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$result = Get-Something') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Process-Result $result') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Output "Done processing"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'function with begin, process, and end blocks', + input: joinAsWindowsLines( + 'function Process-Collection {', + ' begin {', + ' $total = 0', + ' }', + ' process {', + ' $total += $_', + ' }', + ' end {', + ' Write-Output "Total: $total"', + ' }', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('function') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Process-Collection') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('begin') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$total = 0') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('process') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$total += $_') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('end') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Output "Total: $total"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'nested functions', + input: joinAsWindowsLines( + 'function Outer-Function {', + ' param($outerParam)', + ' function Inner-Function {', + ' param($innerParam)', + ' Write-Output "Inner: $innerParam"', + ' }', + ' Write-Output "Outer: $outerParam"', + ' Inner-Function -innerParam "Hello from inner"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('function') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Outer-Function') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('param($outerParam)') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('function') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Inner-Function') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('param($innerParam)') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Output "Inner: $innerParam"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Output "Outer: $outerParam"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Inner-Function -innerParam "Hello from inner"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'function without space before opening brace', + input: joinAsWindowsLines( + 'function Get-Something{', + ' return "Something"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('function') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Get-Something') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('return "Something"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'function with single-line param block', + input: joinAsWindowsLines( + 'function Set-Value {', + ' param([string]$key, [object]$value)', + ' $hash[$key] = $value', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('function') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Set-Value') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('param([string]$key, [object]$value)') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$hash[$key] = $value') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'function after closing bracket', + input: joinAsWindowsLines( + 'if ($condition) { $value = 10 }', + 'function Test-Function { param($param) Write-Host $param }', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('if ($condition) { $value = 10 }') + .withSemicolon() // Semicolon here to prevent runtime errors + .withOptionalWhitespaceButNoNewline() + .withLiteralString('function Test-Function { param($param) Write-Host $param }') + .withOptionalSemicolon() + .buildRegex(), + }, + ]; +} diff --git a/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateHereStringTests.ts b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateHereStringTests.ts new file mode 100644 index 00000000..89a4568f --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateHereStringTests.ts @@ -0,0 +1,240 @@ +import { getInlinedOutputWithSemicolons, joinAsWindowsLines } from './CommonInlinePowerShellTestUtilities'; +import type { PipeTestScenario } from '../PipeTestRunner'; + +export function createHereStringTests(): PipeTestScenario[] { + // https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules?view=powershell-7.4#here-strings + return [ + { + description: 'double-quoted here-string', + input: joinAsWindowsLines( + '@"', + 'Lorem', + 'ipsum', + 'dolor sit amet', + '"@', + ), + expectedOutput: getInlinedOutputWithSemicolons( + joinLinesForDoubleQuotedString( + '"Lorem', + 'ipsum', + 'dolor sit amet"', + ), + ), + }, + { + description: 'single-quoted here-string', + input: joinAsWindowsLines( + '@\'', + 'Lorem', + 'ipsum', + 'dolor sit amet', + '\'@', + ), + expectedOutput: getInlinedOutputWithSemicolons( + joinLinesForSingleQuotedString( + 'Lorem', + 'ipsum', + 'dolor sit amet', + ), + ), + }, + { + description: 'preserves invalid here-string syntax', + input: joinAsWindowsLines( + '@" invalid syntax', + 'I will not be processed as here-string', + '"@', + ), + expectedOutput: getInlinedOutputWithSemicolons( + '@" invalid syntax', + 'I will not be processed as here-string', + '"@', + ), + }, + { + description: 'preserves here-string with character before terminator', + input: joinAsWindowsLines( + '@\'', + 'do not match here', + ' \'@', + 'character \'@', + ), + expectedOutput: getInlinedOutputWithSemicolons( + '@\'', + 'do not match here', + ' \'@', + 'character \'@', + ), + }, + { + description: 'preserves here-string with mismatched delimiters', + input: joinAsWindowsLines( + '@\'', + 'lorem', + '"@', + ), + expectedOutput: getInlinedOutputWithSemicolons( + '@\'', + 'lorem', + '"@', + ), + }, + { + description: 'single quoted here-string with nested single quoted here-string', + input: joinAsWindowsLines( + '$hasInnerDoubleQuotedTerminator = @"', + 'inner text', + '@\'', + 'inner terminator text', + '\'@', + '"@', + ), + expectedOutput: getInlinedOutputWithSemicolons( + joinLinesForDoubleQuotedString( + '$hasInnerDoubleQuotedTerminator = "inner text', + '@\'', + 'inner terminator text', + '\'@"', + ), + ), + }, + { + description: 'single quoted here-string with inner double-quoted string', + input: joinAsWindowsLines( + '$hasInnerSingleQuotedTerminator = @\'', + 'inner text', + '@"', + 'inner terminator text', + '"@', + '\'@', + ), + expectedOutput: getInlinedOutputWithSemicolons( + joinLinesForSingleQuotedString( + '$hasInnerSingleQuotedTerminator = \'inner text', + '@"', + 'inner terminator text', + '"@\'', + ), + ), + }, + { + description: 'here-string with character after terminator', + input: joinAsWindowsLines( + '@\'', + 'lorem', + '\'@ after', + ), + expectedOutput: getInlinedOutputWithSemicolons( + '\'lorem\' after', + ), + }, + { + description: 'escapes double quotes in double-quoted here-string', + input: joinAsWindowsLines( + '@"', + 'For help, type "get-help"', + '"@', + ), + expectedOutput: getInlinedOutputWithSemicolons( + '"For help, type `"get-help`""', + ), + }, + { + description: 'escapes single quotes in single-quoted here-string', + input: joinAsWindowsLines( + '@\'', + 'For help, type \'get-help\'', + '\'@', + ), + expectedOutput: getInlinedOutputWithSemicolons( + '\'For help, type \'\'get-help\'\'\'', + ), + }, + { + description: 'here-string not at line start', + input: joinAsWindowsLines( + '$page = [XML] @"', + 'multi-lined', + 'and "quoted"', + '"@', + ), + expectedOutput: getInlinedOutputWithSemicolons( + joinLinesForDoubleQuotedString( + '$page = [XML] "multi-lined', + 'and `"quoted`""', + ), + ), + }, + { + description: 'trims whitespace after here-string header', + input: joinAsWindowsLines( + '@" \t', + 'text with whitespaces at here-string start', + '"@', + ), + expectedOutput: getInlinedOutputWithSemicolons( + '"text with whitespaces at here-string start"', + ), + }, + { + description: 'preserves whitespace in here-string lines', + input: joinAsWindowsLines( + '@\'', + '\ttext with tabs around\t\t', + ' text with whitespace around ', + '\'@', + ), + expectedOutput: getInlinedOutputWithSemicolons( + joinLinesForSingleQuotedString( + '\'\ttext with tabs around\t\t', + ' text with whitespace around \'', + ), + ), + }, + { + description: 'preserves code inside here-string', + input: joinAsWindowsLines( // Triggering a some code inlining logic: + '@"', + 'if (', + ' $condition1 -and', + ' $condition2', + ') {', + ' Write-Host "True"', + ' Write-Warning "Not false"', + '} else', + '{', + ' Get-Process `', + ' | Where-Object { $_.CPU -gt 50 }', + '}', + '"@', + ), + expectedOutput: getInlinedOutputWithSemicolons( + joinLinesForDoubleQuotedString( // Identical to input + '"if (', + ' $condition1 -and', + ' $condition2', + ') {', + ' Write-Host `"True`"', + ' Write-Warning `"Not false`"', + '} else', + '{', + ' Get-Process `', + ' | Where-Object { $_.CPU -gt 50 }', + '}"', + ), + ), + }, + ]; +} + +function joinLinesForDoubleQuotedString( + ...lines: readonly string[] +): string { + return lines.join('`r`n'); +} + +function joinLinesForSingleQuotedString( + ...lines: readonly string[] +): string { + return lines.join('\'+"`r`n"+\''); +} diff --git a/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateIfStatementTests.ts b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateIfStatementTests.ts new file mode 100644 index 00000000..6b60fa47 --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateIfStatementTests.ts @@ -0,0 +1,426 @@ +import { RegexBuilder, type PipeTestScenario } from '../PipeTestRunner'; +import { joinAsWindowsLines } from './CommonInlinePowerShellTestUtilities'; + +export function createIfStatementTests(): PipeTestScenario[] { + // https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_if?view=powershell-7.4 + return [ + { + description: 'if/else statement without newlines', + input: 'if ($condition) { Write-Host "True" } else { Write-Host "False" }', + expectedOutput: 'if ($condition) { Write-Host "True" } else { Write-Host "False" }', + }, + { + description: 'simple if statement (single line inside code block)', + input: joinAsWindowsLines( + 'if ($true) {', + ' Write-Host "True"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('if') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($true)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "True"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .buildRegex(), + }, + { + description: 'if/else statement', + input: joinAsWindowsLines( + 'if ($condition) {', + ' Write-Host "True"', + '} else {', + ' Write-Host "False"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('if') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($condition)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "True"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('else') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "False"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .buildRegex(), + }, + { + description: 'if/elseif/else statement', + input: joinAsWindowsLines( + 'if ($condition1) {', + ' Write-Host "Condition 1"', + '} elseif ($condition2) {', + ' Write-Host "Condition 2"', + '} else {', + ' Write-Host "None"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('if') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($condition1)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Condition 1"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('elseif') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($condition2)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Condition 2"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('else') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "None"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .buildRegex(), + }, + { + description: 'if statement with multiple lines', + input: joinAsWindowsLines( + 'if ($condition) {', + ' $result = 10 * 5', + ' Write-Host "Calculation done"', + ' $finalResult = $result + 20', + ' Write-Host "Final result: $finalResult"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('if') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($condition)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$result = 10 * 5') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Calculation done"') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$finalResult = $result + 20') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Final result: $finalResult"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .buildRegex(), + }, + { + description: 'nested if statements', + input: joinAsWindowsLines( + 'if ($outerCondition) {', + ' $outerResult = 10', + ' if ($innerCondition1) {', + ' Write-Host "Inner condition 1 met"', + ' } elseif ($innerCondition2) {', + ' Write-Host "Inner condition 2 met"', + ' } else {', + ' Write-Host "No inner conditions met"', + ' }', + ' Write-Host "Outer condition processing complete"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('if') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($outerCondition)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$outerResult = 10') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('if') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($innerCondition1)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Inner condition 1 met"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('elseif') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($innerCondition2)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Inner condition 2 met"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('else') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "No inner conditions met"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Outer condition processing complete"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'if/elseif/else with closing brackets on separate lines', + input: joinAsWindowsLines( + 'if ($condition1) {', + ' Write-Host "Condition 1"', + '}', + 'elseif ($condition2) {', + ' Write-Host "Condition 2"', + '}', + 'else {', + ' Write-Host "No condition met"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('if') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($condition1)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Condition 1"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') // No semicolon after this to prevent runtime errors + .withOptionalWhitespaceButNoNewline() + .withLiteralString('elseif') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($condition2)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Condition 2"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') // No semicolon after this to prevent runtime errors + .withOptionalWhitespaceButNoNewline() + .withLiteralString('else') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "No condition met"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'if statement without space before opening parenthesis', + input: joinAsWindowsLines( + 'if($condition) {', + ' Write-Host "True"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('if($condition)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "True"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .buildRegex(), + }, + { + description: 'if statement without space after closing parenthesis', + input: joinAsWindowsLines( + 'if ($condition){', + ' Write-Host "True"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('if ($condition)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "True"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'if statement with extra spaces in condition', + input: joinAsWindowsLines( + 'if ( $condition ) {', + ' Write-Host "True"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('if') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('(') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$condition') + .withOptionalWhitespaceButNoNewline() + .withLiteralString(')') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "True"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .buildRegex(), + }, + { + description: 'if statement with multiline condition', + input: joinAsWindowsLines( + 'if ( `', // Marked: inline-conditions | Merging multiline conditions is not yet supported, so using backticks + ' $condition1 -and `', + ' $condition2 `', + ') {', + ' Write-Host "True"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('if') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('(') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$condition1 -and') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$condition2') + .withOptionalWhitespaceButNoNewline() + .withLiteralString(')') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "True"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .buildRegex(), + }, + { + description: 'if/elseif/else with mixed brace styles', + input: joinAsWindowsLines( + 'if ($condition1) {', + ' Write-Host "Condition 1"', + '} elseif ($condition2)', + '{', + ' Write-Host "Condition 2"', + '} else {', + ' Write-Host "None"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('if') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($condition1)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Condition 1"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('elseif') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($condition2)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Condition 2"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('else') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "None"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .buildRegex(), + }, + { + description: 'if statement with pipeline in condition', + input: joinAsWindowsLines( + 'if (Get-Process | Where-Object { $_.CPU -gt 50 }) {', + ' Write-Host "High CPU usage detected"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('if') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('(Get-Process |') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Where-Object { $_.CPU -gt 50 })') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "High CPU usage detected"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .buildRegex(), + }, + { + description: 'if statement after closing bracket', + input: joinAsWindowsLines( + '$testArray = @(1, 2, 3, 4, 5)', + '$result = $testArray | ForEach-Object { $_ * 2 }', + 'if ($result.Count -gt 0) { Write-Host "Array has items" }', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('$testArray = @(1, 2, 3, 4, 5)') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$result = $testArray | ForEach-Object { $_ * 2 }') + .withSemicolon() // Semicolon here to prevent runtime errors + .withOptionalWhitespaceButNoNewline() + .withLiteralString('if ($result.Count -gt 0) { Write-Host "Array has items" }') + .withOptionalSemicolon() + .buildRegex(), + }, + ]; +} diff --git a/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateLineContinuationBacktickTests.ts b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateLineContinuationBacktickTests.ts new file mode 100644 index 00000000..699a0a64 --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateLineContinuationBacktickTests.ts @@ -0,0 +1,61 @@ +import { getInlinedOutputWithSemicolons, joinAsWindowsLines } from './CommonInlinePowerShellTestUtilities'; +import type { PipeTestScenario } from '../PipeTestRunner'; + +export function createLineContinuationBacktickCases(): PipeTestScenario[] { + // https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_parsing?view=powershell-7.4#line-continuation + return [ + { + description: 'inlines newlines with trailing backtick', + input: joinAsWindowsLines( + 'Get-Service * `', + '| Format-Table -AutoSize', + ), + expectedOutput: getInlinedOutputWithSemicolons( + 'Get-Service * | Format-Table -AutoSize', + ), + }, + { + description: 'inlines newlines with trailing backtick and different line endings', + input: 'Get-Service `\n' + + '* `\r' + + '| Sort-Object StartType `\r\n' + + '| Format-Table -AutoSize', + expectedOutput: getInlinedOutputWithSemicolons( + 'Get-Service * | Sort-Object StartType | Format-Table -AutoSize', + ), + }, + { + description: 'trims whitespace when inlining with trailing backtick', + input: joinAsWindowsLines( + 'Get-Service * `', + '\t| Sort-Object StartType `', + ' | Format-Table -AutoSize', + ), + expectedOutput: getInlinedOutputWithSemicolons( + 'Get-Service * | Sort-Object StartType | Format-Table -AutoSize', + ), + }, + { + description: 'preserves line without whitespace before backtick', + input: joinAsWindowsLines( + 'Get-Service *`', + '| Format-Table -AutoSize', + ), + expectedOutput: getInlinedOutputWithSemicolons( + 'Get-Service *`', + '| Format-Table -AutoSize', + ), + }, + { + description: 'preserves line with characters after backtick', + input: joinAsWindowsLines( + 'line start ` after', + 'should not be wrapped', + ), + expectedOutput: getInlinedOutputWithSemicolons( + 'line start ` after', + 'should not be wrapped', + ), + }, + ]; +} diff --git a/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateNewlineTests.ts b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateNewlineTests.ts new file mode 100644 index 00000000..7db6142d --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateNewlineTests.ts @@ -0,0 +1,103 @@ +import { getInlinedOutputWithSemicolons, joinAsWindowsLines } from './CommonInlinePowerShellTestUtilities'; +import type { PipeTestScenario } from '../PipeTestRunner'; + +export function createNewlineTests(): PipeTestScenario[] { + return [ + { + description: 'does not add semicolon to single line input', + input: 'Write-Host "Single line input"', + expectedOutput: 'Write-Host "Single line input"', + }, + { + description: 'does not add semicolon to last line of multiline input', + input: joinAsWindowsLines( + 'Write-Host "First line"', + 'Write-Host "Second line"', + ), + expectedOutput: 'Write-Host "First line"; Write-Host "Second line"', + }, + { + description: 'preserves existing semicolons', + input: joinAsWindowsLines( + 'line-without-semicolon', + 'line-with-semicolon;', + 'ending-line', + ), + expectedOutput: getInlinedOutputWithSemicolons( + 'line-without-semicolon', + 'line-with-semicolon', + 'ending-line', + ), + }, + { + description: 'inlines code with \\n newlines', + input: + '$things = Get-ChildItem C:\\Windows\\' + + '\nforeach ($thing in $things) {' + + '\nWrite-Host $thing.Name -ForegroundColor Magenta' + + '\n}', + expectedOutput: getInlinedOutputWithSemicolons( + '$things = Get-ChildItem C:\\Windows\\', + 'foreach ($thing in $things) {', + 'Write-Host $thing.Name -ForegroundColor Magenta', + '}', + ), + }, + { + description: 'removes empty lines', + input: + '$things = Get-ChildItem C:\\Windows\\' + + '\n\nforeach ($thing in $things) {' + + '\n\nWrite-Host $thing.Name -ForegroundColor Magenta' + + '\n\n\n}', + expectedOutput: getInlinedOutputWithSemicolons( + '$things = Get-ChildItem C:\\Windows\\', + 'foreach ($thing in $things) {', + 'Write-Host $thing.Name -ForegroundColor Magenta', + '}', + ), + }, + { + description: 'inlines code with \\r newlines', + input: + '$things = Get-ChildItem C:\\Windows\\' + + '\rforeach ($thing in $things) {' + + '\rWrite-Host $thing.Name -ForegroundColor Magenta' + + '\r}', + expectedOutput: getInlinedOutputWithSemicolons( + '$things = Get-ChildItem C:\\Windows\\', + 'foreach ($thing in $things) {', + 'Write-Host $thing.Name -ForegroundColor Magenta', + '}', + ), + }, + { + description: 'inlines code with mixed newline types', + input: + '$things = Get-ChildItem C:\\Windows\\' + + '\r\nforeach ($thing in $things) {' + + '\n\rWrite-Host $thing.Name -ForegroundColor Magenta' + + '\n\r}', + expectedOutput: getInlinedOutputWithSemicolons( + '$things = Get-ChildItem C:\\Windows\\', + 'foreach ($thing in $things) {', + 'Write-Host $thing.Name -ForegroundColor Magenta', + '}', + ), + }, + { + description: 'trims whitespace from lines', + input: + ' $things = Get-ChildItem C:\\Windows\\ ' + + '\nforeach ($thing in $things) {' + + '\n\tWrite-Host $thing.Name -ForegroundColor Magenta' + + '\r \n}', + expectedOutput: getInlinedOutputWithSemicolons( + '$things = Get-ChildItem C:\\Windows\\', + 'foreach ($thing in $things) {', + 'Write-Host $thing.Name -ForegroundColor Magenta', + '}', + ), + }, + ]; +} diff --git a/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateScriptBlockTests.ts b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateScriptBlockTests.ts new file mode 100644 index 00000000..d8be212c --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateScriptBlockTests.ts @@ -0,0 +1,255 @@ +import { RegexBuilder, type PipeTestScenario } from '../PipeTestRunner'; +import { joinAsWindowsLines } from './CommonInlinePowerShellTestUtilities'; + +export function createScriptBlockTests(): PipeTestScenario[] { + // https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_script_blocks%3Fview=powershell-7.4 + return [ + { + description: 'simple script block without newlines', + input: '{ Write-Host "Hello, World!" }', + expectedOutput: '{ Write-Host "Hello, World!" }', + }, + { + description: 'multiline script block', + input: joinAsWindowsLines( + '{', + ' $result = 10 * 5', + ' Write-Host "The result is: $result"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$result = 10 * 5') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "The result is: $result"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .buildRegex(), + }, + { + description: 'script block with parameters', + input: joinAsWindowsLines( + '{', + ' param($p1, $p2)', + ' Write-Host "p1: $p1, p2: $p2"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('param($p1, $p2)') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "p1: $p1, p2: $p2"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .buildRegex(), + }, + { + description: 'script block with typed parameters', + input: joinAsWindowsLines( + '{', + ' param([int]$p1, [string]$p2)', + ' Write-Host "p1: $p1, p2: $p2"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('param([int]$p1, [string]$p2)') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "p1: $p1, p2: $p2"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .buildRegex(), + }, + { + description: 'script block with begin, process, and end blocks', + input: joinAsWindowsLines( + '{', + ' begin { $total = 0 }', + ' process { $total += $_ }', + ' end { Write-Host "Total: $total" }', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('begin') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$total = 0') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('process') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$total += $_') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('end') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Total: $total"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'nested script blocks', + input: joinAsWindowsLines( + '{', + ' $innerBlock = {', + ' param($x)', + ' Write-Host "Inner: $x"', + ' }', + ' & $innerBlock -x "Hello"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$innerBlock =') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('param($x)') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Inner: $x"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('& $innerBlock -x "Hello"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .buildRegex(), + }, + { + description: 'script block with return statement', + input: joinAsWindowsLines( + '{', + ' $result = 10 * 5', + ' return $result', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$result = 10 * 5') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('return $result') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .buildRegex(), + }, + { + description: 'script block with pipeline input', + input: joinAsWindowsLines( + '{', + ' process {', + ' $_ | Where-Object { $_ % 2 -eq 0 }', + ' }', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('process') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$_ | Where-Object { $_ % 2 -eq 0 }') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'script block with dynamicparam block', + input: joinAsWindowsLines( + '{', + ' dynamicparam {', + ' $paramDictionary = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary', + ' return $paramDictionary', + ' }', + ' process {', + ' Write-Host "Processing"', + ' }', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('dynamicparam') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$paramDictionary = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('return $paramDictionary') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('process') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Processing"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'script block after closing bracket', + input: joinAsWindowsLines( + 'if ($condition) { $value = 10 }', + '$scriptBlock = { param($x) Write-Host $x }', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('if ($condition) { $value = 10 }') + .withSemicolon() // Semicolon here to prevent runtime errors + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$scriptBlock = { param($x) Write-Host $x }') + .withOptionalSemicolon() + .buildRegex(), + }, + ]; +} diff --git a/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateSwitchTests.ts b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateSwitchTests.ts new file mode 100644 index 00000000..9357006d --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateSwitchTests.ts @@ -0,0 +1,330 @@ +import { RegexBuilder, type PipeTestScenario } from '../PipeTestRunner'; +import { joinAsWindowsLines } from './CommonInlinePowerShellTestUtilities'; + +export function createSwitchTests(): PipeTestScenario[] { + // https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_switch?view=powershell-7.4 + return [ + { + description: 'switch statement with no newlines', + input: 'switch ($value) { 1 { Write-Host "One" }; 2 { Write-Host "Two" }; default { Write-Host "Other" } }', + expectedOutput: 'switch ($value) { 1 { Write-Host "One" }; 2 { Write-Host "Two" }; default { Write-Host "Other" } }', + }, + { + description: 'simple switch statement (single line inside code block)', + input: joinAsWindowsLines( + 'switch ($value) {', + ' 1 { Write-Host "One" }', + ' 2 { Write-Host "Two" }', + ' default { Write-Host "Other" }', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('switch') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($value)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('1') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "One"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('2') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Two"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('default') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Other"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'multiline switch statement', + input: joinAsWindowsLines( + 'switch ($value) {', + ' 1 {', + ' Write-Host "One"', + ' $result = 1', + ' }', + ' 2 {', + ' Write-Host "Two"', + ' $result = 2', + ' }', + ' default {', + ' Write-Host "Other"', + ' $result = 0', + ' }', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('switch') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($value)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('1') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "One"') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$result = 1') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('2') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Two"') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$result = 2') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('default') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Other"') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$result = 0') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'nested switch statements', + input: joinAsWindowsLines( + 'switch ($outer) {', + ' 1 {', + ' switch ($inner) {', + ' "A" { Write-Host "1A" }', + ' "B" { Write-Host "1B" }', + ' }', + ' }', + ' 2 {', + ' switch ($inner) {', + ' "A" { Write-Host "2A" }', + ' "B" { Write-Host "2B" }', + ' }', + ' }', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('switch') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($outer)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('1') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('switch') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($inner)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('"A"') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "1A"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('"B"') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "1B"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('2') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('switch') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($inner)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('"A"') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "2A"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('"B"') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "2B"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'switch statement with no spaces', + input: joinAsWindowsLines( + 'switch($value){1{Write-Host"One"}2{Write-Host"Two"}default{Write-Host"Other"}}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('switch') + .withLiteralString('($value)') + .withLiteralString('{') + .withLiteralString('1') + .withLiteralString('{') + .withLiteralString('Write-Host"One"') + .withOptionalSemicolon() + .withLiteralString('}') + .withLiteralString('2') + .withLiteralString('{') + .withLiteralString('Write-Host"Two"') + .withOptionalSemicolon() + .withLiteralString('}') + .withLiteralString('default') + .withLiteralString('{') + .withLiteralString('Write-Host"Other"') + .withOptionalSemicolon() + .withLiteralString('}') + .withLiteralString('}') + .buildRegex(), + }, + { + description: 'switch statement with regex matches', + input: joinAsWindowsLines( + 'switch -Regex ($value) {', + ' "^A.*" { Write-Host "Starts with A" }', + ' ".*Z$" { Write-Host "Ends with Z" }', + ' Default { Write-Host "No match" }', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('switch') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('-Regex') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($value)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('"^A.*"') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Starts with A"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('".*Z$"') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Ends with Z"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Default') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "No match"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .buildRegex(), + }, + { + description: 'switch after closing bracket', + input: joinAsWindowsLines( + 'if ($condition) { $value = 10 }', + 'switch ($value) { 1 { "One" } 2 { "Two" } default { "Other" } }', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('if ($condition) { $value = 10 }') + .withSemicolon() // Semicolon here to prevent runtime errors + .withOptionalWhitespaceButNoNewline() + .withLiteralString('switch ($value) { 1 { "One" } 2 { "Two" } default { "Other" } }') + .withOptionalSemicolon() + .buildRegex(), + }, + ]; +} diff --git a/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateTryCatchFinallyTests.ts b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateTryCatchFinallyTests.ts new file mode 100644 index 00000000..497c2079 --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateTryCatchFinallyTests.ts @@ -0,0 +1,451 @@ +import { RegexBuilder, type PipeTestScenario } from '../PipeTestRunner'; +import { getInlinedOutputWithSemicolons, joinAsWindowsLines } from './CommonInlinePowerShellTestUtilities'; + +export function createTryCatchFinallyTests(): PipeTestScenario[] { + // https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_try_catch_finally?view=powershell-7.4 + return [ + { + description: 'try/catch/finally block without newlines', + input: 'try { $result = 10 / 0 } catch { Write-Host "An error occurred" } finally { Write-Host "Cleanup" }', + expectedOutput: 'try { $result = 10 / 0 } catch { Write-Host "An error occurred" } finally { Write-Host "Cleanup" }', + }, + { + description: 'simple try/catch block (single line inside code blocks)', + input: joinAsWindowsLines( + 'try {', + ' $result = 10 / 0', + '} catch {', + ' Write-Warning "An error occurred"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('try') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$result = 10 / 0') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('catch {') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Warning "An error occurred"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'multiline try/catch block', + input: joinAsWindowsLines( + 'try {', + ' $result = 10 / 0', + ' Write-Host "Succesfully completed"', + '} catch {', + ' Write-Warning "An error occurred"', + ' Write-Host "Wrong division?"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('try') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$result = 10 / 0') + .withSemicolon() // Ensure it adds semicolon to multiline text + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Succesfully completed"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('catch {') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Warning "An error occurred"') + .withSemicolon() // Ensure it adds semicolon to multiline text + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Wrong division?"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'try/finally block', + input: joinAsWindowsLines( + 'try {', + ' $result = 10 / 0', + '} finally {', + ' Write-Warning "An error occurred"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('try') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$result = 10 / 0') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('finally {') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Warning "An error occurred"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'try/catch/finally block', + input: joinAsWindowsLines( + 'try {', + ' $result = 10 / 0', + '} catch {', + ' Write-Host "An error occurred"', + '} finally {', + ' Write-Host "Cleanup"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('try') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$result = 10 / 0') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('catch {') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "An error occurred"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('finally {') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Cleanup"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'try/catch/finally with closing brackets on separate lines', + input: joinAsWindowsLines( + 'try {', + ' $result = 10 / 0', + '}', + 'catch {', + ' Write-Host "An error occurred"', + '}', + 'finally {', + ' Write-Host "Cleanup"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('try') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$result = 10 / 0') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() // No semicolon after this to prevent runtime errors + .withLiteralString('catch') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "An error occurred"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() // No semicolon after this to prevent runtime errors + .withLiteralString('finally') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Cleanup"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'try/catch with empty catch block', + input: joinAsWindowsLines( + 'try {', + ' $result = 10 / 0', + '} catch {}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('try') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$result = 10 / 0') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('catch {}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: '/catch/finally with empty blocks', + input: joinAsWindowsLines( + 'try {} catch {} finally {}', + ), + expectedOutput: getInlinedOutputWithSemicolons( + 'try {} catch {} finally {}', + ), + }, + { + description: 'try/catch with specific exception type', + input: joinAsWindowsLines( + 'try {', + ' throw [System.DivideByZeroException]::new()', + '} catch [System.DivideByZeroException] {', + ' Write-Host "Caught divide by zero exception"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('try') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('throw [System.DivideByZeroException]::new()') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('catch [System.DivideByZeroException]') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Caught divide by zero exception"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'try/catch with multiple specific exception types', + input: joinAsWindowsLines( + 'try {', + ' throw [System.IO.FileNotFoundException]::new()', + '} catch [System.IO.FileNotFoundException], [System.IO.DirectoryNotFoundException] {', + ' Write-Host "Caught file or directory not found exception"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('try') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('throw [System.IO.FileNotFoundException]::new()') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('catch [System.IO.FileNotFoundException], [System.IO.DirectoryNotFoundException]') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Caught file or directory not found exception"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'try/catch with multiple catch blocks', + input: joinAsWindowsLines( + 'try {', + ' $result = 10 / 0', + '} catch [System.DivideByZeroException] {', + ' Write-Host "Caught divide by zero exception"', + '} catch [System.Exception] {', + ' Write-Host "Caught general exception"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('try') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$result = 10 / 0') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('catch [System.DivideByZeroException]') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Caught divide by zero exception"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('catch [System.Exception]') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Caught general exception"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'try/catch with exception variable', + input: joinAsWindowsLines( + 'try {', + ' throw "Custom error"', + '} catch {', + ' Write-Host "Error: $($_.Exception.Message)"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('try') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('throw "Custom error"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('catch') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Error: $($_.Exception.Message)"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'try/catch/finally with return in try block', + input: joinAsWindowsLines( + 'try {', + ' return "Success"', + '} catch {', + ' Write-Host "An error occurred"', + '} finally {', + ' Write-Host "Cleanup"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('try') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('return "Success"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('catch') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "An error occurred"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('finally') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Cleanup"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'nested try/catch blocks', + input: joinAsWindowsLines( + 'try {', + ' try {', + ' throw "Inner exception"', + ' } catch {', + ' throw "Outer exception"', + ' }', + '} catch {', + ' Write-Host "Caught in outer catch: $($_.Exception.Message)"', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('try') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('try') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('throw "Inner exception"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('catch') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('throw "Outer exception"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('catch') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Caught in outer catch: $($_.Exception.Message)"') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'try after closing bracket', + input: joinAsWindowsLines( + 'if ($condition) { $value = 10 }', + 'try { $result = 10 / $value }', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('if ($condition) { $value = 10 }') + .withSemicolon() // Semicolon here to prevent runtime errors + .withOptionalWhitespaceButNoNewline() + .withLiteralString('try { $result = 10 / $value }') + .withOptionalSemicolon() + .buildRegex(), + }, + ]; +} diff --git a/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateWhileTests.ts b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateWhileTests.ts new file mode 100644 index 00000000..c0010175 --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShellTests/CreateWhileTests.ts @@ -0,0 +1,222 @@ +import { RegexBuilder, type PipeTestScenario } from '../PipeTestRunner'; +import { joinAsWindowsLines } from './CommonInlinePowerShellTestUtilities'; + +export function createWhileTests(): PipeTestScenario[] { + return [ + { + description: 'while loop without newlines', + input: 'while ($val -ne 3) { $val++ Write-Host $val }', + expectedOutput: 'while ($val -ne 3) { $val++ Write-Host $val }', + }, + { + description: 'simple while loop (single line inside code block)', + input: joinAsWindowsLines( + 'while ($i -lt 5) {', + ' $i++', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('while') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($i -lt 5)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$i++') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'multiline while loop', + input: joinAsWindowsLines( + 'while ($condition) {', + ' Do-Something', + ' Write-Host "Processing..."', + ' $condition = Test-Condition', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('while') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($condition)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Do-Something') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host "Processing..."') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$condition = Test-Condition') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'nested while loops', + input: joinAsWindowsLines( + 'while ($outerCondition) {', + ' $innerCounter = 0', + ' while ($innerCounter -lt 3) {', + ' Do-InnerTask', + ' $innerCounter++', + ' }', + ' Do-OuterTask', + ' $outerCondition = Test-OuterCondition', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('while') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($outerCondition)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$innerCounter = 0') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('while') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($innerCounter -lt 3)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Do-InnerTask') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$innerCounter++') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Do-OuterTask') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$outerCondition = Test-OuterCondition') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'while loop without space before opening parenthesis', + input: joinAsWindowsLines( + 'while($val -ne 3) {', + ' $val++', + ' Write-Host $val', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('while') + .withLiteralString('($val -ne 3)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$val++') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host $val') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'while loop without space after closing parenthesis', + input: joinAsWindowsLines( + 'while ($val -ne 3){', + ' $val++', + ' Write-Host $val', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('while') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($val -ne 3)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$val++') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host $val') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'while loop and trims extra spaces before statements', + input: joinAsWindowsLines( + 'while ($val -ne 3) {', + ' $val++', + ' Write-Host $val', + '}', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('while') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($val -ne 3)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$val++') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host $val') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'while loop with closing brace on same line as last statement', + input: joinAsWindowsLines( + 'while ($val -ne 3) {', + ' $val++', + ' Write-Host $val }', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('while') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('($val -ne 3)') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('{') + .withOptionalWhitespaceButNoNewline() + .withLiteralString('$val++') + .withSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('Write-Host $val') + .withOptionalSemicolon() + .withOptionalWhitespaceButNoNewline() + .withLiteralString('}') + .withOptionalSemicolon() + .buildRegex(), + }, + { + description: 'while after closing bracket', + input: joinAsWindowsLines( + 'try { throw "Error" } catch { Write-Host "Caught error" }', + 'while ($true) { break }', + ), + expectedOutput: new RegexBuilder() + .withLiteralString('try { throw "Error" } catch { Write-Host "Caught error" }') + .withSemicolon() // Semicolon here to prevent runtime errors + .withOptionalWhitespaceButNoNewline() + .withLiteralString('while ($true) { break }') + .withOptionalSemicolon() + .buildRegex(), + }, + ]; +} diff --git a/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/PipeTestRunner.ts b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/PipeTestRunner.ts index 8a4349fc..ff539054 100644 --- a/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/PipeTestRunner.ts +++ b/tests/unit/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/PipeTestRunner.ts @@ -1,19 +1,71 @@ import { it, expect } from 'vitest'; -import type { IPipe } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/IPipe'; +import type { Pipe } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/Pipe'; +import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; +import { indentText } from '@/application/Common/Text/IndentText'; -export interface IPipeTestCase { - readonly name: string; +export interface PipeTestScenario { + readonly description: string; readonly input: string; - readonly expectedOutput: string; + readonly expectedOutput: RegExp | string; } -export function runPipeTests(sut: IPipe, testCases: IPipeTestCase[]) { - for (const testCase of testCases) { - it(testCase.name, () => { +export function runPipeTests( + pipe: Pipe, + testScenarios: readonly PipeTestScenario[], +) { + testScenarios.forEach(( + { input, description, expectedOutput: expectedInlinedOutput }, + ) => { + it(description, () => { // act - const actual = sut.apply(testCase.input); + const actualOutput = pipe.apply(input); // assert - expect(actual).to.equal(testCase.expectedOutput); + if (typeof expectedInlinedOutput === 'string') { + expect(actualOutput).to.equal(expectedInlinedOutput); + } else { + expect(actualOutput).to.match(expectedInlinedOutput, formatAssertionMessage([ + 'Regex did not match the output.', + 'Expected regex pattern:', + indentText(expectedInlinedOutput.toString()), + 'Actual output:', + indentText(actualOutput), + 'Given input:', + indentText(input), + ])); + } }); + }); +} + +export class RegexBuilder { + private rawRegex: string = ''; + + public withLiteralString(string: string): this { + this.rawRegex += string.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&'); + return this; + } + + public withSomeWhitespaceButNoNewLine(): this { + this.rawRegex += '[ \\t\\f]+'; + return this; + } + + public withOptionalWhitespaceButNoNewline(): this { + this.rawRegex += '[ \\t\\f]*'; + return this; + } + + public withOptionalSemicolon(): this { + this.rawRegex += ';?'; + return this; + } + + public withSemicolon(): this { + this.rawRegex += ';'; + return this; + } + + public buildRegex(): RegExp { + return new RegExp(this.rawRegex, 'g'); } } diff --git a/tests/unit/shared/Stubs/PipeFactoryStub.ts b/tests/unit/shared/Stubs/PipeFactoryStub.ts index 6bf550b1..d1cb1564 100644 --- a/tests/unit/shared/Stubs/PipeFactoryStub.ts +++ b/tests/unit/shared/Stubs/PipeFactoryStub.ts @@ -1,10 +1,10 @@ -import type { IPipe } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/IPipe'; +import type { Pipe } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/Pipe'; import type { IPipeFactory } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeFactory'; export class PipeFactoryStub implements IPipeFactory { - private readonly pipes = new Array(); + private readonly pipes = new Array(); - public get(pipeName: string): IPipe { + public get(pipeName: string): Pipe { const result = this.pipes.find((pipe) => pipe.name === pipeName); if (!result) { throw new Error(`pipe not registered: "${pipeName}"`); @@ -12,12 +12,12 @@ export class PipeFactoryStub implements IPipeFactory { return result; } - public withPipe(pipe: IPipe) { + public withPipe(pipe: Pipe) { this.pipes.push(pipe); return this; } - public withPipes(pipes: IPipe[]) { + public withPipes(pipes: Pipe[]) { for (const pipe of pipes) { this.withPipe(pipe); } diff --git a/tests/unit/shared/Stubs/PipeStub.ts b/tests/unit/shared/Stubs/PipeStub.ts index 5981b64a..b97cc804 100644 --- a/tests/unit/shared/Stubs/PipeStub.ts +++ b/tests/unit/shared/Stubs/PipeStub.ts @@ -1,6 +1,6 @@ -import type { IPipe } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/IPipe'; +import type { Pipe } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/Pipe'; -export class PipeStub implements IPipe { +export class PipeStub implements Pipe { public name = 'pipeStub'; public apply(raw: string): string {