From dc5c87376b9d9796e21a137e5cd7b07f78a67180 Mon Sep 17 00:00:00 2001 From: undergroundwires Date: Tue, 27 Aug 2024 11:32:52 +0200 Subject: [PATCH] Add validation for max line length in compiler This commit adds validation logic in compiler to check for max allowed characters per line for scripts. This allows preventing bugs caused by limitation of terminal emulators. Other supporting changes: - Rename/refactor related code for clarity and better maintainability. - Drop `I` prefix from interfaces to align with latest convention. - Refactor CodeValidator to be functional rather than object-oriented for simplicity. - Refactor syntax definition construction to be functional and be part of rule for better separation of concerns. - Refactored validation logic to use an enum-based factory pattern for improved maintainability and scalability. --- .../Parser/CategoryCollectionParser.ts | 10 +- .../Executable/CategoryCollectionContext.ts | 33 ++ .../CategoryCollectionSpecificUtilities.ts | 35 -- .../Parser/Executable/CategoryParser.ts | 18 +- .../Function/SharedFunctionsParser.ts | 36 +- .../Script/Compiler/IScriptCompiler.ts | 7 - .../Script/Compiler/ScriptCompiler.ts | 87 +---- .../Script/Compiler/ScriptCompilerFactory.ts | 119 ++++++ .../Parser/Executable/Script/ScriptParser.ts | 43 ++- .../Analyzers/AnalyzeDuplicateLines.ts | 63 +++ .../Validation/Analyzers/AnalyzeEmptyLines.ts | 24 ++ .../Analyzers/AnalyzeTooLongLines.ts | 44 +++ .../Analyzers/CodeValidationAnalyzer.ts | 18 + .../{ => Analyzers}/Syntax/BatchFileSyntax.ts | 4 +- .../Analyzers/Syntax/LanguageSyntax.ts | 4 + .../Analyzers/Syntax/ShellScriptSyntax.ts | 7 + .../Analyzers/Syntax/SyntaxFactory.ts | 19 + .../Script/Validation/CodeValidationRule.ts | 5 + .../Script/Validation/CodeValidator.ts | 68 ++-- .../Executable/Script/Validation/ICodeLine.ts | 4 - .../Script/Validation/ICodeValidationRule.ts | 10 - .../Script/Validation/ICodeValidator.ts | 8 - .../Validation/Rules/NoDuplicatedLines.ts | 45 --- .../Script/Validation/Rules/NoEmptyLines.ts | 21 - .../Validation/Syntax/ILanguageSyntax.ts | 4 - .../Validation/Syntax/ISyntaxFactory.ts | 4 - .../Validation/Syntax/ShellScriptSyntax.ts | 7 - .../Script/Validation/Syntax/SyntaxFactory.ts | 16 - .../ValidationRuleAnalyzerFactory.ts | 47 +++ .../CompositeCategoryCollectionValidator.ts | 2 +- .../Parser/CategoryCollectionParser.spec.ts | 67 ++-- .../CategoryCollectionContext.spec.ts | 102 +++++ ...ategoryCollectionSpecificUtilities.spec.ts | 103 ----- .../Parser/Executable/CategoryParser.spec.ts | 29 +- .../Function/SharedFunctionsParser.spec.ts | 84 ++-- .../Script/Compiler/ScriptCompiler.spec.ts | 332 ---------------- .../Compiler/ScriptCompilerFactory.spec.ts | 365 ++++++++++++++++++ .../Executable/Script/ScriptParser.spec.ts | 102 +++-- .../Analyzers/AnalyzeDuplicateLines.spec.ts | 196 ++++++++++ .../Analyzers/AnalyzeEmptyLines.spec.ts | 115 ++++++ .../Analyzers/AnalyzeTooLongLines.spec.ts | 184 +++++++++ .../Validation/Analyzers/CreateCodeLines.ts | 10 + .../Analyzers/ExpectSameInvalidCodeLines.ts | 13 + .../Analyzers/Syntax/BatchFileSyntax.spec.ts | 9 + .../Syntax/LanguageSyntaxTestRunner.ts | 25 ++ .../Syntax/ShellScriptSyntax.spec.ts | 9 + .../Analyzers/Syntax/SyntaxFactory.spec.ts | 38 ++ .../Script/Validation/CodeValidator.spec.ts | 362 +++++++++-------- .../Rules/CodeValidationRuleTestRunner.ts | 36 -- .../Rules/NoDuplicatedLines.spec.ts | 78 ---- .../Validation/Rules/NoEmptyLines.spec.ts | 46 --- .../Syntax/ConcreteSyntaxes.spec.ts | 31 -- .../Validation/Syntax/SyntaxFactory.spec.ts | 14 - .../ValidationRuleAnalyzerFactory.spec.ts | 124 ++++++ .../Stubs/CategoryCollectionContextStub.ts | 21 + ...CategoryCollectionSpecificUtilitiesStub.ts | 22 -- tests/unit/shared/Stubs/CategoryParserStub.ts | 6 +- .../Stubs/CodeValidationAnalyzerStub.ts | 23 ++ .../shared/Stubs/CodeValidationRuleStub.ts | 18 - tests/unit/shared/Stubs/CodeValidatorStub.ts | 110 ++++-- tests/unit/shared/Stubs/LanguageSyntaxStub.ts | 4 +- .../shared/Stubs/ScriptCompilerFactoryStub.ts | 20 + tests/unit/shared/Stubs/ScriptCompilerStub.ts | 4 +- .../shared/Stubs/SharedFunctionsParserStub.ts | 8 +- tests/unit/shared/Stubs/SyntaxFactoryStub.ts | 41 +- 65 files changed, 2215 insertions(+), 1348 deletions(-) create mode 100644 src/application/Parser/Executable/CategoryCollectionContext.ts delete mode 100644 src/application/Parser/Executable/CategoryCollectionSpecificUtilities.ts delete mode 100644 src/application/Parser/Executable/Script/Compiler/IScriptCompiler.ts create mode 100644 src/application/Parser/Executable/Script/Compiler/ScriptCompilerFactory.ts create mode 100644 src/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeDuplicateLines.ts create mode 100644 src/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeEmptyLines.ts create mode 100644 src/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeTooLongLines.ts create mode 100644 src/application/Parser/Executable/Script/Validation/Analyzers/CodeValidationAnalyzer.ts rename src/application/Parser/Executable/Script/Validation/{ => Analyzers}/Syntax/BatchFileSyntax.ts (59%) create mode 100644 src/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax.ts create mode 100644 src/application/Parser/Executable/Script/Validation/Analyzers/Syntax/ShellScriptSyntax.ts create mode 100644 src/application/Parser/Executable/Script/Validation/Analyzers/Syntax/SyntaxFactory.ts create mode 100644 src/application/Parser/Executable/Script/Validation/CodeValidationRule.ts delete mode 100644 src/application/Parser/Executable/Script/Validation/ICodeLine.ts delete mode 100644 src/application/Parser/Executable/Script/Validation/ICodeValidationRule.ts delete mode 100644 src/application/Parser/Executable/Script/Validation/ICodeValidator.ts delete mode 100644 src/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines.ts delete mode 100644 src/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines.ts delete mode 100644 src/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax.ts delete mode 100644 src/application/Parser/Executable/Script/Validation/Syntax/ISyntaxFactory.ts delete mode 100644 src/application/Parser/Executable/Script/Validation/Syntax/ShellScriptSyntax.ts delete mode 100644 src/application/Parser/Executable/Script/Validation/Syntax/SyntaxFactory.ts create mode 100644 src/application/Parser/Executable/Script/Validation/ValidationRuleAnalyzerFactory.ts create mode 100644 tests/unit/application/Parser/Executable/CategoryCollectionContext.spec.ts delete mode 100644 tests/unit/application/Parser/Executable/CategoryCollectionSpecificUtilities.spec.ts delete mode 100644 tests/unit/application/Parser/Executable/Script/Compiler/ScriptCompiler.spec.ts create mode 100644 tests/unit/application/Parser/Executable/Script/Compiler/ScriptCompilerFactory.spec.ts create mode 100644 tests/unit/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeDuplicateLines.spec.ts create mode 100644 tests/unit/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeEmptyLines.spec.ts create mode 100644 tests/unit/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeTooLongLines.spec.ts create mode 100644 tests/unit/application/Parser/Executable/Script/Validation/Analyzers/CreateCodeLines.ts create mode 100644 tests/unit/application/Parser/Executable/Script/Validation/Analyzers/ExpectSameInvalidCodeLines.ts create mode 100644 tests/unit/application/Parser/Executable/Script/Validation/Analyzers/Syntax/BatchFileSyntax.spec.ts create mode 100644 tests/unit/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntaxTestRunner.ts create mode 100644 tests/unit/application/Parser/Executable/Script/Validation/Analyzers/Syntax/ShellScriptSyntax.spec.ts create mode 100644 tests/unit/application/Parser/Executable/Script/Validation/Analyzers/Syntax/SyntaxFactory.spec.ts delete mode 100644 tests/unit/application/Parser/Executable/Script/Validation/Rules/CodeValidationRuleTestRunner.ts delete mode 100644 tests/unit/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines.spec.ts delete mode 100644 tests/unit/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines.spec.ts delete mode 100644 tests/unit/application/Parser/Executable/Script/Validation/Syntax/ConcreteSyntaxes.spec.ts delete mode 100644 tests/unit/application/Parser/Executable/Script/Validation/Syntax/SyntaxFactory.spec.ts create mode 100644 tests/unit/application/Parser/Executable/Script/Validation/ValidationRuleAnalyzerFactory.spec.ts create mode 100644 tests/unit/shared/Stubs/CategoryCollectionContextStub.ts delete mode 100644 tests/unit/shared/Stubs/CategoryCollectionSpecificUtilitiesStub.ts create mode 100644 tests/unit/shared/Stubs/CodeValidationAnalyzerStub.ts delete mode 100644 tests/unit/shared/Stubs/CodeValidationRuleStub.ts create mode 100644 tests/unit/shared/Stubs/ScriptCompilerFactoryStub.ts diff --git a/src/application/Parser/CategoryCollectionParser.ts b/src/application/Parser/CategoryCollectionParser.ts index b7e71420e..553f2457e 100644 --- a/src/application/Parser/CategoryCollectionParser.ts +++ b/src/application/Parser/CategoryCollectionParser.ts @@ -7,7 +7,7 @@ import { createEnumParser, type EnumParser } from '../Common/Enum'; import { parseCategory, type CategoryParser } from './Executable/CategoryParser'; import { parseScriptingDefinition, type ScriptingDefinitionParser } from './ScriptingDefinition/ScriptingDefinitionParser'; import { createTypeValidator, type TypeValidator } from './Common/TypeValidator'; -import { createCollectionUtilities, type CategoryCollectionSpecificUtilitiesFactory } from './Executable/CategoryCollectionSpecificUtilities'; +import { createCategoryCollectionContext, type CategoryCollectionContextFactory } from './Executable/CategoryCollectionContext'; export const parseCategoryCollection: CategoryCollectionParser = ( content, @@ -16,9 +16,9 @@ export const parseCategoryCollection: CategoryCollectionParser = ( ) => { validateCollection(content, utilities.validator); const scripting = utilities.parseScriptingDefinition(content.scripting, projectDetails); - const collectionUtilities = utilities.createUtilities(content.functions, scripting); + const collectionContext = utilities.createContext(content.functions, scripting.language); const categories = content.actions.map( - (action) => utilities.parseCategory(action, collectionUtilities), + (action) => utilities.parseCategory(action, collectionContext), ); const os = utilities.osParser.parseEnum(content.os, 'os'); const collection = utilities.createCategoryCollection({ @@ -60,7 +60,7 @@ interface CategoryCollectionParserUtilities { readonly osParser: EnumParser; readonly validator: TypeValidator; readonly parseScriptingDefinition: ScriptingDefinitionParser; - readonly createUtilities: CategoryCollectionSpecificUtilitiesFactory; + readonly createContext: CategoryCollectionContextFactory; readonly parseCategory: CategoryParser; readonly createCategoryCollection: CategoryCollectionFactory; } @@ -69,7 +69,7 @@ const DefaultUtilities: CategoryCollectionParserUtilities = { osParser: createEnumParser(OperatingSystem), validator: createTypeValidator(), parseScriptingDefinition, - createUtilities: createCollectionUtilities, + createContext: createCategoryCollectionContext, parseCategory, createCategoryCollection: (...args) => new CategoryCollection(...args), }; diff --git a/src/application/Parser/Executable/CategoryCollectionContext.ts b/src/application/Parser/Executable/CategoryCollectionContext.ts new file mode 100644 index 000000000..22b0c870c --- /dev/null +++ b/src/application/Parser/Executable/CategoryCollectionContext.ts @@ -0,0 +1,33 @@ +import type { FunctionData } from '@/application/collections/'; +import type { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import { createScriptCompiler, type ScriptCompilerFactory } from './Script/Compiler/ScriptCompilerFactory'; +import type { ScriptCompiler } from './Script/Compiler/ScriptCompiler'; + +export interface CategoryCollectionContext { + readonly compiler: ScriptCompiler; + readonly language: ScriptingLanguage; +} + +export interface CategoryCollectionContextFactory { + ( + functionsData: ReadonlyArray | undefined, + language: ScriptingLanguage, + compilerFactory?: ScriptCompilerFactory, + ): CategoryCollectionContext; +} + +export const createCategoryCollectionContext: CategoryCollectionContextFactory = ( + functionsData: ReadonlyArray | undefined, + language: ScriptingLanguage, + compilerFactory: ScriptCompilerFactory = createScriptCompiler, +) => { + return { + compiler: compilerFactory({ + categoryContext: { + functions: functionsData ?? [], + language, + }, + }), + language, + }; +}; diff --git a/src/application/Parser/Executable/CategoryCollectionSpecificUtilities.ts b/src/application/Parser/Executable/CategoryCollectionSpecificUtilities.ts deleted file mode 100644 index dfb92ef15..000000000 --- a/src/application/Parser/Executable/CategoryCollectionSpecificUtilities.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { IScriptingDefinition } from '@/domain/IScriptingDefinition'; -import type { FunctionData } from '@/application/collections/'; -import { ScriptCompiler } from './Script/Compiler/ScriptCompiler'; -import { SyntaxFactory } from './Script/Validation/Syntax/SyntaxFactory'; -import type { IScriptCompiler } from './Script/Compiler/IScriptCompiler'; -import type { ILanguageSyntax } from './Script/Validation/Syntax/ILanguageSyntax'; -import type { ISyntaxFactory } from './Script/Validation/Syntax/ISyntaxFactory'; - -export interface CategoryCollectionSpecificUtilities { - readonly compiler: IScriptCompiler; - readonly syntax: ILanguageSyntax; -} - -export const createCollectionUtilities: CategoryCollectionSpecificUtilitiesFactory = ( - functionsData: ReadonlyArray | undefined, - scripting: IScriptingDefinition, - syntaxFactory: ISyntaxFactory = new SyntaxFactory(), -) => { - const syntax = syntaxFactory.create(scripting.language); - return { - compiler: new ScriptCompiler({ - functions: functionsData ?? [], - syntax, - }), - syntax, - }; -}; - -export interface CategoryCollectionSpecificUtilitiesFactory { - ( - functionsData: ReadonlyArray | undefined, - scripting: IScriptingDefinition, - syntaxFactory?: ISyntaxFactory, - ): CategoryCollectionSpecificUtilities; -} diff --git a/src/application/Parser/Executable/CategoryParser.ts b/src/application/Parser/Executable/CategoryParser.ts index c2a65e28c..1adba82ed 100644 --- a/src/application/Parser/Executable/CategoryParser.ts +++ b/src/application/Parser/Executable/CategoryParser.ts @@ -9,16 +9,16 @@ import { parseDocs, type DocsParser } from './DocumentationParser'; import { parseScript, type ScriptParser } from './Script/ScriptParser'; import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from './Validation/ExecutableValidator'; import { ExecutableType } from './Validation/ExecutableType'; -import type { CategoryCollectionSpecificUtilities } from './CategoryCollectionSpecificUtilities'; +import type { CategoryCollectionContext } from './CategoryCollectionContext'; export const parseCategory: CategoryParser = ( category: CategoryData, - collectionUtilities: CategoryCollectionSpecificUtilities, + collectionContext: CategoryCollectionContext, categoryUtilities: CategoryParserUtilities = DefaultCategoryParserUtilities, ) => { return parseCategoryRecursively({ categoryData: category, - collectionUtilities, + collectionContext, categoryUtilities, }); }; @@ -26,14 +26,14 @@ export const parseCategory: CategoryParser = ( export interface CategoryParser { ( category: CategoryData, - collectionUtilities: CategoryCollectionSpecificUtilities, + collectionContext: CategoryCollectionContext, categoryUtilities?: CategoryParserUtilities, ): Category; } interface CategoryParseContext { readonly categoryData: CategoryData; - readonly collectionUtilities: CategoryCollectionSpecificUtilities; + readonly collectionContext: CategoryCollectionContext; readonly parentCategory?: CategoryData; readonly categoryUtilities: CategoryParserUtilities; } @@ -52,7 +52,7 @@ function parseCategoryRecursively( children, parent: context.categoryData, categoryUtilities: context.categoryUtilities, - collectionUtilities: context.collectionUtilities, + collectionContext: context.collectionContext, }); } try { @@ -104,7 +104,7 @@ interface ExecutableParseContext { readonly data: ExecutableData; readonly children: CategoryChildren; readonly parent: CategoryData; - readonly collectionUtilities: CategoryCollectionSpecificUtilities; + readonly collectionContext: CategoryCollectionContext; readonly categoryUtilities: CategoryParserUtilities; } @@ -124,13 +124,13 @@ function parseUnknownExecutable(context: ExecutableParseContext) { if (isCategory(context.data)) { const subCategory = parseCategoryRecursively({ categoryData: context.data, - collectionUtilities: context.collectionUtilities, + collectionContext: context.collectionContext, parentCategory: context.parent, categoryUtilities: context.categoryUtilities, }); context.children.subcategories.push(subCategory); } else { // A script - const script = context.categoryUtilities.parseScript(context.data, context.collectionUtilities); + const script = context.categoryUtilities.parseScript(context.data, context.collectionContext); context.children.subscripts.push(script); } } diff --git a/src/application/Parser/Executable/Script/Compiler/Function/SharedFunctionsParser.ts b/src/application/Parser/Executable/Script/Compiler/Function/SharedFunctionsParser.ts index 7b3c79fac..23f7f05cb 100644 --- a/src/application/Parser/Executable/Script/Compiler/Function/SharedFunctionsParser.ts +++ b/src/application/Parser/Executable/Script/Compiler/Function/SharedFunctionsParser.ts @@ -2,14 +2,12 @@ import type { FunctionData, CodeInstruction, CodeFunctionData, CallFunctionData, CallInstruction, ParameterDefinitionData, } from '@/application/collections/'; -import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax'; -import { CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator'; -import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines'; -import { NoDuplicatedLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines'; -import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator'; +import { validateCode, type CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator'; import { isArray, isNullOrUndefined, isPlainObject } from '@/TypeHelpers'; import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError'; import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings'; +import type { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import { CodeValidationRule } from '@/application/Parser/Executable/Script/Validation/CodeValidationRule'; import { createFunctionWithInlineCode, createCallerFunction } from './SharedFunction'; import { SharedFunctionCollection } from './SharedFunctionCollection'; import { parseFunctionCalls, type FunctionCallsParser } from './Call/FunctionCallsParser'; @@ -23,14 +21,14 @@ import type { ISharedFunction } from './ISharedFunction'; export interface SharedFunctionsParser { ( functions: readonly FunctionData[], - syntax: ILanguageSyntax, + language: ScriptingLanguage, utilities?: SharedFunctionsParsingUtilities, ): ISharedFunctionCollection; } export const parseSharedFunctions: SharedFunctionsParser = ( functions: readonly FunctionData[], - syntax: ILanguageSyntax, + language: ScriptingLanguage, utilities = DefaultUtilities, ) => { const collection = new SharedFunctionCollection(); @@ -39,7 +37,7 @@ export const parseSharedFunctions: SharedFunctionsParser = ( } ensureValidFunctions(functions); return functions - .map((func) => parseFunction(func, syntax, utilities)) + .map((func) => parseFunction(func, language, utilities)) .reduce((acc, func) => { acc.addFunction(func); return acc; @@ -49,7 +47,7 @@ export const parseSharedFunctions: SharedFunctionsParser = ( const DefaultUtilities: SharedFunctionsParsingUtilities = { wrapError: wrapErrorWithAdditionalContext, parseParameter: parseFunctionParameter, - codeValidator: CodeValidator.instance, + codeValidator: validateCode, createParameterCollection: createFunctionParameterCollection, parseFunctionCalls, }; @@ -57,20 +55,20 @@ const DefaultUtilities: SharedFunctionsParsingUtilities = { interface SharedFunctionsParsingUtilities { readonly wrapError: ErrorWithContextWrapper; readonly parseParameter: FunctionParameterParser; - readonly codeValidator: ICodeValidator; + readonly codeValidator: CodeValidator; readonly createParameterCollection: FunctionParameterCollectionFactory; readonly parseFunctionCalls: FunctionCallsParser; } function parseFunction( data: FunctionData, - syntax: ILanguageSyntax, + language: ScriptingLanguage, utilities: SharedFunctionsParsingUtilities, ): ISharedFunction { const { name } = data; const parameters = parseParameters(data, utilities); if (hasCode(data)) { - validateCode(data, syntax, utilities.codeValidator); + validateNonEmptyCode(data, language, utilities.codeValidator); return createFunctionWithInlineCode(name, parameters, data.code, data.revertCode); } // Has call @@ -78,16 +76,20 @@ function parseFunction( return createCallerFunction(name, parameters, calls); } -function validateCode( +function validateNonEmptyCode( data: CodeFunctionData, - syntax: ILanguageSyntax, - validator: ICodeValidator, + language: ScriptingLanguage, + validate: CodeValidator, ): void { filterEmptyStrings([data.code, data.revertCode]) .forEach( - (code) => validator.throwIfInvalid( + (code) => validate( code, - [new NoEmptyLines(), new NoDuplicatedLines(syntax)], + language, + [ + CodeValidationRule.NoEmptyLines, + CodeValidationRule.NoDuplicatedLines, + ], ), ); } diff --git a/src/application/Parser/Executable/Script/Compiler/IScriptCompiler.ts b/src/application/Parser/Executable/Script/Compiler/IScriptCompiler.ts deleted file mode 100644 index a411f947a..000000000 --- a/src/application/Parser/Executable/Script/Compiler/IScriptCompiler.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { ScriptData } from '@/application/collections/'; -import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode'; - -export interface IScriptCompiler { - canCompile(script: ScriptData): boolean; - compile(script: ScriptData): ScriptCode; -} diff --git a/src/application/Parser/Executable/Script/Compiler/ScriptCompiler.ts b/src/application/Parser/Executable/Script/Compiler/ScriptCompiler.ts index fc24b59ac..61ce8226d 100644 --- a/src/application/Parser/Executable/Script/Compiler/ScriptCompiler.ts +++ b/src/application/Parser/Executable/Script/Compiler/ScriptCompiler.ts @@ -1,86 +1,7 @@ -import type { FunctionData, ScriptData, CallInstruction } from '@/application/collections/'; +import type { ScriptData } from '@/application/collections/'; import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode'; -import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax'; -import { CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator'; -import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines'; -import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator'; -import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError'; -import { createScriptCode, type ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory'; -import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings'; -import { FunctionCallSequenceCompiler } from './Function/Call/Compiler/FunctionCallSequenceCompiler'; -import { parseFunctionCalls } from './Function/Call/FunctionCallsParser'; -import { parseSharedFunctions, type SharedFunctionsParser } from './Function/SharedFunctionsParser'; -import type { CompiledCode } from './Function/Call/Compiler/CompiledCode'; -import type { IScriptCompiler } from './IScriptCompiler'; -import type { ISharedFunctionCollection } from './Function/ISharedFunctionCollection'; -import type { FunctionCallCompiler } from './Function/Call/Compiler/FunctionCallCompiler'; -interface ScriptCompilerUtilities { - readonly sharedFunctionsParser: SharedFunctionsParser; - readonly callCompiler: FunctionCallCompiler; - readonly codeValidator: ICodeValidator; - readonly wrapError: ErrorWithContextWrapper; - readonly scriptCodeFactory: ScriptCodeFactory; -} - -const DefaultUtilities: ScriptCompilerUtilities = { - sharedFunctionsParser: parseSharedFunctions, - callCompiler: FunctionCallSequenceCompiler.instance, - codeValidator: CodeValidator.instance, - wrapError: wrapErrorWithAdditionalContext, - scriptCodeFactory: createScriptCode, -}; - -interface CategoryCollectionDataContext { - readonly functions: readonly FunctionData[]; - readonly syntax: ILanguageSyntax; -} - -export class ScriptCompiler implements IScriptCompiler { - private readonly functions: ISharedFunctionCollection; - - constructor( - categoryContext: CategoryCollectionDataContext, - private readonly utilities: ScriptCompilerUtilities = DefaultUtilities, - ) { - this.functions = this.utilities.sharedFunctionsParser( - categoryContext.functions, - categoryContext.syntax, - ); - } - - public canCompile(script: ScriptData): boolean { - return hasCall(script); - } - - public compile(script: ScriptData): ScriptCode { - try { - if (!hasCall(script)) { - throw new Error('Script does include any calls.'); - } - const calls = parseFunctionCalls(script.call); - const compiledCode = this.utilities.callCompiler.compileFunctionCalls(calls, this.functions); - validateCompiledCode(compiledCode, this.utilities.codeValidator); - return this.utilities.scriptCodeFactory( - compiledCode.code, - compiledCode.revertCode, - ); - } catch (error) { - throw this.utilities.wrapError(error, `Failed to compile script: ${script.name}`); - } - } -} - -function validateCompiledCode(compiledCode: CompiledCode, validator: ICodeValidator): void { - filterEmptyStrings([compiledCode.code, compiledCode.revertCode]) - .forEach( - (code) => validator.throwIfInvalid( - code, - [new NoEmptyLines()], - ), - ); -} - -function hasCall(data: ScriptData): data is ScriptData & CallInstruction { - return (data as CallInstruction).call !== undefined; +export interface ScriptCompiler { + canCompile(script: ScriptData): boolean; + compile(script: ScriptData): ScriptCode; } diff --git a/src/application/Parser/Executable/Script/Compiler/ScriptCompilerFactory.ts b/src/application/Parser/Executable/Script/Compiler/ScriptCompilerFactory.ts new file mode 100644 index 000000000..1ed34dcfa --- /dev/null +++ b/src/application/Parser/Executable/Script/Compiler/ScriptCompilerFactory.ts @@ -0,0 +1,119 @@ +import type { FunctionData, ScriptData, CallInstruction } from '@/application/collections/'; +import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode'; +import { validateCode, type CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator'; +import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError'; +import { createScriptCode, type ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory'; +import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings'; +import type { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import { CodeValidationRule } from '@/application/Parser/Executable/Script/Validation/CodeValidationRule'; +import { FunctionCallSequenceCompiler } from './Function/Call/Compiler/FunctionCallSequenceCompiler'; +import { parseFunctionCalls } from './Function/Call/FunctionCallsParser'; +import { parseSharedFunctions, type SharedFunctionsParser } from './Function/SharedFunctionsParser'; +import type { CompiledCode } from './Function/Call/Compiler/CompiledCode'; +import type { ScriptCompiler } from './ScriptCompiler'; +import type { ISharedFunctionCollection } from './Function/ISharedFunctionCollection'; +import type { FunctionCallCompiler } from './Function/Call/Compiler/FunctionCallCompiler'; + +export interface ScriptCompilerInitParameters { + readonly categoryContext: CategoryCollectionDataContext; + readonly utilities?: ScriptCompilerUtilities; +} + +export interface ScriptCompilerFactory { + (parameters: ScriptCompilerInitParameters): ScriptCompiler; +} + +export const createScriptCompiler: ScriptCompilerFactory = ( + parameters, +) => { + return new FunctionCallScriptCompiler( + parameters.categoryContext, + parameters.utilities ?? DefaultUtilities, + ); +}; + +interface ScriptCompilerUtilities { + readonly sharedFunctionsParser: SharedFunctionsParser; + readonly callCompiler: FunctionCallCompiler; + readonly codeValidator: CodeValidator; + readonly wrapError: ErrorWithContextWrapper; + readonly scriptCodeFactory: ScriptCodeFactory; +} + +const DefaultUtilities: ScriptCompilerUtilities = { + sharedFunctionsParser: parseSharedFunctions, + callCompiler: FunctionCallSequenceCompiler.instance, + codeValidator: validateCode, + wrapError: wrapErrorWithAdditionalContext, + scriptCodeFactory: createScriptCode, +}; + +interface CategoryCollectionDataContext { + readonly functions: readonly FunctionData[]; + readonly language: ScriptingLanguage; +} + +class FunctionCallScriptCompiler implements ScriptCompiler { + private readonly functions: ISharedFunctionCollection; + + private readonly language: ScriptingLanguage; + + constructor( + categoryContext: CategoryCollectionDataContext, + private readonly utilities: ScriptCompilerUtilities = DefaultUtilities, + ) { + this.functions = this.utilities.sharedFunctionsParser( + categoryContext.functions, + categoryContext.language, + ); + this.language = categoryContext.language; + } + + public canCompile(script: ScriptData): boolean { + return hasCall(script); + } + + public compile(script: ScriptData): ScriptCode { + try { + if (!hasCall(script)) { + throw new Error('Script does include any calls.'); + } + const calls = parseFunctionCalls(script.call); + const compiledCode = this.utilities.callCompiler.compileFunctionCalls(calls, this.functions); + validateCompiledCode( + compiledCode, + this.language, + this.utilities.codeValidator, + ); + return this.utilities.scriptCodeFactory( + compiledCode.code, + compiledCode.revertCode, + ); + } catch (error) { + throw this.utilities.wrapError(error, `Failed to compile script: ${script.name}`); + } + } +} + +function validateCompiledCode( + compiledCode: CompiledCode, + language: ScriptingLanguage, + validate: CodeValidator, +): void { + filterEmptyStrings([compiledCode.code, compiledCode.revertCode]) + .forEach( + (code) => validate( + code, + language, + [ + CodeValidationRule.NoEmptyLines, + CodeValidationRule.NoTooLongLines, + // Allow duplicated lines to enable calling same function multiple times + ], + ), + ); +} + +function hasCall(data: ScriptData): data is ScriptData & CallInstruction { + return (data as CallInstruction).call !== undefined; +} diff --git a/src/application/Parser/Executable/Script/ScriptParser.ts b/src/application/Parser/Executable/Script/ScriptParser.ts index 5aa9b9406..0eaf507c1 100644 --- a/src/application/Parser/Executable/Script/ScriptParser.ts +++ b/src/application/Parser/Executable/Script/ScriptParser.ts @@ -1,9 +1,7 @@ import type { ScriptData, CodeScriptData, CallScriptData } from '@/application/collections/'; -import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines'; -import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax'; import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel'; import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode'; -import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator'; +import { validateCode, type CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator'; import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError'; import type { ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory'; import { createScriptCode } from '@/domain/Executables/Script/Code/ScriptCodeFactory'; @@ -11,24 +9,24 @@ import type { Script } from '@/domain/Executables/Script/Script'; import { createEnumParser, type EnumParser } from '@/application/Common/Enum'; import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings'; import { createScript, type ScriptFactory } from '@/domain/Executables/Script/ScriptFactory'; +import type { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import { CodeValidationRule } from '@/application/Parser/Executable/Script/Validation/CodeValidationRule'; import { parseDocs, type DocsParser } from '../DocumentationParser'; import { ExecutableType } from '../Validation/ExecutableType'; import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from '../Validation/ExecutableValidator'; -import { CodeValidator } from './Validation/CodeValidator'; -import { NoDuplicatedLines } from './Validation/Rules/NoDuplicatedLines'; -import type { CategoryCollectionSpecificUtilities } from '../CategoryCollectionSpecificUtilities'; +import type { CategoryCollectionContext } from '../CategoryCollectionContext'; export interface ScriptParser { ( data: ScriptData, - collectionUtilities: CategoryCollectionSpecificUtilities, + collectionContext: CategoryCollectionContext, scriptUtilities?: ScriptParserUtilities, ): Script; } export const parseScript: ScriptParser = ( data, - collectionUtilities, + collectionContext, scriptUtilities = DefaultUtilities, ) => { const validator = scriptUtilities.createValidator({ @@ -42,7 +40,7 @@ export const parseScript: ScriptParser = ( name: data.name, code: parseCode( data, - collectionUtilities, + collectionContext, scriptUtilities.codeValidator, scriptUtilities.createCode, ), @@ -70,29 +68,34 @@ function parseLevel( function parseCode( script: ScriptData, - collectionUtilities: CategoryCollectionSpecificUtilities, - codeValidator: ICodeValidator, + collectionContext: CategoryCollectionContext, + codeValidator: CodeValidator, createCode: ScriptCodeFactory, ): ScriptCode { - if (collectionUtilities.compiler.canCompile(script)) { - return collectionUtilities.compiler.compile(script); + if (collectionContext.compiler.canCompile(script)) { + return collectionContext.compiler.compile(script); } const codeScript = script as CodeScriptData; // Must be inline code if it cannot be compiled const code = createCode(codeScript.code, codeScript.revertCode); - validateHardcodedCodeWithoutCalls(code, codeValidator, collectionUtilities.syntax); + validateHardcodedCodeWithoutCalls(code, codeValidator, collectionContext.language); return code; } function validateHardcodedCodeWithoutCalls( scriptCode: ScriptCode, - validator: ICodeValidator, - syntax: ILanguageSyntax, + validate: CodeValidator, + language: ScriptingLanguage, ) { filterEmptyStrings([scriptCode.execute, scriptCode.revert]) .forEach( - (code) => validator.throwIfInvalid( + (code) => validate( code, - [new NoEmptyLines(), new NoDuplicatedLines(syntax)], + language, + [ + CodeValidationRule.NoEmptyLines, + CodeValidationRule.NoDuplicatedLines, + CodeValidationRule.NoTooLongLines, + ], ), ); } @@ -126,7 +129,7 @@ function validateScript( interface ScriptParserUtilities { readonly levelParser: EnumParser; readonly createScript: ScriptFactory; - readonly codeValidator: ICodeValidator; + readonly codeValidator: CodeValidator; readonly wrapError: ErrorWithContextWrapper; readonly createValidator: ExecutableValidatorFactory; readonly createCode: ScriptCodeFactory; @@ -136,7 +139,7 @@ interface ScriptParserUtilities { const DefaultUtilities: ScriptParserUtilities = { levelParser: createEnumParser(RecommendationLevel), createScript, - codeValidator: CodeValidator.instance, + codeValidator: validateCode, wrapError: wrapErrorWithAdditionalContext, createValidator: createExecutableDataValidator, createCode: createScriptCode, diff --git a/src/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeDuplicateLines.ts b/src/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeDuplicateLines.ts new file mode 100644 index 000000000..06256a1e0 --- /dev/null +++ b/src/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeDuplicateLines.ts @@ -0,0 +1,63 @@ +import type { LanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax'; +import type { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import { createSyntax, type SyntaxFactory } from './Syntax/SyntaxFactory'; +import type { CodeLine, CodeValidationAnalyzer, InvalidCodeLine } from './CodeValidationAnalyzer'; + +export type DuplicateLinesAnalyzer = CodeValidationAnalyzer & { + ( + ...args: [ + ...Parameters, + syntaxFactory?: SyntaxFactory, + ] + ): ReturnType; +}; + +export const analyzeDuplicateLines: DuplicateLinesAnalyzer = ( + lines: readonly CodeLine[], + language: ScriptingLanguage, + syntaxFactory = createSyntax, +) => { + const syntax = syntaxFactory(language); + return lines + .map((line): CodeLineWithDuplicateOccurrences => ({ + lineNumber: line.lineNumber, + shouldBeIgnoredInAnalysis: shouldIgnoreLine(line.text, syntax), + duplicateLineNumbers: lines + .filter((other) => other.text === line.text) + .map((duplicatedLine) => duplicatedLine.lineNumber), + })) + .filter((line) => isNonIgnorableDuplicateLine(line)) + .map((line): InvalidCodeLine => ({ + lineNumber: line.lineNumber, + error: `Line is duplicated at line numbers ${line.duplicateLineNumbers.join(',')}.`, + })); +}; + +interface CodeLineWithDuplicateOccurrences { + readonly lineNumber: number; + readonly duplicateLineNumbers: readonly number[]; + readonly shouldBeIgnoredInAnalysis: boolean; +} + +function isNonIgnorableDuplicateLine(line: CodeLineWithDuplicateOccurrences): boolean { + return !line.shouldBeIgnoredInAnalysis && line.duplicateLineNumbers.length > 1; +} + +function shouldIgnoreLine(codeLine: string, syntax: LanguageSyntax): boolean { + return isCommentLine(codeLine, syntax) + || isLineComposedEntirelyOfCommonCodeParts(codeLine, syntax); +} + +function isCommentLine(codeLine: string, syntax: LanguageSyntax): boolean { + return syntax.commentDelimiters.some( + (delimiter) => codeLine.startsWith(delimiter), + ); +} + +function isLineComposedEntirelyOfCommonCodeParts( + codeLine: string, + syntax: LanguageSyntax, +): boolean { + const codeLineParts = codeLine.toLowerCase().trim().split(' '); + return codeLineParts.every((part) => syntax.commonCodeParts.includes(part)); +} diff --git a/src/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeEmptyLines.ts b/src/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeEmptyLines.ts new file mode 100644 index 000000000..804cd9639 --- /dev/null +++ b/src/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeEmptyLines.ts @@ -0,0 +1,24 @@ +import type { CodeValidationAnalyzer, InvalidCodeLine } from './CodeValidationAnalyzer'; + +export const analyzeEmptyLines: CodeValidationAnalyzer = ( + lines, +) => { + return lines + .filter((line) => isEmptyLine(line.text)) + .map((line): InvalidCodeLine => ({ + lineNumber: line.lineNumber, + error: (() => { + if (!line.text) { + return 'Empty line'; + } + const markedText = line.text + .replaceAll(' ', '{whitespace}') + .replaceAll('\t', '{tab}'); + return `Empty line: "${markedText}"`; + })(), + })); +}; + +function isEmptyLine(line: string): boolean { + return line.trim().length === 0; +} diff --git a/src/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeTooLongLines.ts b/src/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeTooLongLines.ts new file mode 100644 index 000000000..fc8c5da1a --- /dev/null +++ b/src/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeTooLongLines.ts @@ -0,0 +1,44 @@ +import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import type { CodeValidationAnalyzer, InvalidCodeLine } from './CodeValidationAnalyzer'; + +export const analyzeTooLongLines: CodeValidationAnalyzer = ( + lines, + language, +) => { + const maxLineLength = getMaxAllowedLineLength(language); + return lines + .filter((line) => line.text.length > maxLineLength) + .map((line): InvalidCodeLine => ({ + lineNumber: line.lineNumber, + error: [ + `Line is too long (${line.text.length}).`, + `It exceed maximum allowed length ${maxLineLength}.`, + 'This may cause bugs due to unintended trimming by operating system, shells or terminal emulators.', + ].join(' '), + })); +}; + +function getMaxAllowedLineLength(language: ScriptingLanguage): number { + switch (language) { + case ScriptingLanguage.batchfile: + /* + The maximum length of the string that you can use at the command prompt is 8191 characters. + https://web.archive.org/web/20240815120224/https://learn.microsoft.com/en-us/troubleshoot/windows-client/shell-experience/command-line-string-limitation + */ + return 8191; + case ScriptingLanguage.shellscript: + /* + Tests show: + + | OS | Command | Value | + | --- | ------- | ----- | + | Pop!_OS 22.04 | xargs --show-limits | 2088784 | + | macOS Sonoma 14.3 on Intel | getconf ARG_MAX | 1048576 | + | macOS Sonoma 14.3 on Apple Silicon M1 | getconf ARG_MAX | 1048576 | + | Android 12 (4.14.180) with Termux | xargs --show-limits | 2087244 | + */ + return 1048576; // Minimum value for reliability + default: + throw new Error(`Unsupported language: ${language}`); + } +} diff --git a/src/application/Parser/Executable/Script/Validation/Analyzers/CodeValidationAnalyzer.ts b/src/application/Parser/Executable/Script/Validation/Analyzers/CodeValidationAnalyzer.ts new file mode 100644 index 000000000..25543c8a4 --- /dev/null +++ b/src/application/Parser/Executable/Script/Validation/Analyzers/CodeValidationAnalyzer.ts @@ -0,0 +1,18 @@ +import type { ScriptingLanguage } from '@/domain/ScriptingLanguage'; + +export interface CodeValidationAnalyzer { + ( + lines: readonly CodeLine[], + language: ScriptingLanguage, + ): InvalidCodeLine[]; +} + +export interface InvalidCodeLine { + readonly lineNumber: number; + readonly error: string; +} + +export interface CodeLine { + readonly lineNumber: number; + readonly text: string; +} diff --git a/src/application/Parser/Executable/Script/Validation/Syntax/BatchFileSyntax.ts b/src/application/Parser/Executable/Script/Validation/Analyzers/Syntax/BatchFileSyntax.ts similarity index 59% rename from src/application/Parser/Executable/Script/Validation/Syntax/BatchFileSyntax.ts rename to src/application/Parser/Executable/Script/Validation/Analyzers/Syntax/BatchFileSyntax.ts index 8df1765f1..70f82c9d3 100644 --- a/src/application/Parser/Executable/Script/Validation/Syntax/BatchFileSyntax.ts +++ b/src/application/Parser/Executable/Script/Validation/Analyzers/Syntax/BatchFileSyntax.ts @@ -1,9 +1,9 @@ -import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax'; +import type { LanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax'; const BatchFileCommonCodeParts = ['(', ')', 'else', '||']; const PowerShellCommonCodeParts = ['{', '}']; -export class BatchFileSyntax implements ILanguageSyntax { +export class BatchFileSyntax implements LanguageSyntax { public readonly commentDelimiters = ['REM', '::']; public readonly commonCodeParts = [...BatchFileCommonCodeParts, ...PowerShellCommonCodeParts]; diff --git a/src/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax.ts b/src/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax.ts new file mode 100644 index 000000000..702b8166c --- /dev/null +++ b/src/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax.ts @@ -0,0 +1,4 @@ +export interface LanguageSyntax { + readonly commentDelimiters: readonly string[]; + readonly commonCodeParts: readonly string[]; +} diff --git a/src/application/Parser/Executable/Script/Validation/Analyzers/Syntax/ShellScriptSyntax.ts b/src/application/Parser/Executable/Script/Validation/Analyzers/Syntax/ShellScriptSyntax.ts new file mode 100644 index 000000000..29bfa22c2 --- /dev/null +++ b/src/application/Parser/Executable/Script/Validation/Analyzers/Syntax/ShellScriptSyntax.ts @@ -0,0 +1,7 @@ +import type { LanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax'; + +export class ShellScriptSyntax implements LanguageSyntax { + public readonly commentDelimiters = ['#']; + + public readonly commonCodeParts = ['(', ')', 'else', 'fi', 'done']; +} diff --git a/src/application/Parser/Executable/Script/Validation/Analyzers/Syntax/SyntaxFactory.ts b/src/application/Parser/Executable/Script/Validation/Analyzers/Syntax/SyntaxFactory.ts new file mode 100644 index 000000000..79a7937f7 --- /dev/null +++ b/src/application/Parser/Executable/Script/Validation/Analyzers/Syntax/SyntaxFactory.ts @@ -0,0 +1,19 @@ +import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import type { LanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax'; +import { BatchFileSyntax } from './BatchFileSyntax'; +import { ShellScriptSyntax } from './ShellScriptSyntax'; + +export interface SyntaxFactory { + (language: ScriptingLanguage): LanguageSyntax; +} + +export const createSyntax: SyntaxFactory = (language: ScriptingLanguage): LanguageSyntax => { + switch (language) { + case ScriptingLanguage.batchfile: + return new BatchFileSyntax(); + case ScriptingLanguage.shellscript: + return new ShellScriptSyntax(); + default: + throw new RangeError(`Invalid language: "${ScriptingLanguage[language]}"`); + } +}; diff --git a/src/application/Parser/Executable/Script/Validation/CodeValidationRule.ts b/src/application/Parser/Executable/Script/Validation/CodeValidationRule.ts new file mode 100644 index 000000000..a12063483 --- /dev/null +++ b/src/application/Parser/Executable/Script/Validation/CodeValidationRule.ts @@ -0,0 +1,5 @@ +export enum CodeValidationRule { + NoEmptyLines, + NoDuplicatedLines, + NoTooLongLines, +} diff --git a/src/application/Parser/Executable/Script/Validation/CodeValidator.ts b/src/application/Parser/Executable/Script/Validation/CodeValidator.ts index 4cb612f3b..f89e5c8a4 100644 --- a/src/application/Parser/Executable/Script/Validation/CodeValidator.ts +++ b/src/application/Parser/Executable/Script/Validation/CodeValidator.ts @@ -1,46 +1,54 @@ import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines'; -import type { ICodeLine } from './ICodeLine'; -import type { ICodeValidationRule, IInvalidCodeLine } from './ICodeValidationRule'; -import type { ICodeValidator } from './ICodeValidator'; +import type { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import { createValidationAnalyzers, type ValidationRuleAnalyzerFactory } from './ValidationRuleAnalyzerFactory'; +import type { CodeLine, InvalidCodeLine } from './Analyzers/CodeValidationAnalyzer'; +import type { CodeValidationRule } from './CodeValidationRule'; -export class CodeValidator implements ICodeValidator { - public static readonly instance: ICodeValidator = new CodeValidator(); - - public throwIfInvalid( +export interface CodeValidator { + ( code: string, - rules: readonly ICodeValidationRule[], - ): void { - if (rules.length === 0) { throw new Error('missing rules'); } - if (!code) { - return; - } - const lines = extractLines(code); - const invalidLines = rules.flatMap((rule) => rule.analyze(lines)); - if (invalidLines.length === 0) { - return; - } - const errorText = `Errors with the code.\n${printLines(lines, invalidLines)}`; - throw new Error(errorText); - } + language: ScriptingLanguage, + rules: readonly CodeValidationRule[], + analyzerFactory?: ValidationRuleAnalyzerFactory, + ): void; } -function extractLines(code: string): ICodeLine[] { +export const validateCode: CodeValidator = ( + code, + language, + rules, + analyzerFactory = createValidationAnalyzers, +) => { + const analyzers = analyzerFactory(rules); + if (!code) { + return; + } + const lines = extractLines(code); + const invalidLines = analyzers.flatMap((analyze) => analyze(lines, language)); + if (invalidLines.length === 0) { + return; + } + const errorText = `Errors with the code.\n${formatLines(lines, invalidLines)}`; + throw new Error(errorText); +}; + +function extractLines(code: string): CodeLine[] { const lines = splitTextIntoLines(code); - return lines.map((lineText, lineIndex): ICodeLine => ({ - index: lineIndex + 1, + return lines.map((lineText, lineIndex): CodeLine => ({ + lineNumber: lineIndex + 1, text: lineText, })); } -function printLines( - lines: readonly ICodeLine[], - invalidLines: readonly IInvalidCodeLine[], +function formatLines( + lines: readonly CodeLine[], + invalidLines: readonly InvalidCodeLine[], ): string { return lines.map((line) => { - const badLine = invalidLines.find((invalidLine) => invalidLine.index === line.index); + const badLine = invalidLines.find((invalidLine) => invalidLine.lineNumber === line.lineNumber); if (!badLine) { - return `[${line.index}] ✅ ${line.text}`; + return `[${line.lineNumber}] ✅ ${line.text}`; } - return `[${badLine.index}] ❌ ${line.text}\n\t⟶ ${badLine.error}`; + return `[${badLine.lineNumber}] ❌ ${line.text}\n\t⟶ ${badLine.error}`; }).join('\n'); } diff --git a/src/application/Parser/Executable/Script/Validation/ICodeLine.ts b/src/application/Parser/Executable/Script/Validation/ICodeLine.ts deleted file mode 100644 index 6a371d6ab..000000000 --- a/src/application/Parser/Executable/Script/Validation/ICodeLine.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface ICodeLine { - readonly index: number; - readonly text: string; -} diff --git a/src/application/Parser/Executable/Script/Validation/ICodeValidationRule.ts b/src/application/Parser/Executable/Script/Validation/ICodeValidationRule.ts deleted file mode 100644 index 02d3658fb..000000000 --- a/src/application/Parser/Executable/Script/Validation/ICodeValidationRule.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { ICodeLine } from './ICodeLine'; - -export interface IInvalidCodeLine { - readonly index: number; - readonly error: string; -} - -export interface ICodeValidationRule { - analyze(lines: readonly ICodeLine[]): IInvalidCodeLine[]; -} diff --git a/src/application/Parser/Executable/Script/Validation/ICodeValidator.ts b/src/application/Parser/Executable/Script/Validation/ICodeValidator.ts deleted file mode 100644 index 296f2c305..000000000 --- a/src/application/Parser/Executable/Script/Validation/ICodeValidator.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { ICodeValidationRule } from './ICodeValidationRule'; - -export interface ICodeValidator { - throwIfInvalid( - code: string, - rules: readonly ICodeValidationRule[], - ): void; -} diff --git a/src/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines.ts b/src/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines.ts deleted file mode 100644 index f61845f62..000000000 --- a/src/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax'; -import type { ICodeLine } from '../ICodeLine'; -import type { ICodeValidationRule, IInvalidCodeLine } from '../ICodeValidationRule'; - -export class NoDuplicatedLines implements ICodeValidationRule { - constructor(private readonly syntax: ILanguageSyntax) { } - - public analyze(lines: readonly ICodeLine[]): IInvalidCodeLine[] { - return lines - .map((line): IDuplicateAnalyzedLine => ({ - index: line.index, - isIgnored: shouldIgnoreLine(line.text, this.syntax), - occurrenceIndices: lines - .filter((other) => other.text === line.text) - .map((duplicatedLine) => duplicatedLine.index), - })) - .filter((line) => hasInvalidDuplicates(line)) - .map((line): IInvalidCodeLine => ({ - index: line.index, - error: `Line is duplicated at line numbers ${line.occurrenceIndices.join(',')}.`, - })); - } -} - -interface IDuplicateAnalyzedLine { - readonly index: number; - readonly occurrenceIndices: readonly number[]; - readonly isIgnored: boolean; -} - -function hasInvalidDuplicates(line: IDuplicateAnalyzedLine): boolean { - return !line.isIgnored && line.occurrenceIndices.length > 1; -} - -function shouldIgnoreLine(codeLine: string, syntax: ILanguageSyntax): boolean { - const lowerCaseCodeLine = codeLine.toLowerCase(); - const isCommentLine = () => syntax.commentDelimiters.some( - (delimiter) => lowerCaseCodeLine.startsWith(delimiter), - ); - const consistsOfFrequentCommands = () => { - const trimmed = lowerCaseCodeLine.trim().split(' '); - return trimmed.every((part) => syntax.commonCodeParts.includes(part)); - }; - return isCommentLine() || consistsOfFrequentCommands(); -} diff --git a/src/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines.ts b/src/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines.ts deleted file mode 100644 index 4b0861ad0..000000000 --- a/src/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { ICodeValidationRule, IInvalidCodeLine } from '../ICodeValidationRule'; -import type { ICodeLine } from '../ICodeLine'; - -export class NoEmptyLines implements ICodeValidationRule { - public analyze(lines: readonly ICodeLine[]): IInvalidCodeLine[] { - return lines - .filter((line) => (line.text?.trim().length ?? 0) === 0) - .map((line): IInvalidCodeLine => ({ - index: line.index, - error: (() => { - if (!line.text) { - return 'Empty line'; - } - const markedText = line.text - .replaceAll(' ', '{whitespace}') - .replaceAll('\t', '{tab}'); - return `Empty line: "${markedText}"`; - })(), - })); - } -} diff --git a/src/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax.ts b/src/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax.ts deleted file mode 100644 index 8483261e8..000000000 --- a/src/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface ILanguageSyntax { - readonly commentDelimiters: string[]; - readonly commonCodeParts: string[]; -} diff --git a/src/application/Parser/Executable/Script/Validation/Syntax/ISyntaxFactory.ts b/src/application/Parser/Executable/Script/Validation/Syntax/ISyntaxFactory.ts deleted file mode 100644 index e3c6bda03..000000000 --- a/src/application/Parser/Executable/Script/Validation/Syntax/ISyntaxFactory.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { IScriptingLanguageFactory } from '@/application/Common/ScriptingLanguage/IScriptingLanguageFactory'; -import type { ILanguageSyntax } from './ILanguageSyntax'; - -export type ISyntaxFactory = IScriptingLanguageFactory; diff --git a/src/application/Parser/Executable/Script/Validation/Syntax/ShellScriptSyntax.ts b/src/application/Parser/Executable/Script/Validation/Syntax/ShellScriptSyntax.ts deleted file mode 100644 index 9b7034238..000000000 --- a/src/application/Parser/Executable/Script/Validation/Syntax/ShellScriptSyntax.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax'; - -export class ShellScriptSyntax implements ILanguageSyntax { - public readonly commentDelimiters = ['#']; - - public readonly commonCodeParts = ['(', ')', 'else', 'fi', 'done']; -} diff --git a/src/application/Parser/Executable/Script/Validation/Syntax/SyntaxFactory.ts b/src/application/Parser/Executable/Script/Validation/Syntax/SyntaxFactory.ts deleted file mode 100644 index 31d9f02ef..000000000 --- a/src/application/Parser/Executable/Script/Validation/Syntax/SyntaxFactory.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; -import { ScriptingLanguageFactory } from '@/application/Common/ScriptingLanguage/ScriptingLanguageFactory'; -import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax'; -import { BatchFileSyntax } from './BatchFileSyntax'; -import { ShellScriptSyntax } from './ShellScriptSyntax'; -import type { ISyntaxFactory } from './ISyntaxFactory'; - -export class SyntaxFactory - extends ScriptingLanguageFactory - implements ISyntaxFactory { - constructor() { - super(); - this.registerGetter(ScriptingLanguage.batchfile, () => new BatchFileSyntax()); - this.registerGetter(ScriptingLanguage.shellscript, () => new ShellScriptSyntax()); - } -} diff --git a/src/application/Parser/Executable/Script/Validation/ValidationRuleAnalyzerFactory.ts b/src/application/Parser/Executable/Script/Validation/ValidationRuleAnalyzerFactory.ts new file mode 100644 index 000000000..ac906f092 --- /dev/null +++ b/src/application/Parser/Executable/Script/Validation/ValidationRuleAnalyzerFactory.ts @@ -0,0 +1,47 @@ +import { CodeValidationRule } from './CodeValidationRule'; +import { analyzeDuplicateLines } from './Analyzers/AnalyzeDuplicateLines'; +import { analyzeEmptyLines } from './Analyzers/AnalyzeEmptyLines'; +import { analyzeTooLongLines } from './Analyzers/AnalyzeTooLongLines'; +import type { CodeValidationAnalyzer } from './Analyzers/CodeValidationAnalyzer'; + +export interface ValidationRuleAnalyzerFactory { + ( + rules: readonly CodeValidationRule[], + ): CodeValidationAnalyzer[]; +} + +export const createValidationAnalyzers: ValidationRuleAnalyzerFactory = ( + rules, +): CodeValidationAnalyzer[] => { + if (rules.length === 0) { throw new Error('missing rules'); } + validateUniqueRules(rules); + return rules.map((rule) => createValidationRule(rule)); +}; + +function createValidationRule(rule: CodeValidationRule): CodeValidationAnalyzer { + switch (rule) { + case CodeValidationRule.NoEmptyLines: + return analyzeEmptyLines; + case CodeValidationRule.NoDuplicatedLines: + return analyzeDuplicateLines; + case CodeValidationRule.NoTooLongLines: + return analyzeTooLongLines; + default: + throw new Error(`Unknown rule: ${rule}`); + } +} + +function validateUniqueRules( + rules: readonly CodeValidationRule[], +): void { + const ruleCounts = new Map(); + rules.forEach((rule) => { + ruleCounts.set(rule, (ruleCounts.get(rule) || 0) + 1); + }); + const duplicates = Array.from(ruleCounts.entries()) + .filter(([, count]) => count > 1) + .map(([rule, count]) => `${CodeValidationRule[rule]} (${count} times)`); + if (duplicates.length > 0) { + throw new Error(`Duplicate rules are not allowed. Duplicates found: ${duplicates.join(', ')}`); + } +} diff --git a/src/domain/Collection/Validation/CompositeCategoryCollectionValidator.ts b/src/domain/Collection/Validation/CompositeCategoryCollectionValidator.ts index e7876d16b..2ed218e80 100644 --- a/src/domain/Collection/Validation/CompositeCategoryCollectionValidator.ts +++ b/src/domain/Collection/Validation/CompositeCategoryCollectionValidator.ts @@ -10,7 +10,7 @@ export type CompositeCategoryCollectionValidator = CategoryCollectionValidator & ...Parameters, (readonly CategoryCollectionValidator[])?, ] - ): void; + ): ReturnType; }; export const validateCategoryCollection: CompositeCategoryCollectionValidator = ( diff --git a/tests/unit/application/Parser/CategoryCollectionParser.spec.ts b/tests/unit/application/Parser/CategoryCollectionParser.spec.ts index c27f7cd67..6ed412f1b 100644 --- a/tests/unit/application/Parser/CategoryCollectionParser.spec.ts +++ b/tests/unit/application/Parser/CategoryCollectionParser.spec.ts @@ -6,7 +6,7 @@ import { OperatingSystem } from '@/domain/OperatingSystem'; import { EnumParserStub } from '@tests/unit/shared/Stubs/EnumParserStub'; import { ProjectDetailsStub } from '@tests/unit/shared/Stubs/ProjectDetailsStub'; import { getCategoryStub, CollectionDataStub } from '@tests/unit/shared/Stubs/CollectionDataStub'; -import { CategoryCollectionSpecificUtilitiesStub } from '@tests/unit/shared/Stubs/CategoryCollectionSpecificUtilitiesStub'; +import { CategoryCollectionContextStub } from '@tests/unit/shared/Stubs/CategoryCollectionContextStub'; import { createFunctionDataWithCode } from '@tests/unit/shared/Stubs/FunctionDataStub'; import type { CollectionData, ScriptingDefinitionData, FunctionData } from '@/application/collections/'; import type { ProjectDetails } from '@/domain/Project/ProjectDetails'; @@ -14,12 +14,12 @@ import type { NonEmptyCollectionAssertion, ObjectAssertion, TypeValidator } from import type { EnumParser } from '@/application/Common/Enum'; import type { ScriptingDefinitionParser } from '@/application/Parser/ScriptingDefinition/ScriptingDefinitionParser'; import { ScriptingDefinitionStub } from '@tests/unit/shared/Stubs/ScriptingDefinitionStub'; -import type { CategoryCollectionSpecificUtilitiesFactory } from '@/application/Parser/Executable/CategoryCollectionSpecificUtilities'; +import type { CategoryCollectionContextFactory } from '@/application/Parser/Executable/CategoryCollectionContext'; import { ScriptingDefinitionDataStub } from '@tests/unit/shared/Stubs/ScriptingDefinitionDataStub'; import { CategoryParserStub } from '@tests/unit/shared/Stubs/CategoryParserStub'; import { createCategoryCollectionFactorySpy } from '@tests/unit/shared/Stubs/CategoryCollectionFactoryStub'; import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub'; -import type { IScriptingDefinition } from '@/domain/IScriptingDefinition'; +import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; describe('CategoryCollectionParser', () => { describe('parseCategoryCollection', () => { @@ -86,12 +86,12 @@ describe('CategoryCollectionParser', () => { expect(actualActions).to.have.lengthOf(expectedActions.length); expect(actualActions).to.have.members(expectedActions); }); - describe('utilities', () => { - it('parses actions with correct utilities', () => { + describe('context', () => { + it('parses actions with correct context', () => { // arrange - const expectedUtilities = new CategoryCollectionSpecificUtilitiesStub(); - const utilitiesFactory: CategoryCollectionSpecificUtilitiesFactory = () => { - return expectedUtilities; + const expectedContext = new CategoryCollectionContextStub(); + const contextFactory: CategoryCollectionContextFactory = () => { + return expectedContext; }; const actionsData = [getCategoryStub('test1'), getCategoryStub('test2')]; const collectionData = new CollectionDataStub() @@ -99,53 +99,54 @@ describe('CategoryCollectionParser', () => { const categoryParserStub = new CategoryParserStub(); const context = new TestContext() .withData(collectionData) - .withCollectionUtilitiesFactory(utilitiesFactory) + .withCollectionContextFactory(contextFactory) .withCategoryParser(categoryParserStub.get()); // act context.parseCategoryCollection(); // assert - const usedUtilities = categoryParserStub.getUsedUtilities(); - expect(usedUtilities).to.have.lengthOf(2); - expect(usedUtilities[0]).to.equal(expectedUtilities); - expect(usedUtilities[1]).to.equal(expectedUtilities); + const actualContext = categoryParserStub.getUsedContext(); + expect(actualContext).to.have.lengthOf(2); + expect(actualContext[0]).to.equal(expectedContext); + expect(actualContext[1]).to.equal(expectedContext); }); describe('construction', () => { - it('creates utilities with correct functions data', () => { + it('creates with correct functions data', () => { // arrange const expectedFunctionsData = [createFunctionDataWithCode()]; const collectionData = new CollectionDataStub() .withFunctions(expectedFunctionsData); let actualFunctionsData: ReadonlyArray | undefined; - const utilitiesFactory: CategoryCollectionSpecificUtilitiesFactory = (data) => { + const contextFactory: CategoryCollectionContextFactory = (data) => { actualFunctionsData = data; - return new CategoryCollectionSpecificUtilitiesStub(); + return new CategoryCollectionContextStub(); }; const context = new TestContext() .withData(collectionData) - .withCollectionUtilitiesFactory(utilitiesFactory); + .withCollectionContextFactory(contextFactory); // act context.parseCategoryCollection(); // assert expect(actualFunctionsData).to.equal(expectedFunctionsData); }); - it('creates utilities with correct scripting definition', () => { + it('creates with correct language', () => { // arrange - const expectedScripting = new ScriptingDefinitionStub(); + const expectedLanguage = ScriptingLanguage.batchfile; const scriptingDefinitionParser: ScriptingDefinitionParser = () => { - return expectedScripting; + return new ScriptingDefinitionStub() + .withLanguage(expectedLanguage); }; - let actualScripting: IScriptingDefinition | undefined; - const utilitiesFactory: CategoryCollectionSpecificUtilitiesFactory = (_, scripting) => { - actualScripting = scripting; - return new CategoryCollectionSpecificUtilitiesStub(); + let actualLanguage: ScriptingLanguage | undefined; + const contextFactory: CategoryCollectionContextFactory = (_, language) => { + actualLanguage = language; + return new CategoryCollectionContextStub(); }; const context = new TestContext() - .withCollectionUtilitiesFactory(utilitiesFactory) + .withCollectionContextFactory(contextFactory) .withScriptDefinitionParser(scriptingDefinitionParser); // act context.parseCategoryCollection(); // assert - expect(actualScripting).to.equal(expectedScripting); + expect(actualLanguage).to.equal(expectedLanguage); }); }); }); @@ -245,9 +246,9 @@ class TestContext { private osParser: EnumParser = new EnumParserStub() .setupDefaultValue(OperatingSystem.Android); - private collectionUtilitiesFactory - : CategoryCollectionSpecificUtilitiesFactory = () => { - return new CategoryCollectionSpecificUtilitiesStub(); + private collectionContextFactory + : CategoryCollectionContextFactory = () => { + return new CategoryCollectionContextStub(); }; private scriptDefinitionParser: ScriptingDefinitionParser = () => new ScriptingDefinitionStub(); @@ -292,10 +293,10 @@ class TestContext { return this; } - public withCollectionUtilitiesFactory( - collectionUtilitiesFactory: CategoryCollectionSpecificUtilitiesFactory, + public withCollectionContextFactory( + collectionContextFactory: CategoryCollectionContextFactory, ): this { - this.collectionUtilitiesFactory = collectionUtilitiesFactory; + this.collectionContextFactory = collectionContextFactory; return this; } @@ -307,7 +308,7 @@ class TestContext { osParser: this.osParser, validator: this.validator, parseScriptingDefinition: this.scriptDefinitionParser, - createUtilities: this.collectionUtilitiesFactory, + createContext: this.collectionContextFactory, parseCategory: this.categoryParser, createCategoryCollection: this.categoryCollectionFactory, }, diff --git a/tests/unit/application/Parser/Executable/CategoryCollectionContext.spec.ts b/tests/unit/application/Parser/Executable/CategoryCollectionContext.spec.ts new file mode 100644 index 000000000..6ddcd197a --- /dev/null +++ b/tests/unit/application/Parser/Executable/CategoryCollectionContext.spec.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import type { FunctionData } from '@/application/collections/'; +import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests'; +import { createFunctionDataWithCode } from '@tests/unit/shared/Stubs/FunctionDataStub'; +import type { ScriptCompilerFactory } from '@/application/Parser/Executable/Script/Compiler/ScriptCompilerFactory'; +import { createCategoryCollectionContext } from '@/application/Parser/Executable/CategoryCollectionContext'; +import { createScriptCompilerFactorySpy } from '@tests/unit/shared/Stubs/ScriptCompilerFactoryStub'; + +describe('CategoryCollectionContext', () => { + describe('createCategoryCollectionContext', () => { + describe('functionsData', () => { + describe('can create with absent data', () => { + itEachAbsentCollectionValue((absentValue) => { + // arrange + const context = new TextContext() + .withData(absentValue); + // act + const act = () => context.create(); + // assert + expect(act).to.not.throw(); + }, { excludeNull: true }); + }); + }); + }); + describe('compiler', () => { + it('constructed with correct functions', () => { + // arrange + const expectedFunctions = [createFunctionDataWithCode()]; + const compilerSpy = createScriptCompilerFactorySpy(); + const context = new TextContext() + .withData(expectedFunctions) + .withScriptCompilerFactory(compilerSpy.instance); + // act + const actualContext = context.create(); + // assert + const actualCompiler = actualContext.compiler; + const compilerParameters = compilerSpy.getInitParameters(actualCompiler); + const actualFunctions = compilerParameters?.categoryContext.functions; + expect(actualFunctions).to.equal(expectedFunctions); + }); + it('constructed with correct language', () => { + // arrange + const expectedLanguage = ScriptingLanguage.batchfile; + const compilerSpy = createScriptCompilerFactorySpy(); + const context = new TextContext() + .withLanguage(expectedLanguage) + .withScriptCompilerFactory(compilerSpy.instance); + // act + const actualContext = context.create(); + // assert + const actualCompiler = actualContext.compiler; + const compilerParameters = compilerSpy.getInitParameters(actualCompiler); + const actualLanguage = compilerParameters?.categoryContext.language; + expect(actualLanguage).to.equal(expectedLanguage); + }); + }); + describe('language', () => { + it('set from syntax factory', () => { + // arrange + const expectedLanguage = ScriptingLanguage.shellscript; + const context = new TextContext() + .withLanguage(expectedLanguage); + // act + const actualContext = context.create(); + // assert + const actualLanguage = actualContext.language; + expect(actualLanguage).to.equal(expectedLanguage); + }); + }); +}); + +class TextContext { + private functionsData: readonly FunctionData[] | undefined = [createFunctionDataWithCode()]; + + private language: ScriptingLanguage = ScriptingLanguage.shellscript; + + private scriptCompilerFactory: ScriptCompilerFactory = createScriptCompilerFactorySpy().instance; + + public withScriptCompilerFactory(scriptCompilerFactory: ScriptCompilerFactory): this { + this.scriptCompilerFactory = scriptCompilerFactory; + return this; + } + + public withData(data: readonly FunctionData[] | undefined): this { + this.functionsData = data; + return this; + } + + public withLanguage(language: ScriptingLanguage): this { + this.language = language; + return this; + } + + public create(): ReturnType { + return createCategoryCollectionContext( + this.functionsData, + this.language, + this.scriptCompilerFactory, + ); + } +} diff --git a/tests/unit/application/Parser/Executable/CategoryCollectionSpecificUtilities.spec.ts b/tests/unit/application/Parser/Executable/CategoryCollectionSpecificUtilities.spec.ts deleted file mode 100644 index f09372f68..000000000 --- a/tests/unit/application/Parser/Executable/CategoryCollectionSpecificUtilities.spec.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import type { ISyntaxFactory } from '@/application/Parser/Executable/Script/Validation/Syntax/ISyntaxFactory'; -import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; -import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub'; -import { ScriptingDefinitionStub } from '@tests/unit/shared/Stubs/ScriptingDefinitionStub'; -import type { FunctionData } from '@/application/collections/'; -import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests'; -import { createFunctionDataWithCode } from '@tests/unit/shared/Stubs/FunctionDataStub'; -import { ScriptCompiler } from '@/application/Parser/Executable/Script/Compiler/ScriptCompiler'; -import { createCollectionUtilities } from '@/application/Parser/Executable/CategoryCollectionSpecificUtilities'; -import type { IScriptingDefinition } from '@/domain/IScriptingDefinition'; -import { createSyntaxFactoryStub } from '@tests/unit/shared/Stubs/SyntaxFactoryStub'; - -describe('CategoryCollectionSpecificUtilities', () => { - describe('createCollectionUtilities', () => { - describe('functionsData', () => { - describe('can create with absent data', () => { - itEachAbsentCollectionValue((absentValue) => { - // arrange - const context = new TextContext() - .withData(absentValue); - // act - const act = () => context.createCollectionUtilities(); - // assert - expect(act).to.not.throw(); - }, { excludeNull: true }); - }); - }); - }); - describe('compiler', () => { - it('constructed as expected', () => { - // arrange - const functionsData = [createFunctionDataWithCode()]; - const syntax = new LanguageSyntaxStub(); - const expected = new ScriptCompiler({ - functions: functionsData, - syntax, - }); - const language = ScriptingLanguage.shellscript; - const factoryMock = createSyntaxFactoryStub(language, syntax); - const definition = new ScriptingDefinitionStub() - .withLanguage(language); - const context = new TextContext() - .withData(functionsData) - .withScripting(definition) - .withSyntaxFactory(factoryMock); - // act - const utilities = context.createCollectionUtilities(); - // assert - const actual = utilities.compiler; - expect(actual).to.deep.equal(expected); - }); - }); - describe('syntax', () => { - it('set from syntax factory', () => { - // arrange - const language = ScriptingLanguage.shellscript; - const expected = new LanguageSyntaxStub(); - const factoryMock = createSyntaxFactoryStub(language, expected); - const definition = new ScriptingDefinitionStub() - .withLanguage(language); - const context = new TextContext() - .withScripting(definition) - .withSyntaxFactory(factoryMock); - // act - const utilities = context.createCollectionUtilities(); - // assert - const actual = utilities.syntax; - expect(actual).to.equal(expected); - }); - }); -}); - -class TextContext { - private functionsData: readonly FunctionData[] | undefined = [createFunctionDataWithCode()]; - - private scripting: IScriptingDefinition = new ScriptingDefinitionStub(); - - private syntaxFactory: ISyntaxFactory = createSyntaxFactoryStub(); - - public withScripting(scripting: IScriptingDefinition): this { - this.scripting = scripting; - return this; - } - - public withData(data: readonly FunctionData[] | undefined): this { - this.functionsData = data; - return this; - } - - public withSyntaxFactory(syntaxFactory: ISyntaxFactory): this { - this.syntaxFactory = syntaxFactory; - return this; - } - - public createCollectionUtilities(): ReturnType { - return createCollectionUtilities( - this.functionsData, - this.scripting, - this.syntaxFactory, - ); - } -} diff --git a/tests/unit/application/Parser/Executable/CategoryParser.spec.ts b/tests/unit/application/Parser/Executable/CategoryParser.spec.ts index 40a1642cc..9b77d47cc 100644 --- a/tests/unit/application/Parser/Executable/CategoryParser.spec.ts +++ b/tests/unit/application/Parser/Executable/CategoryParser.spec.ts @@ -3,7 +3,7 @@ import type { CategoryData, ExecutableData } from '@/application/collections/'; import { parseCategory } from '@/application/Parser/Executable/CategoryParser'; import { type ScriptParser } from '@/application/Parser/Executable/Script/ScriptParser'; import { type DocsParser } from '@/application/Parser/Executable/DocumentationParser'; -import { CategoryCollectionSpecificUtilitiesStub } from '@tests/unit/shared/Stubs/CategoryCollectionSpecificUtilitiesStub'; +import { CategoryCollectionContextStub } from '@tests/unit/shared/Stubs/CategoryCollectionContextStub'; import { CategoryDataStub } from '@tests/unit/shared/Stubs/CategoryDataStub'; import { ExecutableType } from '@/application/Parser/Executable/Validation/ExecutableType'; import { createScriptDataWithCall, createScriptDataWithCode } from '@tests/unit/shared/Stubs/ScriptDataStub'; @@ -357,9 +357,9 @@ describe('CategoryParser', () => { expect(actualParsedScripts.length).to.equal(expectedScripts.length); expect(actualParsedScripts).to.have.members(expectedScripts); }); - it('parses all scripts with correct utilities', () => { + it('parses all scripts with correct context', () => { // arrange - const expected = new CategoryCollectionSpecificUtilitiesStub(); + const expectedContext = new CategoryCollectionContextStub(); const scriptParser = new ScriptParserStub(); const childrenData = [ createScriptDataWithCode(), @@ -372,24 +372,24 @@ describe('CategoryParser', () => { // act const actualCategory = new TestContext() .withData(categoryData) - .withCollectionUtilities(expected) + .withCollectionContext(expectedContext) .withScriptParser(scriptParser.get()) .withCategoryFactory(categoryFactorySpy) .parseCategory(); // assert const actualParsedScripts = getInitParameters(actualCategory)?.scripts; expectExists(actualParsedScripts); - const actualUtilities = actualParsedScripts.map( + const actualContext = actualParsedScripts.map( (s) => scriptParser.getParseParameters(s)[1], ); expect( - actualUtilities.every( - (actual) => actual === expected, + actualContext.every( + (actual) => actual === expectedContext, ), formatAssertionMessage([ - `Expected all elements to be ${JSON.stringify(expected)}`, + `Expected all elements to be ${JSON.stringify(expectedContext)}`, 'All elements:', - indentText(JSON.stringify(actualUtilities)), + indentText(JSON.stringify(actualContext)), ]), ).to.equal(true); }); @@ -464,8 +464,7 @@ describe('CategoryParser', () => { class TestContext { private data: CategoryData = new CategoryDataStub(); - private collectionUtilities: - CategoryCollectionSpecificUtilitiesStub = new CategoryCollectionSpecificUtilitiesStub(); + private collectionContext: CategoryCollectionContextStub = new CategoryCollectionContextStub(); private categoryFactory: CategoryFactory = createCategoryFactorySpy().categoryFactorySpy; @@ -482,10 +481,10 @@ class TestContext { return this; } - public withCollectionUtilities( - collectionUtilities: CategoryCollectionSpecificUtilitiesStub, + public withCollectionContext( + collectionContext: CategoryCollectionContextStub, ): this { - this.collectionUtilities = collectionUtilities; + this.collectionContext = collectionContext; return this; } @@ -517,7 +516,7 @@ class TestContext { public parseCategory() { return parseCategory( this.data, - this.collectionUtilities, + this.collectionContext, { createCategory: this.categoryFactory, wrapError: this.errorWrapper, diff --git a/tests/unit/application/Parser/Executable/Script/Compiler/Function/SharedFunctionsParser.spec.ts b/tests/unit/application/Parser/Executable/Script/Compiler/Function/SharedFunctionsParser.spec.ts index adac33320..a33d31a1c 100644 --- a/tests/unit/application/Parser/Executable/Script/Compiler/Function/SharedFunctionsParser.spec.ts +++ b/tests/unit/application/Parser/Executable/Script/Compiler/Function/SharedFunctionsParser.spec.ts @@ -9,12 +9,8 @@ import { createFunctionDataWithCall, createFunctionDataWithCode, createFunctionD import { ParameterDefinitionDataStub } from '@tests/unit/shared/Stubs/ParameterDefinitionDataStub'; import { FunctionCallDataStub } from '@tests/unit/shared/Stubs/FunctionCallDataStub'; import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests'; -import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax'; -import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub'; import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub'; -import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator'; -import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines'; -import { NoDuplicatedLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines'; +import type { CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator'; import type { ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError'; import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub'; import { itThrowsContextualError } from '@tests/unit/application/Parser/Common/ContextualErrorTester'; @@ -27,6 +23,8 @@ import type { IReadOnlyFunctionParameterCollection } from '@/application/Parser/ import type { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall'; import type { FunctionParameterParser } from '@/application/Parser/Executable/Script/Compiler/Function/Parameter/FunctionParameterParser'; import { createFunctionParameterParserStub } from '@tests/unit/shared/Stubs/FunctionParameterParserStub'; +import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import { CodeValidationRule } from '@/application/Parser/Executable/Script/Validation/CodeValidationRule'; import { expectCallsFunctionBody, expectCodeFunctionBody } from './ExpectFunctionBodyType'; describe('SharedFunctionsParser', () => { @@ -161,22 +159,53 @@ describe('SharedFunctionsParser', () => { }); } }); - it('validates function code as expected when code is defined', () => { - // arrange - const expectedRules = [NoEmptyLines, NoDuplicatedLines]; - const functionData = createFunctionDataWithCode() - .withCode('expected code to be validated') - .withRevertCode('expected revert code to be validated'); - const validator = new CodeValidatorStub(); - // act - new TestContext() - .withFunctions([functionData]) - .withValidator(validator) - .parseFunctions(); - // assert - validator.assertHistory({ - validatedCodes: [functionData.code, functionData.revertCode], - rules: expectedRules, + describe('code validation', () => { + it('validates function code', () => { + // arrange + const expectedCode = 'expected code to be validated'; + const expectedRevertCode = 'expected revert code to be validated'; + const functionData = createFunctionDataWithCode() + .withCode(expectedCode) + .withRevertCode(expectedRevertCode); + const expectedCodes: readonly string[] = [expectedCode, expectedRevertCode]; + const validator = new CodeValidatorStub(); + // act + new TestContext() + .withFunctions([functionData]) + .withValidator(validator.get()) + .parseFunctions(); + // assert + validator.assertValidatedCodes(expectedCodes); + }); + it('applies correct validation rules', () => { + // arrange + const expectedRules: readonly CodeValidationRule[] = [ + CodeValidationRule.NoEmptyLines, + CodeValidationRule.NoDuplicatedLines, + ]; + const functionData = createFunctionDataWithCode(); + const validator = new CodeValidatorStub(); + // act + new TestContext() + .withFunctions([functionData]) + .withValidator(validator.get()) + .parseFunctions(); + // assert + validator.assertValidatedRules(expectedRules); + }); + it('validates for correct scripting language', () => { + // arrange + const expectedLanguage: ScriptingLanguage = ScriptingLanguage.shellscript; + const functionData = createFunctionDataWithCode(); + const validator = new CodeValidatorStub(); + // act + new TestContext() + .withFunctions([functionData]) + .withValidator(validator.get()) + .withLanguage(expectedLanguage) + .parseFunctions(); + // assert + validator.assertValidatedLanguage(expectedLanguage); }); }); describe('parameter creation', () => { @@ -406,9 +435,10 @@ describe('SharedFunctionsParser', () => { }); class TestContext { - private syntax: ILanguageSyntax = new LanguageSyntaxStub(); + private language: ScriptingLanguage = ScriptingLanguage.batchfile; - private codeValidator: ICodeValidator = new CodeValidatorStub(); + private codeValidator: CodeValidator = new CodeValidatorStub() + .get(); private functions: readonly FunctionData[] = [createFunctionDataWithCode()]; @@ -421,12 +451,12 @@ class TestContext { private parameterCollectionFactory : FunctionParameterCollectionFactory = () => new FunctionParameterCollectionStub(); - public withSyntax(syntax: ILanguageSyntax): this { - this.syntax = syntax; + public withLanguage(language: ScriptingLanguage): this { + this.language = language; return this; } - public withValidator(codeValidator: ICodeValidator): this { + public withValidator(codeValidator: CodeValidator): this { this.codeValidator = codeValidator; return this; } @@ -461,7 +491,7 @@ class TestContext { public parseFunctions(): ReturnType { return parseSharedFunctions( this.functions, - this.syntax, + this.language, { codeValidator: this.codeValidator, wrapError: this.wrapError, diff --git a/tests/unit/application/Parser/Executable/Script/Compiler/ScriptCompiler.spec.ts b/tests/unit/application/Parser/Executable/Script/Compiler/ScriptCompiler.spec.ts deleted file mode 100644 index 5e985a855..000000000 --- a/tests/unit/application/Parser/Executable/Script/Compiler/ScriptCompiler.spec.ts +++ /dev/null @@ -1,332 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import type { FunctionData } from '@/application/collections/'; -import { ScriptCompiler } from '@/application/Parser/Executable/Script/Compiler/ScriptCompiler'; -import type { FunctionCallCompiler } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/FunctionCallCompiler'; -import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub'; -import { createFunctionDataWithCode } from '@tests/unit/shared/Stubs/FunctionDataStub'; -import { FunctionCallCompilerStub } from '@tests/unit/shared/Stubs/FunctionCallCompilerStub'; -import { createSharedFunctionsParserStub } from '@tests/unit/shared/Stubs/SharedFunctionsParserStub'; -import { SharedFunctionCollectionStub } from '@tests/unit/shared/Stubs/SharedFunctionCollectionStub'; -import { parseFunctionCalls } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCallsParser'; -import { FunctionCallDataStub } from '@tests/unit/shared/Stubs/FunctionCallDataStub'; -import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax'; -import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator'; -import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub'; -import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines'; -import { CompiledCodeStub } from '@tests/unit/shared/Stubs/CompiledCodeStub'; -import { createScriptDataWithCall, createScriptDataWithCode } from '@tests/unit/shared/Stubs/ScriptDataStub'; -import type { ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError'; -import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub'; -import { ScriptCodeStub } from '@tests/unit/shared/Stubs/ScriptCodeStub'; -import type { ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory'; -import { createScriptCodeFactoryStub } from '@tests/unit/shared/Stubs/ScriptCodeFactoryStub'; -import { itThrowsContextualError } from '@tests/unit/application/Parser/Common/ContextualErrorTester'; -import type { SharedFunctionsParser } from '@/application/Parser/Executable/Script/Compiler/Function/SharedFunctionsParser'; - -describe('ScriptCompiler', () => { - describe('canCompile', () => { - it('returns true if "call" is defined', () => { - // arrange - const sut = new ScriptCompilerBuilder() - .withEmptyFunctions() - .build(); - const script = createScriptDataWithCall(); - // act - const actual = sut.canCompile(script); - // assert - expect(actual).to.equal(true); - }); - it('returns false if "call" is undefined', () => { - // arrange - const sut = new ScriptCompilerBuilder() - .withEmptyFunctions() - .build(); - const script = createScriptDataWithCode(); - // act - const actual = sut.canCompile(script); - // assert - expect(actual).to.equal(false); - }); - }); - describe('compile', () => { - it('throws if script does not have body', () => { - // arrange - const expectedError = 'Script does include any calls.'; - const scriptData = createScriptDataWithCode(); - const sut = new ScriptCompilerBuilder() - .withSomeFunctions() - .build(); - // act - const act = () => sut.compile(scriptData); - // assert - expect(act).to.throw(expectedError); - }); - describe('code construction', () => { - it('returns code from the factory', () => { - // arrange - const expectedCode = new ScriptCodeStub(); - const scriptCodeFactory = () => expectedCode; - const sut = new ScriptCompilerBuilder() - .withSomeFunctions() - .withScriptCodeFactory(scriptCodeFactory) - .build(); - // act - const actualCode = sut.compile(createScriptDataWithCall()); - // assert - expect(actualCode).to.equal(expectedCode); - }); - it('creates code correctly', () => { - // arrange - const expectedCode = 'expected-code'; - const expectedRevertCode = 'expected-revert-code'; - let actualCode: string | undefined; - let actualRevertCode: string | undefined; - const scriptCodeFactory = (code: string, revertCode: string) => { - actualCode = code; - actualRevertCode = revertCode; - return new ScriptCodeStub(); - }; - const call = new FunctionCallDataStub(); - const script = createScriptDataWithCall(call); - const functions = [createFunctionDataWithCode().withName('existing-func')]; - const compiledFunctions = new SharedFunctionCollectionStub(); - const functionParserMock = createSharedFunctionsParserStub(); - functionParserMock.setup(functions, compiledFunctions); - const callCompilerMock = new FunctionCallCompilerStub(); - callCompilerMock.setup( - parseFunctionCalls(call), - compiledFunctions, - new CompiledCodeStub() - .withCode(expectedCode) - .withRevertCode(expectedRevertCode), - ); - const sut = new ScriptCompilerBuilder() - .withFunctions(...functions) - .withSharedFunctionsParser(functionParserMock.parser) - .withFunctionCallCompiler(callCompilerMock) - .withScriptCodeFactory(scriptCodeFactory) - .build(); - // act - sut.compile(script); - // assert - expect(actualCode).to.equal(expectedCode); - expect(actualRevertCode).to.equal(expectedRevertCode); - }); - }); - - describe('parses functions as expected', () => { - it('parses functions with expected syntax', () => { - // arrange - const expected: ILanguageSyntax = new LanguageSyntaxStub(); - const functionParserMock = createSharedFunctionsParserStub(); - const sut = new ScriptCompilerBuilder() - .withSomeFunctions() - .withSyntax(expected) - .withSharedFunctionsParser(functionParserMock.parser) - .build(); - const scriptData = createScriptDataWithCall(); - // act - sut.compile(scriptData); - // assert - const parserCalls = functionParserMock.callHistory; - expect(parserCalls.length).to.equal(1); - expect(parserCalls[0].syntax).to.equal(expected); - }); - it('parses given functions', () => { - // arrange - const expectedFunctions = [createFunctionDataWithCode().withName('existing-func')]; - const functionParserMock = createSharedFunctionsParserStub(); - const sut = new ScriptCompilerBuilder() - .withFunctions(...expectedFunctions) - .withSharedFunctionsParser(functionParserMock.parser) - .build(); - const scriptData = createScriptDataWithCall(); - // act - sut.compile(scriptData); - // assert - const parserCalls = functionParserMock.callHistory; - expect(parserCalls.length).to.equal(1); - expect(parserCalls[0].functions).to.deep.equal(expectedFunctions); - }); - }); - describe('rethrows error with script name', () => { - // arrange - const scriptName = 'scriptName'; - const expectedErrorMessage = `Failed to compile script: ${scriptName}`; - const expectedInnerError = new Error(); - const callCompiler: FunctionCallCompiler = { - compileFunctionCalls: () => { throw expectedInnerError; }, - }; - const scriptData = createScriptDataWithCall() - .withName(scriptName); - const builder = new ScriptCompilerBuilder() - .withSomeFunctions() - .withFunctionCallCompiler(callCompiler); - itThrowsContextualError({ - // act - throwingAction: (wrapError) => { - builder - .withErrorWrapper(wrapError) - .build() - .compile(scriptData); - }, - // assert - expectedWrappedError: expectedInnerError, - expectedContextMessage: expectedErrorMessage, - }); - }); - describe('rethrows error from script code factory with script name', () => { - // arrange - const scriptName = 'scriptName'; - const expectedErrorMessage = `Failed to compile script: ${scriptName}`; - const expectedInnerError = new Error(); - const scriptCodeFactory: ScriptCodeFactory = () => { - throw expectedInnerError; - }; - const scriptData = createScriptDataWithCall() - .withName(scriptName); - const builder = new ScriptCompilerBuilder() - .withSomeFunctions() - .withScriptCodeFactory(scriptCodeFactory); - itThrowsContextualError({ - // act - throwingAction: (wrapError) => { - builder - .withErrorWrapper(wrapError) - .build() - .compile(scriptData); - }, - // assert - expectedWrappedError: expectedInnerError, - expectedContextMessage: expectedErrorMessage, - }); - }); - it('validates compiled code as expected', () => { - // arrange - const expectedRules = [ - NoEmptyLines, - // Allow duplicated lines to enable calling same function multiple times - ]; - const expectedExecuteCode = 'execute code to be validated'; - const expectedRevertCode = 'revert code to be validated'; - const scriptData = createScriptDataWithCall(); - const validator = new CodeValidatorStub(); - const sut = new ScriptCompilerBuilder() - .withSomeFunctions() - .withCodeValidator(validator) - .withFunctionCallCompiler( - new FunctionCallCompilerStub() - .withDefaultCompiledCode( - new CompiledCodeStub() - .withCode(expectedExecuteCode) - .withRevertCode(expectedRevertCode), - ), - ) - .build(); - // act - sut.compile(scriptData); - // assert - validator.assertHistory({ - validatedCodes: [expectedExecuteCode, expectedRevertCode], - rules: expectedRules, - }); - }); - }); -}); - -class ScriptCompilerBuilder { - private static createFunctions(...names: string[]): FunctionData[] { - return names.map((functionName) => { - return createFunctionDataWithCode().withName(functionName); - }); - } - - private functions: FunctionData[] | undefined; - - private syntax: ILanguageSyntax = new LanguageSyntaxStub(); - - private sharedFunctionsParser: SharedFunctionsParser = createSharedFunctionsParserStub().parser; - - private callCompiler: FunctionCallCompiler = new FunctionCallCompilerStub(); - - private codeValidator: ICodeValidator = new CodeValidatorStub(); - - private wrapError: ErrorWithContextWrapper = errorWithContextWrapperStub; - - private scriptCodeFactory: ScriptCodeFactory = createScriptCodeFactoryStub({ - defaultCodePrefix: ScriptCompilerBuilder.name, - }); - - public withFunctions(...functions: FunctionData[]): this { - this.functions = functions; - return this; - } - - public withSomeFunctions(): this { - this.functions = ScriptCompilerBuilder.createFunctions('test-function'); - return this; - } - - public withFunctionNames(...functionNames: string[]): this { - this.functions = ScriptCompilerBuilder.createFunctions(...functionNames); - return this; - } - - public withEmptyFunctions(): this { - this.functions = []; - return this; - } - - public withSyntax(syntax: ILanguageSyntax): this { - this.syntax = syntax; - return this; - } - - public withSharedFunctionsParser( - sharedFunctionsParser: SharedFunctionsParser, - ): this { - this.sharedFunctionsParser = sharedFunctionsParser; - return this; - } - - public withCodeValidator( - codeValidator: ICodeValidator, - ): this { - this.codeValidator = codeValidator; - return this; - } - - public withFunctionCallCompiler(callCompiler: FunctionCallCompiler): this { - this.callCompiler = callCompiler; - return this; - } - - public withErrorWrapper(wrapError: ErrorWithContextWrapper): this { - this.wrapError = wrapError; - return this; - } - - public withScriptCodeFactory(scriptCodeFactory: ScriptCodeFactory): this { - this.scriptCodeFactory = scriptCodeFactory; - return this; - } - - public build(): ScriptCompiler { - if (!this.functions) { - throw new Error('Function behavior not defined'); - } - return new ScriptCompiler( - { - functions: this.functions, - syntax: this.syntax, - }, - { - sharedFunctionsParser: this.sharedFunctionsParser, - callCompiler: this.callCompiler, - codeValidator: this.codeValidator, - wrapError: this.wrapError, - scriptCodeFactory: this.scriptCodeFactory, - }, - ); - } -} diff --git a/tests/unit/application/Parser/Executable/Script/Compiler/ScriptCompilerFactory.spec.ts b/tests/unit/application/Parser/Executable/Script/Compiler/ScriptCompilerFactory.spec.ts new file mode 100644 index 000000000..d3a4b81d5 --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Compiler/ScriptCompilerFactory.spec.ts @@ -0,0 +1,365 @@ +import { describe, it, expect } from 'vitest'; +import type { FunctionData } from '@/application/collections/'; +import { createScriptCompiler } from '@/application/Parser/Executable/Script/Compiler/ScriptCompilerFactory'; +import type { FunctionCallCompiler } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/FunctionCallCompiler'; +import { createFunctionDataWithCode } from '@tests/unit/shared/Stubs/FunctionDataStub'; +import { FunctionCallCompilerStub } from '@tests/unit/shared/Stubs/FunctionCallCompilerStub'; +import { createSharedFunctionsParserStub } from '@tests/unit/shared/Stubs/SharedFunctionsParserStub'; +import { SharedFunctionCollectionStub } from '@tests/unit/shared/Stubs/SharedFunctionCollectionStub'; +import { parseFunctionCalls } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCallsParser'; +import { FunctionCallDataStub } from '@tests/unit/shared/Stubs/FunctionCallDataStub'; +import type { CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator'; +import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub'; +import { CompiledCodeStub } from '@tests/unit/shared/Stubs/CompiledCodeStub'; +import { createScriptDataWithCall, createScriptDataWithCode } from '@tests/unit/shared/Stubs/ScriptDataStub'; +import type { ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError'; +import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub'; +import { ScriptCodeStub } from '@tests/unit/shared/Stubs/ScriptCodeStub'; +import type { ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory'; +import { createScriptCodeFactoryStub } from '@tests/unit/shared/Stubs/ScriptCodeFactoryStub'; +import { itThrowsContextualError } from '@tests/unit/application/Parser/Common/ContextualErrorTester'; +import type { SharedFunctionsParser } from '@/application/Parser/Executable/Script/Compiler/Function/SharedFunctionsParser'; +import type { ScriptCompiler } from '@/application/Parser/Executable/Script/Compiler/ScriptCompiler'; +import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import { CodeValidationRule } from '@/application/Parser/Executable/Script/Validation/CodeValidationRule'; + +describe('ScriptCompilerFactory', () => { + describe('createScriptCompiler', () => { + describe('canCompile', () => { + it('returns true if "call" is defined', () => { + // arrange + const sut = new TestContext() + .withEmptyFunctions() + .create(); + const script = createScriptDataWithCall(); + // act + const actual = sut.canCompile(script); + // assert + expect(actual).to.equal(true); + }); + it('returns false if "call" is undefined', () => { + // arrange + const sut = new TestContext() + .withEmptyFunctions() + .create(); + const script = createScriptDataWithCode(); + // act + const actual = sut.canCompile(script); + // assert + expect(actual).to.equal(false); + }); + }); + describe('compile', () => { + it('throws if script does not have body', () => { + // arrange + const expectedError = 'Script does include any calls.'; + const scriptData = createScriptDataWithCode(); + const sut = new TestContext() + .withSomeFunctions() + .create(); + // act + const act = () => sut.compile(scriptData); + // assert + expect(act).to.throw(expectedError); + }); + describe('code construction', () => { + it('returns code from the factory', () => { + // arrange + const expectedCode = new ScriptCodeStub(); + const scriptCodeFactory = () => expectedCode; + const sut = new TestContext() + .withSomeFunctions() + .withScriptCodeFactory(scriptCodeFactory) + .create(); + // act + const actualCode = sut.compile(createScriptDataWithCall()); + // assert + expect(actualCode).to.equal(expectedCode); + }); + it('creates code correctly', () => { + // arrange + const expectedCode = 'expected-code'; + const expectedRevertCode = 'expected-revert-code'; + let actualCode: string | undefined; + let actualRevertCode: string | undefined; + const scriptCodeFactory = (code: string, revertCode: string) => { + actualCode = code; + actualRevertCode = revertCode; + return new ScriptCodeStub(); + }; + const call = new FunctionCallDataStub(); + const script = createScriptDataWithCall(call); + const functions = [createFunctionDataWithCode().withName('existing-func')]; + const compiledFunctions = new SharedFunctionCollectionStub(); + const functionParserMock = createSharedFunctionsParserStub(); + functionParserMock.setup(functions, compiledFunctions); + const callCompilerMock = new FunctionCallCompilerStub(); + callCompilerMock.setup( + parseFunctionCalls(call), + compiledFunctions, + new CompiledCodeStub() + .withCode(expectedCode) + .withRevertCode(expectedRevertCode), + ); + const sut = new TestContext() + .withFunctions(...functions) + .withSharedFunctionsParser(functionParserMock.parser) + .withFunctionCallCompiler(callCompilerMock) + .withScriptCodeFactory(scriptCodeFactory) + .create(); + // act + sut.compile(script); + // assert + expect(actualCode).to.equal(expectedCode); + expect(actualRevertCode).to.equal(expectedRevertCode); + }); + }); + describe('parses functions as expected', () => { + it('parses functions with expected language', () => { + // arrange + const expectedLanguage = ScriptingLanguage.batchfile; + const functionParserMock = createSharedFunctionsParserStub(); + const sut = new TestContext() + .withSomeFunctions() + .withLanguage(expectedLanguage) + .withSharedFunctionsParser(functionParserMock.parser) + .create(); + const scriptData = createScriptDataWithCall(); + // act + sut.compile(scriptData); + // assert + const parserCalls = functionParserMock.callHistory; + expect(parserCalls.length).to.equal(1); + const actualLanguage = parserCalls[0].language; + expect(actualLanguage).to.equal(expectedLanguage); + }); + it('parses given functions', () => { + // arrange + const expectedFunctions = [createFunctionDataWithCode().withName('existing-func')]; + const functionParserMock = createSharedFunctionsParserStub(); + const sut = new TestContext() + .withFunctions(...expectedFunctions) + .withSharedFunctionsParser(functionParserMock.parser) + .create(); + const scriptData = createScriptDataWithCall(); + // act + sut.compile(scriptData); + // assert + const parserCalls = functionParserMock.callHistory; + expect(parserCalls.length).to.equal(1); + expect(parserCalls[0].functions).to.deep.equal(expectedFunctions); + }); + }); + describe('rethrows error with script name', () => { + // arrange + const scriptName = 'scriptName'; + const expectedErrorMessage = `Failed to compile script: ${scriptName}`; + const expectedInnerError = new Error(); + const callCompiler: FunctionCallCompiler = { + compileFunctionCalls: () => { throw expectedInnerError; }, + }; + const scriptData = createScriptDataWithCall() + .withName(scriptName); + const builder = new TestContext() + .withSomeFunctions() + .withFunctionCallCompiler(callCompiler); + itThrowsContextualError({ + // act + throwingAction: (wrapError) => { + builder + .withErrorWrapper(wrapError) + .create() + .compile(scriptData); + }, + // assert + expectedWrappedError: expectedInnerError, + expectedContextMessage: expectedErrorMessage, + }); + }); + describe('rethrows error from script code factory with script name', () => { + // arrange + const scriptName = 'scriptName'; + const expectedErrorMessage = `Failed to compile script: ${scriptName}`; + const expectedInnerError = new Error(); + const scriptCodeFactory: ScriptCodeFactory = () => { + throw expectedInnerError; + }; + const scriptData = createScriptDataWithCall() + .withName(scriptName); + const builder = new TestContext() + .withSomeFunctions() + .withScriptCodeFactory(scriptCodeFactory); + itThrowsContextualError({ + // act + throwingAction: (wrapError) => { + builder + .withErrorWrapper(wrapError) + .create() + .compile(scriptData); + }, + // assert + expectedWrappedError: expectedInnerError, + expectedContextMessage: expectedErrorMessage, + }); + }); + describe('compiled code validation', () => { + it('validates compiled code', () => { + // arrange + const expectedExecuteCode = 'execute code to be validated'; + const expectedRevertCode = 'revert code to be validated'; + const scriptData = createScriptDataWithCall(); + const validator = new CodeValidatorStub(); + const sut = new TestContext() + .withSomeFunctions() + .withCodeValidator(validator.get()) + .withFunctionCallCompiler( + new FunctionCallCompilerStub() + .withDefaultCompiledCode( + new CompiledCodeStub() + .withCode(expectedExecuteCode) + .withRevertCode(expectedRevertCode), + ), + ) + .create(); + // act + sut.compile(scriptData); + // assert + validator.assertValidatedCodes([ + expectedExecuteCode, expectedRevertCode, + ]); + }); + it('applies correct validation rules', () => { + // arrange + const expectedRules: readonly CodeValidationRule[] = [ + CodeValidationRule.NoEmptyLines, + CodeValidationRule.NoTooLongLines, + // Allow duplicated lines to enable calling same function multiple times + ]; + const scriptData = createScriptDataWithCall(); + const validator = new CodeValidatorStub(); + const sut = new TestContext() + .withSomeFunctions() + .withCodeValidator(validator.get()) + .create(); + // act + sut.compile(scriptData); + // assert + validator.assertValidatedRules(expectedRules); + }); + it('validates for correct scripting language', () => { + // arrange + const expectedLanguage: ScriptingLanguage = ScriptingLanguage.shellscript; + const scriptData = createScriptDataWithCall(); + const validator = new CodeValidatorStub(); + const sut = new TestContext() + .withSomeFunctions() + .withLanguage(expectedLanguage) + .withCodeValidator(validator.get()) + .create(); + // act + sut.compile(scriptData); + // assert + validator.assertValidatedLanguage(expectedLanguage); + }); + }); + }); + }); +}); + +class TestContext { + private static createFunctions(...names: string[]): FunctionData[] { + return names.map((functionName) => { + return createFunctionDataWithCode().withName(functionName); + }); + } + + private functions: FunctionData[] | undefined; + + private language: ScriptingLanguage = ScriptingLanguage.batchfile; + + private sharedFunctionsParser: SharedFunctionsParser = createSharedFunctionsParserStub().parser; + + private callCompiler: FunctionCallCompiler = new FunctionCallCompilerStub(); + + private codeValidator: CodeValidator = new CodeValidatorStub() + .get(); + + private wrapError: ErrorWithContextWrapper = errorWithContextWrapperStub; + + private scriptCodeFactory: ScriptCodeFactory = createScriptCodeFactoryStub({ + defaultCodePrefix: TestContext.name, + }); + + public withFunctions(...functions: FunctionData[]): this { + this.functions = functions; + return this; + } + + public withSomeFunctions(): this { + this.functions = TestContext.createFunctions('test-function'); + return this; + } + + public withFunctionNames(...functionNames: string[]): this { + this.functions = TestContext.createFunctions(...functionNames); + return this; + } + + public withEmptyFunctions(): this { + this.functions = []; + return this; + } + + public withLanguage(language: ScriptingLanguage): this { + this.language = language; + return this; + } + + public withSharedFunctionsParser( + sharedFunctionsParser: SharedFunctionsParser, + ): this { + this.sharedFunctionsParser = sharedFunctionsParser; + return this; + } + + public withCodeValidator( + codeValidator: CodeValidator, + ): this { + this.codeValidator = codeValidator; + return this; + } + + public withFunctionCallCompiler(callCompiler: FunctionCallCompiler): this { + this.callCompiler = callCompiler; + return this; + } + + public withErrorWrapper(wrapError: ErrorWithContextWrapper): this { + this.wrapError = wrapError; + return this; + } + + public withScriptCodeFactory(scriptCodeFactory: ScriptCodeFactory): this { + this.scriptCodeFactory = scriptCodeFactory; + return this; + } + + public create(): ScriptCompiler { + if (!this.functions) { + throw new Error('Function behavior not defined'); + } + return createScriptCompiler({ + categoryContext: { + functions: this.functions, + language: this.language, + }, + utilities: { + sharedFunctionsParser: this.sharedFunctionsParser, + callCompiler: this.callCompiler, + codeValidator: this.codeValidator, + wrapError: this.wrapError, + scriptCodeFactory: this.scriptCodeFactory, + }, + }); + } +} diff --git a/tests/unit/application/Parser/Executable/Script/ScriptParser.spec.ts b/tests/unit/application/Parser/Executable/Script/ScriptParser.spec.ts index 74b034c77..05f935322 100644 --- a/tests/unit/application/Parser/Executable/Script/ScriptParser.spec.ts +++ b/tests/unit/application/Parser/Executable/Script/ScriptParser.spec.ts @@ -8,12 +8,9 @@ import { ScriptCompilerStub } from '@tests/unit/shared/Stubs/ScriptCompilerStub' import { createScriptDataWithCall, createScriptDataWithCode, createScriptDataWithoutCallOrCodes } from '@tests/unit/shared/Stubs/ScriptDataStub'; import { EnumParserStub } from '@tests/unit/shared/Stubs/EnumParserStub'; import { ScriptCodeStub } from '@tests/unit/shared/Stubs/ScriptCodeStub'; -import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub'; import type { EnumParser } from '@/application/Common/Enum'; -import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines'; -import { NoDuplicatedLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines'; import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub'; -import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator'; +import type { CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator'; import type { ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError'; import { ErrorWrapperStub } from '@tests/unit/shared/Stubs/ErrorWrapperStub'; import type { ExecutableValidatorFactory } from '@/application/Parser/Executable/Validation/ExecutableValidator'; @@ -26,11 +23,13 @@ import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub'; import { createScriptFactorySpy } from '@tests/unit/shared/Stubs/ScriptFactoryStub'; import { expectExists } from '@tests/shared/Assertions/ExpectExists'; import { itThrowsContextualError } from '@tests/unit/application/Parser/Common/ContextualErrorTester'; -import { CategoryCollectionSpecificUtilitiesStub } from '@tests/unit/shared/Stubs/CategoryCollectionSpecificUtilitiesStub'; -import type { CategoryCollectionSpecificUtilities } from '@/application/Parser/Executable/CategoryCollectionSpecificUtilities'; +import { CategoryCollectionContextStub } from '@tests/unit/shared/Stubs/CategoryCollectionContextStub'; +import type { CategoryCollectionContext } from '@/application/Parser/Executable/CategoryCollectionContext'; import type { ObjectAssertion } from '@/application/Parser/Common/TypeValidator'; import type { ExecutableId } from '@/domain/Executables/Identifiable'; import type { ScriptFactory } from '@/domain/Executables/Script/ScriptFactory'; +import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import { CodeValidationRule } from '@/application/Parser/Executable/Script/Validation/CodeValidationRule'; import { itAsserts, itValidatesType, itValidatesName } from '../Validation/ExecutableValidationTester'; import { generateDataValidationTestScenarios } from '../Validation/DataValidationTestScenarioGenerator'; @@ -330,13 +329,13 @@ describe('ScriptParser', () => { const script = createScriptDataWithCode(); const compiler = new ScriptCompilerStub() .withCompileAbility(script, expectedCode); - const collectionUtilities = new CategoryCollectionSpecificUtilitiesStub() + const collectionContext = new CategoryCollectionContextStub() .withCompiler(compiler); const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy(); // act const actualScript = new TestContext() .withData(script) - .withCollectionUtilities(collectionUtilities) + .withCollectionContext(collectionContext) .withScriptFactory(scriptFactorySpy) .parseScript(); // assert @@ -344,33 +343,12 @@ describe('ScriptParser', () => { expect(actualCode).to.equal(expectedCode); }); }); - describe('syntax', () => { - it('set from the context', () => { // tests through script validation logic - // arrange - const commentDelimiter = 'should not throw'; - const duplicatedCode = `${commentDelimiter} duplicate-line\n${commentDelimiter} duplicate-line`; - const collectionUtilities = new CategoryCollectionSpecificUtilitiesStub() - .withSyntax(new LanguageSyntaxStub().withCommentDelimiters(commentDelimiter)); - const script = createScriptDataWithoutCallOrCodes() - .withCode(duplicatedCode); - // act - const act = () => new TestContext() - .withData(script) - .withCollectionUtilities(collectionUtilities); - // assert - expect(act).to.not.throw(); - }); - }); describe('validates a expected', () => { it('validates script with inline code (that is not compiled)', () => { // arrange - const expectedRules = [ - NoEmptyLines, - NoDuplicatedLines, - ]; const expectedCode = 'expected code to be validated'; const expectedRevertCode = 'expected revert code to be validated'; - const expectedCodeCalls = [ + const expectedCodeCalls: readonly string[] = [ expectedCode, expectedRevertCode, ]; @@ -383,35 +361,55 @@ describe('ScriptParser', () => { // act new TestContext() .withScriptCodeFactory(scriptCodeFactory) - .withCodeValidator(validator) + .withCodeValidator(validator.get()) .parseScript(); // assert - validator.assertHistory({ - validatedCodes: expectedCodeCalls, - rules: expectedRules, - }); + validator.assertValidatedCodes(expectedCodeCalls); }); it('does not validate compiled code', () => { // arrange - const expectedRules = []; - const expectedCodeCalls = []; const validator = new CodeValidatorStub(); const script = createScriptDataWithCall(); const compiler = new ScriptCompilerStub() .withCompileAbility(script, new ScriptCodeStub()); - const collectionUtilities = new CategoryCollectionSpecificUtilitiesStub() + const collectionContext = new CategoryCollectionContextStub() .withCompiler(compiler); // act new TestContext() .withData(script) - .withCodeValidator(validator) - .withCollectionUtilities(collectionUtilities) + .withCodeValidator(validator.get()) + .withCollectionContext(collectionContext) .parseScript(); // assert - validator.assertHistory({ - validatedCodes: expectedCodeCalls, - rules: expectedRules, - }); + const calls = validator.callHistory; + expect(calls).to.have.lengthOf(0); + }); + it('validates with correct rules', () => { + const expectedRules: readonly CodeValidationRule[] = [ + CodeValidationRule.NoEmptyLines, + CodeValidationRule.NoDuplicatedLines, + CodeValidationRule.NoTooLongLines, + ]; + const validator = new CodeValidatorStub(); + // act + new TestContext() + .withCodeValidator(validator.get()) + .parseScript(); + // assert + validator.assertValidatedRules(expectedRules); + }); + it('validates with correct language', () => { + const expectedLanguage: ScriptingLanguage = ScriptingLanguage.batchfile; + const validator = new CodeValidatorStub(); + const collectionContext = new CategoryCollectionContextStub() + .withLanguage(expectedLanguage); + // act + new TestContext() + .withCodeValidator(validator.get()) + .withCollectionContext(collectionContext) + .parseScript(); + // assert + validator.assertValidatedLanguage(expectedLanguage); }); }); }); @@ -461,15 +459,15 @@ describe('ScriptParser', () => { class TestContext { private data: ScriptData = createScriptDataWithCode(); - private collectionUtilities - : CategoryCollectionSpecificUtilities = new CategoryCollectionSpecificUtilitiesStub(); + private collectionContext + : CategoryCollectionContext = new CategoryCollectionContextStub(); private levelParser: EnumParser = new EnumParserStub() .setupDefaultValue(RecommendationLevel.Standard); private scriptFactory: ScriptFactory = createScriptFactorySpy().scriptFactorySpy; - private codeValidator: ICodeValidator = new CodeValidatorStub(); + private codeValidator: CodeValidator = new CodeValidatorStub().get(); private errorWrapper: ErrorWithContextWrapper = new ErrorWrapperStub().get(); @@ -481,7 +479,7 @@ class TestContext { defaultCodePrefix: TestContext.name, }); - public withCodeValidator(codeValidator: ICodeValidator): this { + public withCodeValidator(codeValidator: CodeValidator): this { this.codeValidator = codeValidator; return this; } @@ -491,10 +489,10 @@ class TestContext { return this; } - public withCollectionUtilities( - collectionUtilities: CategoryCollectionSpecificUtilities, + public withCollectionContext( + collectionContext: CategoryCollectionContext, ): this { - this.collectionUtilities = collectionUtilities; + this.collectionContext = collectionContext; return this; } @@ -531,7 +529,7 @@ class TestContext { public parseScript(): ReturnType { return parseScript( this.data, - this.collectionUtilities, + this.collectionContext, { levelParser: this.levelParser, createScript: this.scriptFactory, diff --git a/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeDuplicateLines.spec.ts b/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeDuplicateLines.spec.ts new file mode 100644 index 000000000..6694994f5 --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeDuplicateLines.spec.ts @@ -0,0 +1,196 @@ +import { describe } from 'vitest'; +import { analyzeDuplicateLines } from '@/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeDuplicateLines'; +import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub'; +import type { CodeLine, InvalidCodeLine } from '@/application/Parser/Executable/Script/Validation/Analyzers/CodeValidationAnalyzer'; +import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import type { SyntaxFactory } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/SyntaxFactory'; +import { SyntaxFactoryStub } from '@tests/unit/shared/Stubs/SyntaxFactoryStub'; +import { createCodeLines } from './CreateCodeLines'; +import { expectSameInvalidCodeLines } from './ExpectSameInvalidCodeLines'; + +describe('AnalyzeDuplicateLines', () => { + describe('analyzeDuplicateLines', () => { + it('returns no results for unique lines', () => { + // arrange + const expected = createExpectedDuplicateLineErrors([]); + const context = new TestContext() + .withLines([ + /* 1 */ 'unique1', /* 2 */ 'unique2', /* 3 */ 'unique3', /* 4 */ 'unique4', + ]); + // act + const actual = context.analyze(); + // assert + expectSameInvalidCodeLines(actual, expected); + }); + it('identifies single duplicated line', () => { + // arrange + const expected = createExpectedDuplicateLineErrors([1, 2, 4]); + const context = new TestContext() + .withLines([ + /* 1 */ 'duplicate', /* 2 */ 'duplicate', /* 3 */ 'unique', /* 4 */ 'duplicate', + ]); + // act + const actual = context.analyze(); + // assert + expectSameInvalidCodeLines(actual, expected); + }); + it('identifies multiple duplicated lines', () => { + // arrange + const expected = createExpectedDuplicateLineErrors([1, 4], [2, 6]); + const context = new TestContext() + .withLines([ + /* 1 */ 'duplicate1', /* 2 */ 'duplicate2', /* 3 */ 'unique', + /* 4 */ 'duplicate1', /* 5 */ 'unique2', /* 6 */ 'duplicate2', + ]); + // act + const actual = context.analyze(); + // assert + expectSameInvalidCodeLines(actual, expected); + }); + describe('syntax handling', () => { + it('uses correct language for syntax creation', () => { + // arrange + const expectedLanguage = ScriptingLanguage.batchfile; + let actualLanguage: ScriptingLanguage | undefined; + const factory: SyntaxFactory = (language) => { + actualLanguage = language; + return new LanguageSyntaxStub(); + }; + const context = new TestContext() + .withLanguage(expectedLanguage) + .withSyntaxFactory(factory); + // act + context.analyze(); + // assert + expect(actualLanguage).to.equal(expectedLanguage); + }); + describe('common code parts', () => { + it('ignores multiple occurrences of common code parts', () => { + // arrange + const expected = createExpectedDuplicateLineErrors([3, 4]); + const syntax = new LanguageSyntaxStub() + .withCommonCodeParts('good', 'also-good'); + const context = new TestContext() + .withLines([ + /* 1 */ 'good', /* 2 */ 'good', /* 3 */ 'bad', /* 4 */ 'bad', + /* 5 */ 'good', /* 6 */ 'also-good', /* 7 */ 'also-good', /* 8 */ 'unique', + ]) + .withSyntaxFactory(() => syntax); + // act + const actual = context.analyze(); + // assert + expectSameInvalidCodeLines(actual, expected); + }); + it('ignores common code parts used in same line', () => { + // arrange + const expected = createExpectedDuplicateLineErrors([1, 2]); + const syntax = new LanguageSyntaxStub() + .withCommonCodeParts('good2', 'good1'); + const context = new TestContext() + .withLines([ + /* 1 */ 'bad', /* 2 */ 'bad', /* 3 */ 'good1 good2', + /* 4 */ 'good1 good2', /* 5 */ 'good2 good1', /* 6 */ 'good2 good1', + ]) + .withSyntaxFactory(() => syntax); + // act + const actual = context.analyze(); + // assert + expectSameInvalidCodeLines(actual, expected); + }); + it('detects duplicates with common parts and unique words', () => { + // arrange + const expected = createExpectedDuplicateLineErrors([4, 5], [8, 9]); + const syntax = new LanguageSyntaxStub() + .withCommonCodeParts('common-part1', 'common-part2'); + const context = new TestContext() + .withLines([ + /* 1 */ 'common-part1', /* 2 */ 'common-part1', /* 3 */ 'common-part1 common-part2', + /* 4 */ 'common-part1 unique', /* 5 */ 'common-part1 unique', /* 6 */ 'common-part2', + /* 7 */ 'common-part2 common-part1', /* 8 */ 'unique common-part2', /* 9 */ 'unique common-part2', + ]) + .withSyntaxFactory(() => syntax); + // act + const actual = context.analyze(); + // assert + expectSameInvalidCodeLines(actual, expected); + }); + }); + describe('comment handling', () => { + it('ignores lines starting with comment delimiters', () => { + // arrange + const expected = createExpectedDuplicateLineErrors([3, 5]); + const syntax = new LanguageSyntaxStub() + .withCommentDelimiters('#', '//'); + const context = new TestContext() + .withLines([ + /* 1 */ '#abc', /* 2 */ '#abc', /* 3 */ 'abc', /* 4 */ 'unique', + /* 5 */ 'abc', /* 6 */ '//abc', /* 7 */ '//abc', /* 8 */ '//unique', + /* 9 */ '#unique', + ]) + .withSyntaxFactory(() => syntax); + // act + const actual = context.analyze(); + // assert + expectSameInvalidCodeLines(actual, expected); + }); + it('detects duplicates when comments are not at line start', () => { + // arrange + const expected = createExpectedDuplicateLineErrors([1, 2], [3, 4]); + const syntax = new LanguageSyntaxStub() + .withCommentDelimiters('#'); + const context = new TestContext() + .withLines([ + /* 1 */ 'test #comment', /* 2 */ 'test #comment', /* 3 */ 'test2 # comment', + /* 4 */ 'test2 # comment', + ]) + .withSyntaxFactory(() => syntax); + // act + const actual = context.analyze(); + // assert + expectSameInvalidCodeLines(actual, expected); + }); + }); + }); + }); +}); + +function createExpectedDuplicateLineErrors( + ...lines: readonly ReadonlyArray[] +): InvalidCodeLine[] { + return lines.flatMap((occurrenceIndices): readonly InvalidCodeLine[] => occurrenceIndices + .map((index): InvalidCodeLine => ({ + lineNumber: index, + error: `Line is duplicated at line numbers ${occurrenceIndices.join(',')}.`, + }))); +} + +export class TestContext { + private codeLines: readonly CodeLine[] = createCodeLines(['test-code-line']); + + private language = ScriptingLanguage.batchfile; + + private syntaxFactory: SyntaxFactory = new SyntaxFactoryStub().get(); + + public withLines(lines: readonly string[]): this { + this.codeLines = createCodeLines(lines); + return this; + } + + public withLanguage(language: ScriptingLanguage): this { + this.language = language; + return this; + } + + public withSyntaxFactory(syntaxFactory: SyntaxFactory): this { + this.syntaxFactory = syntaxFactory; + return this; + } + + public analyze() { + return analyzeDuplicateLines( + this.codeLines, + this.language, + this.syntaxFactory, + ); + } +} diff --git a/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeEmptyLines.spec.ts b/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeEmptyLines.spec.ts new file mode 100644 index 000000000..1b6dbacbb --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeEmptyLines.spec.ts @@ -0,0 +1,115 @@ +import { describe } from 'vitest'; +import { analyzeEmptyLines } from '@/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeEmptyLines'; +import type { CodeLine, InvalidCodeLine } from '@/application/Parser/Executable/Script/Validation/Analyzers/CodeValidationAnalyzer'; +import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import { createCodeLines } from './CreateCodeLines'; +import { expectSameInvalidCodeLines } from './ExpectSameInvalidCodeLines'; + +describe('AnalyzeEmptyLines', () => { + describe('analyzeEmptyLines', () => { + it('returns no results for non-empty lines', () => { + // arrange + const expected: InvalidCodeLine[] = []; + const context = new TestContext() + .withLines([ + /* 1 */ 'non-empty-line1', /* 2 */ 'none-empty-line2', + ]); + // act + const actual = context.analyze(); + // assert + expectSameInvalidCodeLines(actual, expected); + }); + it('identifies single empty line', () => { + // arrange + const expected: InvalidCodeLine[] = [ + { lineNumber: 2, error: 'Empty line' }, + ]; + const context = new TestContext() + .withLines([ + /* 1 */ 'first line', /* 2 */ '', /* 3 */ 'third line', + ]); + // act + const actual = context.analyze(); + // assert + expectSameInvalidCodeLines(actual, expected); + }); + it('identifies multiple empty lines', () => { + // arrange + const expected: InvalidCodeLine[] = [2, 4].map((index): InvalidCodeLine => ({ lineNumber: index, error: 'Empty line' })); + const context = new TestContext() + .withLines([ + /* 1 */ 'first line', /* 2 */ '', /* 3 */ 'third line', /* 4 */ '', + ]); + // act + const actual = context.analyze(); + // assert + expectSameInvalidCodeLines(actual, expected); + }); + it('identifies lines with only spaces', () => { + // arrange + const expected: InvalidCodeLine[] = [ + { lineNumber: 2, error: 'Empty line: "{whitespace}{whitespace}"' }, + ]; + const context = new TestContext() + .withLines([ + /* 1 */ 'first line', /* 2 */ ' ', /* 3 */ 'third line', + ]); + // act + const actual = context.analyze(); + // assert + expectSameInvalidCodeLines(actual, expected); + }); + it('identifies lines with only tabs', () => { + // arrange + const expected: InvalidCodeLine[] = [ + { lineNumber: 2, error: 'Empty line: "{tab}{tab}"' }, + ]; + const context = new TestContext() + .withLines([ + /* 1 */ 'first line', /* 2 */ '\t\t', /* 3 */ 'third line', + ]); + // act + const actual = context.analyze(); + // assert + expectSameInvalidCodeLines(actual, expected); + }); + it('identifies lines with mixed spaces and tabs', () => { + // arrange + const expected: InvalidCodeLine[] = [ + { lineNumber: 2, error: 'Empty line: "{tab}{whitespace}{tab}"' }, + { lineNumber: 4, error: 'Empty line: "{whitespace}{tab}{whitespace}"' }, + ]; + const context = new TestContext() + .withLines([ + /* 1 */ 'first line', /* 2 */ '\t \t', /* 3 */ 'third line', /* 4 */ ' \t ', + ]); + // act + const actual = context.analyze(); + // assert + expectSameInvalidCodeLines(actual, expected); + }); + }); +}); + +export class TestContext { + private codeLines: readonly CodeLine[] = createCodeLines(['test-code-line']); + + private language = ScriptingLanguage.batchfile; + + public withLines(lines: readonly string[]): this { + this.codeLines = createCodeLines(lines); + return this; + } + + public withLanguage(language: ScriptingLanguage): this { + this.language = language; + return this; + } + + public analyze() { + return analyzeEmptyLines( + this.codeLines, + this.language, + ); + } +} diff --git a/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeTooLongLines.spec.ts b/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeTooLongLines.spec.ts new file mode 100644 index 000000000..ac4cd1209 --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeTooLongLines.spec.ts @@ -0,0 +1,184 @@ +import { describe } from 'vitest'; +import type { CodeLine, InvalidCodeLine } from '@/application/Parser/Executable/Script/Validation/Analyzers/CodeValidationAnalyzer'; +import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import { analyzeTooLongLines } from '@/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeTooLongLines'; +import { createCodeLines } from './CreateCodeLines'; +import { expectSameInvalidCodeLines } from './ExpectSameInvalidCodeLines'; + +describe('AnalyzeTooLongLines', () => { + describe('analyzeTooLongLines', () => { + describe('batchfile', () => { + const MAX_BATCHFILE_LENGTH = 8191; + + it('returns no results for lines within the maximum length', () => { + // arrange + const expected: InvalidCodeLine[] = []; + const context = new TestContext() + .withLanguage(ScriptingLanguage.batchfile) + .withLines([ + 'A'.repeat(MAX_BATCHFILE_LENGTH), + 'B'.repeat(8000), + 'C'.repeat(100), + ]); + // act + const actual = context.analyze(); + // assert + expectSameInvalidCodeLines(actual, expected); + }); + + it('identifies a single line exceeding maximum length', () => { + // arrange + const expectedLength = MAX_BATCHFILE_LENGTH + 1; + const expected: InvalidCodeLine[] = [{ + lineNumber: 2, + error: createTooLongLineError(expectedLength, MAX_BATCHFILE_LENGTH), + }]; + const context = new TestContext() + .withLanguage(ScriptingLanguage.batchfile) + .withLines([ + 'A'.repeat(MAX_BATCHFILE_LENGTH), + 'B'.repeat(expectedLength), + 'C'.repeat(100), + ]); + // act + const actual = context.analyze(); + // assert + expectSameInvalidCodeLines(actual, expected); + }); + + it('identifies multiple lines exceeding maximum length', () => { + // arrange + const expectedLength1 = MAX_BATCHFILE_LENGTH + 1; + const expectedLength2 = MAX_BATCHFILE_LENGTH + 2; + const expected: InvalidCodeLine[] = [ + { + lineNumber: 1, + error: createTooLongLineError(expectedLength1, MAX_BATCHFILE_LENGTH), + }, + { + lineNumber: 3, + error: createTooLongLineError(expectedLength2, MAX_BATCHFILE_LENGTH), + }, + ]; + const context = new TestContext() + .withLanguage(ScriptingLanguage.batchfile) + .withLines([ + 'A'.repeat(expectedLength1), + 'B'.repeat(MAX_BATCHFILE_LENGTH), + 'C'.repeat(expectedLength2), + ]); + // act + const actual = context.analyze(); + // assert + expectSameInvalidCodeLines(actual, expected); + }); + }); + + describe('shellscript', () => { + const MAX_SHELLSCRIPT_LENGTH = 1048576; + + it('returns no results for lines within the maximum length', () => { + // arrange + const expected: InvalidCodeLine[] = []; + const context = new TestContext() + .withLanguage(ScriptingLanguage.shellscript) + .withLines([ + 'A'.repeat(MAX_SHELLSCRIPT_LENGTH), + 'B'.repeat(1000000), + 'C'.repeat(100), + ]); + // act + const actual = context.analyze(); + // assert + expectSameInvalidCodeLines(actual, expected); + }); + + it('identifies a single line exceeding maximum length', () => { + // arrange + const expectedLength = MAX_SHELLSCRIPT_LENGTH + 1; + const expected: InvalidCodeLine[] = [{ + lineNumber: 2, + error: createTooLongLineError(expectedLength, MAX_SHELLSCRIPT_LENGTH), + }]; + const context = new TestContext() + .withLanguage(ScriptingLanguage.shellscript) + .withLines([ + 'A'.repeat(MAX_SHELLSCRIPT_LENGTH), + 'B'.repeat(expectedLength), + 'C'.repeat(100), + ]); + // act + const actual = context.analyze(); + // assert + expectSameInvalidCodeLines(actual, expected); + }); + + it('identifies multiple lines exceeding maximum length', () => { + // arrange + const expectedLength1 = MAX_SHELLSCRIPT_LENGTH + 1; + const expectedLength2 = MAX_SHELLSCRIPT_LENGTH + 2; + const expected: InvalidCodeLine[] = [ + { + lineNumber: 1, + error: createTooLongLineError(expectedLength1, MAX_SHELLSCRIPT_LENGTH), + }, + { + lineNumber: 3, + error: createTooLongLineError(expectedLength2, MAX_SHELLSCRIPT_LENGTH), + }, + ]; + const context = new TestContext() + .withLanguage(ScriptingLanguage.shellscript) + .withLines([ + 'A'.repeat(expectedLength1), + 'B'.repeat(MAX_SHELLSCRIPT_LENGTH), + 'C'.repeat(expectedLength2), + ]); + // act + const actual = context.analyze(); + // assert + expectSameInvalidCodeLines(actual, expected); + }); + }); + + it('throws an error for unsupported language', () => { + // arrange + const context = new TestContext() + .withLanguage('unsupported' as unknown as ScriptingLanguage) + .withLines(['A', 'B', 'C']); + // act & assert + expect(() => context.analyze()).to.throw('Unsupported language: unsupported'); + }); + }); +}); + +function createTooLongLineError(actualLength: number, maxAllowedLength: number): string { + return [ + `Line is too long (${actualLength}).`, + `It exceed maximum allowed length ${maxAllowedLength}.`, + 'This may cause bugs due to unintended trimming by operating system, shells or terminal emulators.', + ].join(' '); +} + +class TestContext { + private codeLines: readonly CodeLine[] = createCodeLines(['test-code-line']); + + private language = ScriptingLanguage.batchfile; + + public withLines(lines: readonly string[]): this { + this.codeLines = createCodeLines(lines); + return this; + } + + public withLanguage(language: ScriptingLanguage): this { + this.language = language; + return this; + } + + public analyze() { + return analyzeTooLongLines( + this.codeLines, + this.language, + ); + } +} diff --git a/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/CreateCodeLines.ts b/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/CreateCodeLines.ts new file mode 100644 index 000000000..5ace22195 --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/CreateCodeLines.ts @@ -0,0 +1,10 @@ +import type { CodeLine } from '@/application/Parser/Executable/Script/Validation/Analyzers/CodeValidationAnalyzer'; + +export function createCodeLines(lines: readonly string[]): CodeLine[] { + return lines.map((lineText, index): CodeLine => ( + { + lineNumber: index + 1, + text: lineText, + } + )); +} diff --git a/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/ExpectSameInvalidCodeLines.ts b/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/ExpectSameInvalidCodeLines.ts new file mode 100644 index 000000000..948489e73 --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/ExpectSameInvalidCodeLines.ts @@ -0,0 +1,13 @@ +import { expect } from 'vitest'; +import type { InvalidCodeLine } from '@/application/Parser/Executable/Script/Validation/Analyzers/CodeValidationAnalyzer'; + +export function expectSameInvalidCodeLines( + expected: readonly InvalidCodeLine[], + actual: readonly InvalidCodeLine[], +) { + expect(sort(expected)).to.deep.equal(sort(actual)); +} + +function sort(lines: readonly InvalidCodeLine[]) { // To ignore order + return Array.from(lines).sort((a, b) => a.lineNumber - b.lineNumber); +} diff --git a/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/Syntax/BatchFileSyntax.spec.ts b/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/Syntax/BatchFileSyntax.spec.ts new file mode 100644 index 000000000..fbc6a1818 --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/Syntax/BatchFileSyntax.spec.ts @@ -0,0 +1,9 @@ +import { describe } from 'vitest'; +import { BatchFileSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/BatchFileSyntax'; +import { runLanguageSyntaxTests } from './LanguageSyntaxTestRunner'; + +describe('BatchFileSyntax', () => { + runLanguageSyntaxTests( + () => new BatchFileSyntax(), + ); +}); diff --git a/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntaxTestRunner.ts b/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntaxTestRunner.ts new file mode 100644 index 000000000..ffda8f764 --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntaxTestRunner.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import type { LanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax'; + +export function runLanguageSyntaxTests(createSyntax: () => LanguageSyntax) { + describe('commentDelimiters', () => { + it('returns defined value', () => { + // arrange + const sut = createSyntax(); + // act + const value = sut.commentDelimiters; + // assert + expect(value); + }); + }); + describe('commonCodeParts', () => { + it('returns defined value', () => { + // arrange + const sut = createSyntax(); + // act + const value = sut.commonCodeParts; + // assert + expect(value); + }); + }); +} diff --git a/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/Syntax/ShellScriptSyntax.spec.ts b/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/Syntax/ShellScriptSyntax.spec.ts new file mode 100644 index 000000000..48efb2d86 --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/Syntax/ShellScriptSyntax.spec.ts @@ -0,0 +1,9 @@ +import { describe } from 'vitest'; +import { ShellScriptSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/ShellScriptSyntax'; +import { runLanguageSyntaxTests } from './LanguageSyntaxTestRunner'; + +describe('ShellScriptSyntax', () => { + runLanguageSyntaxTests( + () => new ShellScriptSyntax(), + ); +}); diff --git a/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/Syntax/SyntaxFactory.spec.ts b/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/Syntax/SyntaxFactory.spec.ts new file mode 100644 index 000000000..0f83e48c7 --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Validation/Analyzers/Syntax/SyntaxFactory.spec.ts @@ -0,0 +1,38 @@ +import { describe } from 'vitest'; +import { createSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/SyntaxFactory'; +import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import { ShellScriptSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/ShellScriptSyntax'; +import { BatchFileSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/BatchFileSyntax'; +import type { LanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax'; +import type { Constructible } from '@/TypeHelpers'; + +describe('SyntaxFactory', () => { + describe('createSyntax', () => { + it('throws given invalid language', () => { + // arrange + const invalidLanguage = 5 as ScriptingLanguage; + const expectedErrorMessage = `Invalid language: "${ScriptingLanguage[invalidLanguage]}"`; + // act + const act = () => createSyntax(invalidLanguage); + // assert + expect(act).to.throw(expectedErrorMessage); + }); + describe('creates syntax for supported languages', () => { + const languageTestScenarios: Record> = { + [ScriptingLanguage.batchfile]: BatchFileSyntax, + [ScriptingLanguage.shellscript]: ShellScriptSyntax, + }; + Object.entries(languageTestScenarios).forEach(([key, value]) => { + // arrange + const scriptingLanguage = Number(key) as ScriptingLanguage; + const expectedType = value; + it(`gets correct type for "${ScriptingLanguage[scriptingLanguage]}" language`, () => { + // act + const syntax = createSyntax(scriptingLanguage); + // assert + expect(syntax).to.be.instanceOf(expectedType); + }); + }); + }); + }); +}); diff --git a/tests/unit/application/Parser/Executable/Script/Validation/CodeValidator.spec.ts b/tests/unit/application/Parser/Executable/Script/Validation/CodeValidator.spec.ts index edd8431eb..1854c4cd1 100644 --- a/tests/unit/application/Parser/Executable/Script/Validation/CodeValidator.spec.ts +++ b/tests/unit/application/Parser/Executable/Script/Validation/CodeValidator.spec.ts @@ -1,182 +1,226 @@ import { describe, it, expect } from 'vitest'; -import { CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator'; -import { CodeValidationRuleStub } from '@tests/unit/shared/Stubs/CodeValidationRuleStub'; -import { itEachAbsentCollectionValue, itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests'; -import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests'; -import type { ICodeLine } from '@/application/Parser/Executable/Script/Validation/ICodeLine'; -import type { ICodeValidationRule, IInvalidCodeLine } from '@/application/Parser/Executable/Script/Validation/ICodeValidationRule'; +import { CodeValidationAnalyzerStub } from '@tests/unit/shared/Stubs/CodeValidationAnalyzerStub'; +import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests'; import { indentText } from '@/application/Common/Text/IndentText'; +import type { CodeLine, InvalidCodeLine } from '@/application/Parser/Executable/Script/Validation/Analyzers/CodeValidationAnalyzer'; +import { validateCode } from '@/application/Parser/Executable/Script/Validation/CodeValidator'; +import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import { CodeValidationRule } from '@/application/Parser/Executable/Script/Validation/CodeValidationRule'; +import type { ValidationRuleAnalyzerFactory } from '@/application/Parser/Executable/Script/Validation/ValidationRuleAnalyzerFactory'; -describe('CodeValidator', () => { - describe('instance', () => { - itIsSingletonFactory({ - getter: () => CodeValidator.instance, - expectedType: CodeValidator, - }); +describe('validateCode', () => { + describe('does not throw if code is absent', () => { + itEachAbsentStringValue((absentValue) => { + // arrange + const code = absentValue; + // act + const act = () => new TestContext() + .withCode(code) + .validate(); + // assert + expect(act).to.not.throw(); + }, { excludeNull: true, excludeUndefined: true }); }); - describe('throwIfInvalid', () => { - describe('does not throw if code is absent', () => { - itEachAbsentStringValue((absentValue) => { - // arrange - const code = absentValue; - const sut = new CodeValidator(); - // act - const act = () => sut.throwIfInvalid(code, [new CodeValidationRuleStub()]); - // assert - expect(act).to.not.throw(); - }, { excludeNull: true, excludeUndefined: true }); + describe('line splitting', () => { + it('supports all line separators', () => { + // arrange + const expectedLineTexts = ['line1', 'line2', 'line3', 'line4']; + const code = 'line1\r\nline2\rline3\nline4'; + const analyzer = new CodeValidationAnalyzerStub(); + const analyzerFactory: ValidationRuleAnalyzerFactory = () => [analyzer.get()]; + // act + new TestContext() + .withCode(code) + .withAnalyzerFactory(analyzerFactory) + .validate(); + // expect + expect(analyzer.receivedLines).has.lengthOf(1); + const actualLineTexts = analyzer.receivedLines[0].map((line) => line.text); + expect(actualLineTexts).to.deep.equal(expectedLineTexts); + }); + it('uses 1-indexed line numbering', () => { + // arrange + const expectedLineNumbers = [1, 2, 3]; + const code = ['line1', 'line2', 'line3'].join('\n'); + const analyzer = new CodeValidationAnalyzerStub(); + const analyzerFactory: ValidationRuleAnalyzerFactory = () => [analyzer.get()]; + // act + new TestContext() + .withCode(code) + .withAnalyzerFactory(analyzerFactory) + .validate(); + // expect + expect(analyzer.receivedLines).has.lengthOf(1); + const actualLineIndexes = analyzer.receivedLines[0].map((line) => line.lineNumber); + expect(actualLineIndexes).to.deep.equal(expectedLineNumbers); + }); + it('includes empty lines in count', () => { + // arrange + const expectedEmptyLineCount = 4; + const code = '\n'.repeat(expectedEmptyLineCount - 1); + const analyzer = new CodeValidationAnalyzerStub(); + const analyzerFactory: ValidationRuleAnalyzerFactory = () => [analyzer.get()]; + // act + new TestContext() + .withCode(code) + .withAnalyzerFactory(analyzerFactory) + .validate(); + // expect + expect(analyzer.receivedLines).has.lengthOf(1); + const actualLines = analyzer.receivedLines[0]; + expect(actualLines).to.have.lengthOf(expectedEmptyLineCount); }); - describe('throws if rules are empty', () => { - itEachAbsentCollectionValue((absentValue) => { - // arrange - const expectedError = 'missing rules'; - const rules = absentValue; - const sut = new CodeValidator(); - // act - const act = () => sut.throwIfInvalid('code', rules); - // assert - expect(act).to.throw(expectedError); - }, { excludeUndefined: true, excludeNull: true }); + it('correctly matches line numbers with text', () => { + // arrange + const expected: readonly CodeLine[] = [ + { lineNumber: 1, text: 'first' }, + { lineNumber: 2, text: 'second' }, + ]; + const code = expected.map((line) => line.text).join('\n'); + const analyzer = new CodeValidationAnalyzerStub(); + const analyzerFactory: ValidationRuleAnalyzerFactory = () => [analyzer.get()]; + // act + new TestContext() + .withCode(code) + .withAnalyzerFactory(analyzerFactory) + .validate(); + // expect + expect(analyzer.receivedLines).has.lengthOf(1); + expect(analyzer.receivedLines[0]).to.deep.equal(expected); }); - describe('splits lines as expected', () => { - it('supports all line separators', () => { - // arrange - const expectedLineTexts = ['line1', 'line2', 'line3', 'line4']; - const code = 'line1\r\nline2\rline3\nline4'; - const spy = new CodeValidationRuleStub(); - const sut = new CodeValidator(); - // act - sut.throwIfInvalid(code, [spy]); - // expect - expect(spy.receivedLines).has.lengthOf(1); - const actualLineTexts = spy.receivedLines[0].map((line) => line.text); - expect(actualLineTexts).to.deep.equal(expectedLineTexts); - }); - it('uses 1-indexed line numbering', () => { - // arrange - const expectedIndexes = [1, 2, 3]; - const code = ['line1', 'line2', 'line3'].join('\n'); - const spy = new CodeValidationRuleStub(); - const sut = new CodeValidator(); - // act - sut.throwIfInvalid(code, [spy]); - // expect - expect(spy.receivedLines).has.lengthOf(1); - const actualLineIndexes = spy.receivedLines[0].map((line) => line.index); - expect(actualLineIndexes).to.deep.equal(expectedIndexes); - }); - it('counts empty lines', () => { - // arrange - const expectedTotalEmptyLines = 4; - const code = '\n'.repeat(expectedTotalEmptyLines - 1); - const spy = new CodeValidationRuleStub(); - const sut = new CodeValidator(); - // act - sut.throwIfInvalid(code, [spy]); - // expect - expect(spy.receivedLines).has.lengthOf(1); - const actualLines = spy.receivedLines[0]; - expect(actualLines).to.have.lengthOf(expectedTotalEmptyLines); - }); - it('matches texts with indexes as expected', () => { - // arrange - const expected: readonly ICodeLine[] = [ - { index: 1, text: 'first' }, - { index: 2, text: 'second' }, - ]; - const code = expected.map((line) => line.text).join('\n'); - const spy = new CodeValidationRuleStub(); - const sut = new CodeValidator(); - // act - sut.throwIfInvalid(code, [spy]); - // expect - expect(spy.receivedLines).has.lengthOf(1); - expect(spy.receivedLines[0]).to.deep.equal(expected); - }); + }); + it('analyzes lines for correct language', () => { + // arrange + const expectedLanguage = ScriptingLanguage.batchfile; + const analyzers = [ + new CodeValidationAnalyzerStub(), + new CodeValidationAnalyzerStub(), + new CodeValidationAnalyzerStub(), + ]; + const analyzerFactory: ValidationRuleAnalyzerFactory = () => analyzers.map((s) => s.get()); + // act + new TestContext() + .withAnalyzerFactory(analyzerFactory) + .validate(); + // assert + const actualLanguages = analyzers.flatMap((a) => a.receivedLanguages); + const unexpectedLanguages = actualLanguages.filter((l) => l !== expectedLanguage); + expect(unexpectedLanguages).to.have.lengthOf(0); + }); + describe('throwing invalid lines', () => { + it('throws error for invalid line from single rule', () => { + // arrange + const errorText = 'error'; + const expectedError = constructExpectedValidationErrorMessage([ + { text: 'line1' }, + { text: 'line2', error: errorText }, + { text: 'line3' }, + { text: 'line4' }, + ]); + const code = ['line1', 'line2', 'line3', 'line4'].join('\n'); + const invalidLines: readonly InvalidCodeLine[] = [ + { lineNumber: 2, error: errorText }, + ]; + const invalidAnalyzer = new CodeValidationAnalyzerStub() + .withReturnValue(invalidLines); + const noopAnalyzer = new CodeValidationAnalyzerStub() + .withReturnValue([]); + const analyzerFactory: ValidationRuleAnalyzerFactory = () => [ + invalidAnalyzer, noopAnalyzer, + ].map((s) => s.get()); + // act + const act = () => new TestContext() + .withCode(code) + .withAnalyzerFactory(analyzerFactory) + .validate(); + // assert + expect(act).to.throw(expectedError); }); - describe('throws invalid lines as expected', () => { - it('throws with invalid line from single rule', () => { - // arrange - const errorText = 'error'; - const expectedError = new ExpectedErrorBuilder() - .withOkLine('line1') - .withErrorLine('line2', errorText) - .withOkLine('line3') - .withOkLine('line4') - .buildError(); - const code = ['line1', 'line2', 'line3', 'line4'].join('\n'); - const invalidLines: readonly IInvalidCodeLine[] = [ - { index: 2, error: errorText }, - ]; - const rule = new CodeValidationRuleStub() - .withReturnValue(invalidLines); - const noopRule = new CodeValidationRuleStub() - .withReturnValue([]); - const sut = new CodeValidator(); - // act - const act = () => sut.throwIfInvalid(code, [rule, noopRule]); - // assert - expect(act).to.throw(expectedError); - }); - it('throws with combined invalid lines from multiple rules', () => { - // arrange - const firstError = 'firstError'; - const secondError = 'firstError'; - const expectedError = new ExpectedErrorBuilder() - .withOkLine('line1') - .withErrorLine('line2', firstError) - .withOkLine('line3') - .withErrorLine('line4', secondError) - .buildError(); - const code = ['line1', 'line2', 'line3', 'line4'].join('\n'); - const firstRuleError: readonly IInvalidCodeLine[] = [ - { index: 2, error: firstError }, - ]; - const secondRuleError: readonly IInvalidCodeLine[] = [ - { index: 4, error: secondError }, - ]; - const firstRule = new CodeValidationRuleStub().withReturnValue(firstRuleError); - const secondRule = new CodeValidationRuleStub().withReturnValue(secondRuleError); - const sut = new CodeValidator(); - // act - const act = () => sut.throwIfInvalid(code, [firstRule, secondRule]); - // assert - expect(act).to.throw(expectedError); - }); + it('throws error with combined invalid lines from multiple rules', () => { + // arrange + const firstError = 'firstError'; + const secondError = 'firstError'; + const expectedError = constructExpectedValidationErrorMessage([ + { text: 'line1' }, + { text: 'line2', error: firstError }, + { text: 'line3' }, + { text: 'line4', error: secondError }, + ]); + const code = ['line1', 'line2', 'line3', 'line4'].join('\n'); + const firstRuleError: readonly InvalidCodeLine[] = [ + { lineNumber: 2, error: firstError }, + ]; + const secondRuleError: readonly InvalidCodeLine[] = [ + { lineNumber: 4, error: secondError }, + ]; + const firstRule = new CodeValidationAnalyzerStub().withReturnValue(firstRuleError); + const secondRule = new CodeValidationAnalyzerStub().withReturnValue(secondRuleError); + const analyzerFactory: ValidationRuleAnalyzerFactory = () => [ + firstRule, secondRule, + ].map((s) => s.get()); + // act + const act = () => new TestContext() + .withCode(code) + .withAnalyzerFactory(analyzerFactory) + .validate(); + // assert + expect(act).to.throw(expectedError); }); }); }); -class ExpectedErrorBuilder { - private lineCount = 0; +function constructExpectedValidationErrorMessage( + lines: readonly { + readonly text: string, + readonly error?: string, + }[], +): string { + return [ + 'Errors with the code.', + ...lines.flatMap((line, index): string[] => { + const textPrefix = line.error ? '❌' : '✅'; + const lineNumber = `[${index + 1}]`; + const formattedLine = `${lineNumber} ${textPrefix} ${line.text}`; + return [ + formattedLine, + ...(line.error ? [indentText(`⟶ ${line.error}`)] : []), + ]; + }), + ].join('\n'); +} - private outputLines = new Array(); +class TestContext { + private code = `[${TestContext.name}] code`; - public withOkLine(text: string) { - return this.withNumberedLine(`✅ ${text}`); - } + private language: ScriptingLanguage = ScriptingLanguage.batchfile; + + private rules: readonly CodeValidationRule[] = [CodeValidationRule.NoDuplicatedLines]; + + private analyzerFactory: ValidationRuleAnalyzerFactory = () => [ + new CodeValidationAnalyzerStub().get(), + ]; - public withErrorLine(text: string, error: string) { - return this - .withNumberedLine(`❌ ${text}`) - .withLine(indentText(`⟶ ${error}`)); + public withCode(code: string): this { + this.code = code; + return this; } - public buildError(): string { - return [ - 'Errors with the code.', - ...this.outputLines, - ].join('\n'); + public withRules(rules: readonly CodeValidationRule[]): this { + this.rules = rules; + return this; } - private withLine(line: string) { - this.outputLines.push(line); + public withAnalyzerFactory(analyzerFactory: ValidationRuleAnalyzerFactory): this { + this.analyzerFactory = analyzerFactory; return this; } - private withNumberedLine(text: string) { - this.lineCount += 1; - const lineNumber = `[${this.lineCount}]`; - return this.withLine(`${lineNumber} ${text}`); + public validate(): ReturnType { + return validateCode( + this.code, + this.language, + this.rules, + this.analyzerFactory, + ); } } diff --git a/tests/unit/application/Parser/Executable/Script/Validation/Rules/CodeValidationRuleTestRunner.ts b/tests/unit/application/Parser/Executable/Script/Validation/Rules/CodeValidationRuleTestRunner.ts deleted file mode 100644 index 6c1633000..000000000 --- a/tests/unit/application/Parser/Executable/Script/Validation/Rules/CodeValidationRuleTestRunner.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { it, expect } from 'vitest'; -import type { ICodeValidationRule, IInvalidCodeLine } from '@/application/Parser/Executable/Script/Validation/ICodeValidationRule'; -import type { ICodeLine } from '@/application/Parser/Executable/Script/Validation/ICodeLine'; - -interface ICodeValidationRuleTestCase { - testName: string; - codeLines: readonly string[]; - expected: readonly IInvalidCodeLine[]; - sut: ICodeValidationRule; -} - -export function testCodeValidationRule(testCases: readonly ICodeValidationRuleTestCase[]) { - for (const testCase of testCases) { - it(testCase.testName, () => { - // arrange - const { sut } = testCase; - const codeLines = createCodeLines(testCase.codeLines); - // act - const actual = sut.analyze(codeLines); - // assert - function sort(lines: readonly IInvalidCodeLine[]) { // To ignore order - return Array.from(lines).sort((a, b) => a.index - b.index); - } - expect(sort(actual)).to.deep.equal(sort(testCase.expected)); - }); - } -} - -function createCodeLines(lines: readonly string[]): ICodeLine[] { - return lines.map((lineText, index): ICodeLine => ( - { - index: index + 1, - text: lineText, - } - )); -} diff --git a/tests/unit/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines.spec.ts b/tests/unit/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines.spec.ts deleted file mode 100644 index accad9d89..000000000 --- a/tests/unit/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe } from 'vitest'; -import { NoDuplicatedLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines'; -import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub'; -import type { IInvalidCodeLine } from '@/application/Parser/Executable/Script/Validation/ICodeValidationRule'; -import { testCodeValidationRule } from './CodeValidationRuleTestRunner'; - -describe('NoDuplicatedLines', () => { - describe('analyze', () => { - testCodeValidationRule([ - { - testName: 'no results when code is valid', - codeLines: ['unique1', 'unique2', 'unique3', 'unique4'], - expected: [], - sut: new NoDuplicatedLines(new LanguageSyntaxStub()), - }, - { - testName: 'detects single duplicated line as expected', - codeLines: ['duplicate', 'duplicate', 'unique', 'duplicate'], - expected: expectInvalidCodeLines([1, 2, 4]), - sut: new NoDuplicatedLines(new LanguageSyntaxStub()), - }, - { - testName: 'detects multiple duplicated lines as expected', - codeLines: ['duplicate1', 'duplicate2', 'unique', 'duplicate1', 'unique2', 'duplicate2'], - expected: expectInvalidCodeLines([1, 4], [2, 6]), - sut: new NoDuplicatedLines(new LanguageSyntaxStub()), - }, - { - testName: 'common code parts: does not detect multiple common code part usages as duplicates', - codeLines: ['good', 'good', 'bad', 'bad', 'good', 'also-good', 'also-good', 'unique'], - expected: expectInvalidCodeLines([3, 4]), - sut: new NoDuplicatedLines(new LanguageSyntaxStub() - .withCommonCodeParts('good', 'also-good')), - }, - { - testName: 'common code parts: does not detect multiple common code part used in same code line as duplicates', - codeLines: ['bad', 'bad', 'good1 good2', 'good1 good2', 'good2 good1', 'good2 good1'], - expected: expectInvalidCodeLines([1, 2]), - sut: new NoDuplicatedLines(new LanguageSyntaxStub() - .withCommonCodeParts('good2', 'good1')), - }, - { - testName: 'common code parts: detects when common code parts used in conjunction with unique words', - codeLines: [ - 'common-part1', 'common-part1', 'common-part1 common-part2', 'common-part1 unique', 'common-part1 unique', - 'common-part2', 'common-part2 common-part1', 'unique common-part2', 'unique common-part2', - ], - expected: expectInvalidCodeLines([4, 5], [8, 9]), - sut: new NoDuplicatedLines(new LanguageSyntaxStub() - .withCommonCodeParts('common-part1', 'common-part2')), - }, - { - testName: 'comments: does not when lines start with comment', - codeLines: ['#abc', '#abc', 'abc', 'unique', 'abc', '//abc', '//abc', '//unique', '#unique'], - expected: expectInvalidCodeLines([3, 5]), - sut: new NoDuplicatedLines(new LanguageSyntaxStub() - .withCommentDelimiters('#', '//')), - }, - { - testName: 'comments: does when comments come after lien start', - codeLines: ['test #comment', 'test #comment', 'test2 # comment', 'test2 # comment'], - expected: expectInvalidCodeLines([1, 2], [3, 4]), - sut: new NoDuplicatedLines(new LanguageSyntaxStub() - .withCommentDelimiters('#')), - }, - ]); - }); -}); - -function expectInvalidCodeLines( - ...lines: readonly ReadonlyArray[] -): IInvalidCodeLine[] { - return lines.flatMap((occurrenceIndices): readonly IInvalidCodeLine[] => occurrenceIndices - .map((index): IInvalidCodeLine => ({ - index, - error: `Line is duplicated at line numbers ${occurrenceIndices.join(',')}.`, - }))); -} diff --git a/tests/unit/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines.spec.ts b/tests/unit/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines.spec.ts deleted file mode 100644 index 03e4db7f5..000000000 --- a/tests/unit/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe } from 'vitest'; -import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines'; -import { testCodeValidationRule } from './CodeValidationRuleTestRunner'; - -describe('NoEmptyLines', () => { - describe('analyze', () => { - testCodeValidationRule([ - { - testName: 'no results when code is valid', - codeLines: ['non-empty-line1', 'none-empty-line2'], - expected: [], - sut: new NoEmptyLines(), - }, - { - testName: 'shows error for empty line', - codeLines: ['first line', '', 'third line'], - expected: [{ index: 2, error: 'Empty line' }], - sut: new NoEmptyLines(), - }, - { - testName: 'shows error for multiple empty lines', - codeLines: ['first line', '', 'third line', ''], - expected: [2, 4].map((index) => ({ index, error: 'Empty line' })), - sut: new NoEmptyLines(), - }, - { - testName: 'shows error for whitespace-only lines', - codeLines: ['first line', ' ', 'third line'], - expected: [{ index: 2, error: 'Empty line: "{whitespace}{whitespace}"' }], - sut: new NoEmptyLines(), - }, - { - testName: 'shows error for tab-only lines', - codeLines: ['first line', '\t\t', 'third line'], - expected: [{ index: 2, error: 'Empty line: "{tab}{tab}"' }], - sut: new NoEmptyLines(), - }, - { - testName: 'shows error for lines that consists of whitespace and tabs', - codeLines: ['first line', '\t \t', 'third line', ' \t '], - expected: [{ index: 2, error: 'Empty line: "{tab}{whitespace}{tab}"' }, { index: 4, error: 'Empty line: "{whitespace}{tab}{whitespace}"' }], - sut: new NoEmptyLines(), - }, - ]); - }); -}); diff --git a/tests/unit/application/Parser/Executable/Script/Validation/Syntax/ConcreteSyntaxes.spec.ts b/tests/unit/application/Parser/Executable/Script/Validation/Syntax/ConcreteSyntaxes.spec.ts deleted file mode 100644 index deade41fb..000000000 --- a/tests/unit/application/Parser/Executable/Script/Validation/Syntax/ConcreteSyntaxes.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { ShellScriptSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ShellScriptSyntax'; -import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax'; -import { BatchFileSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/BatchFileSyntax'; - -function getSystemsUnderTest(): ILanguageSyntax[] { - return [new BatchFileSyntax(), new ShellScriptSyntax()]; -} - -describe('ConcreteSyntaxes', () => { - describe('commentDelimiters', () => { - for (const sut of getSystemsUnderTest()) { - it(`${sut.constructor.name} returns defined value`, () => { - // act - const value = sut.commentDelimiters; - // assert - expect(value); - }); - } - }); - describe('commonCodeParts', () => { - for (const sut of getSystemsUnderTest()) { - it(`${sut.constructor.name} returns defined value`, () => { - // act - const value = sut.commonCodeParts; - // assert - expect(value); - }); - } - }); -}); diff --git a/tests/unit/application/Parser/Executable/Script/Validation/Syntax/SyntaxFactory.spec.ts b/tests/unit/application/Parser/Executable/Script/Validation/Syntax/SyntaxFactory.spec.ts deleted file mode 100644 index 35a4b47df..000000000 --- a/tests/unit/application/Parser/Executable/Script/Validation/Syntax/SyntaxFactory.spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe } from 'vitest'; -import { SyntaxFactory } from '@/application/Parser/Executable/Script/Validation/Syntax/SyntaxFactory'; -import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; -import { ShellScriptSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ShellScriptSyntax'; -import { ScriptingLanguageFactoryTestRunner } from '@tests/unit/application/Common/ScriptingLanguage/ScriptingLanguageFactoryTestRunner'; -import { BatchFileSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/BatchFileSyntax'; - -describe('SyntaxFactory', () => { - const sut = new SyntaxFactory(); - const runner = new ScriptingLanguageFactoryTestRunner() - .expectInstance(ScriptingLanguage.shellscript, ShellScriptSyntax) - .expectInstance(ScriptingLanguage.batchfile, BatchFileSyntax); - runner.testCreateMethod(sut); -}); diff --git a/tests/unit/application/Parser/Executable/Script/Validation/ValidationRuleAnalyzerFactory.spec.ts b/tests/unit/application/Parser/Executable/Script/Validation/ValidationRuleAnalyzerFactory.spec.ts new file mode 100644 index 000000000..8358bd7f2 --- /dev/null +++ b/tests/unit/application/Parser/Executable/Script/Validation/ValidationRuleAnalyzerFactory.spec.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'vitest'; +import { CodeValidationRule } from '@/application/Parser/Executable/Script/Validation/CodeValidationRule'; +import { analyzeEmptyLines } from '@/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeEmptyLines'; +import { analyzeDuplicateLines } from '@/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeDuplicateLines'; +import { analyzeTooLongLines } from '@/application/Parser/Executable/Script/Validation/Analyzers/AnalyzeTooLongLines'; +import { createValidationAnalyzers } from '@/application/Parser/Executable/Script/Validation/ValidationRuleAnalyzerFactory'; +import type { CodeValidationAnalyzer } from '@/application/Parser/Executable/Script/Validation/Analyzers/CodeValidationAnalyzer'; +import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; + +describe('ValidationRuleAnalyzerFactory', () => { + describe('createValidationAnalyzers', () => { + it('throws error when no rules are provided', () => { + // arrange + const expectedErrorMessage = 'missing rules'; + const rules: readonly CodeValidationRule[] = []; + const context = new TestContext() + .withRules(rules); + // act + const act = () => context.create(); + // assert + expect(act).to.throw(expectedErrorMessage); + }); + + it('creates correct analyzers for all valid rules', () => { + // arrange + const expectedAnalyzersForRules: Record = { + [CodeValidationRule.NoEmptyLines]: analyzeEmptyLines, + [CodeValidationRule.NoDuplicatedLines]: analyzeDuplicateLines, + [CodeValidationRule.NoTooLongLines]: analyzeTooLongLines, + }; + const givenRules: CodeValidationRule[] = Object + .keys(expectedAnalyzersForRules) + .map((r) => Number(r) as CodeValidationRule); + const context = new TestContext() + .withRules(givenRules); + // act + const actualAnalyzers = context.create(); + // assert + expect(actualAnalyzers).to.have.lengthOf(Object.entries(expectedAnalyzersForRules).length); + const expectedAnalyzers = Object.values(expectedAnalyzersForRules); + expect(actualAnalyzers).to.deep.equal(expectedAnalyzers); + }); + + it('throws error for unknown rule', () => { + // arrange + const unknownRule = 9999 as CodeValidationRule; + const expectedErrorMessage = `Unknown rule: ${unknownRule}`; + const context = new TestContext() + .withRules([unknownRule]); + // arrange + const act = () => context.create(); + // assert + expect(act).to.throw(expectedErrorMessage); + }); + + it('throws error for duplicate rules', () => { + // arrange + const duplicate1 = CodeValidationRule.NoEmptyLines; + const duplicate2 = CodeValidationRule.NoDuplicatedLines; + const rules: CodeValidationRule[] = [ + duplicate1, duplicate1, + duplicate2, duplicate2, + ]; + const expectedErrorMessage: string = [ + 'Duplicate rules are not allowed.', + `Duplicates found: ${CodeValidationRule[duplicate1]} (2 times), ${CodeValidationRule[duplicate2]} (2 times)`, + ].join(' '); + const context = new TestContext() + .withRules(rules); + // act + const act = () => context.create(); + // assert + expect(act).to.throw(expectedErrorMessage); + }); + + it('handles single rule correctly', () => { + // arrange + const givenRule = CodeValidationRule.NoEmptyLines; + const expectedAnalyzer = analyzeEmptyLines; + const context = new TestContext() + .withRules([givenRule]); + // act + const analyzers = context.create(); + // assert + expect(analyzers).to.have.lengthOf(1); + expect(analyzers[0]).toBe(expectedAnalyzer); + }); + + it('handles multiple unique rules correctly', () => { + // arrange + const expectedRuleAnalyzerPairs = new Map([ + [CodeValidationRule.NoEmptyLines, analyzeEmptyLines], + [CodeValidationRule.NoDuplicatedLines, analyzeDuplicateLines], + ]); + const rules = Array.from(expectedRuleAnalyzerPairs.keys()); + const context = new TestContext() + .withRules(rules); + // act + const actualAnalyzers = context.create(); + // assert + expect(actualAnalyzers).to.have.lengthOf(expectedRuleAnalyzerPairs.size); + actualAnalyzers.forEach((analyzer, index) => { + const rule = rules[index]; + const expectedAnalyzer = expectedRuleAnalyzerPairs.get(rule); + expect(analyzer).to.equal(expectedAnalyzer, formatAssertionMessage([ + `Analyzer for rule ${CodeValidationRule[rule]} does not match the expected analyzer`, + ])); + }); + }); + }); +}); + +class TestContext { + private rules: readonly CodeValidationRule[] = [CodeValidationRule.NoDuplicatedLines]; + + public withRules(rules: readonly CodeValidationRule[]): this { + this.rules = rules; + return this; + } + + public create(): ReturnType { + return createValidationAnalyzers(this.rules); + } +} diff --git a/tests/unit/shared/Stubs/CategoryCollectionContextStub.ts b/tests/unit/shared/Stubs/CategoryCollectionContextStub.ts new file mode 100644 index 000000000..7326dffe0 --- /dev/null +++ b/tests/unit/shared/Stubs/CategoryCollectionContextStub.ts @@ -0,0 +1,21 @@ +import type { CategoryCollectionContext } from '@/application/Parser/Executable/CategoryCollectionContext'; +import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import type { ScriptCompiler } from '@/application/Parser/Executable/Script/Compiler/ScriptCompiler'; +import { ScriptCompilerStub } from './ScriptCompilerStub'; + +export class CategoryCollectionContextStub +implements CategoryCollectionContext { + public compiler: ScriptCompiler = new ScriptCompilerStub(); + + public language: ScriptingLanguage = ScriptingLanguage.shellscript; + + public withCompiler(compiler: ScriptCompiler) { + this.compiler = compiler; + return this; + } + + public withLanguage(language: ScriptingLanguage) { + this.language = language; + return this; + } +} diff --git a/tests/unit/shared/Stubs/CategoryCollectionSpecificUtilitiesStub.ts b/tests/unit/shared/Stubs/CategoryCollectionSpecificUtilitiesStub.ts deleted file mode 100644 index cb41e2510..000000000 --- a/tests/unit/shared/Stubs/CategoryCollectionSpecificUtilitiesStub.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { IScriptCompiler } from '@/application/Parser/Executable/Script/Compiler/IScriptCompiler'; -import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax'; -import type { CategoryCollectionSpecificUtilities } from '@/application/Parser/Executable/CategoryCollectionSpecificUtilities'; -import { ScriptCompilerStub } from './ScriptCompilerStub'; -import { LanguageSyntaxStub } from './LanguageSyntaxStub'; - -export class CategoryCollectionSpecificUtilitiesStub -implements CategoryCollectionSpecificUtilities { - public compiler: IScriptCompiler = new ScriptCompilerStub(); - - public syntax: ILanguageSyntax = new LanguageSyntaxStub(); - - public withCompiler(compiler: IScriptCompiler) { - this.compiler = compiler; - return this; - } - - public withSyntax(syntax: ILanguageSyntax) { - this.syntax = syntax; - return this; - } -} diff --git a/tests/unit/shared/Stubs/CategoryParserStub.ts b/tests/unit/shared/Stubs/CategoryParserStub.ts index d5b83be9f..8f7b85303 100644 --- a/tests/unit/shared/Stubs/CategoryParserStub.ts +++ b/tests/unit/shared/Stubs/CategoryParserStub.ts @@ -1,13 +1,13 @@ import type { CategoryParser } from '@/application/Parser/Executable/CategoryParser'; import type { CategoryData } from '@/application/collections/'; import type { Category } from '@/domain/Executables/Category/Category'; -import type { CategoryCollectionSpecificUtilities } from '@/application/Parser/Executable/CategoryCollectionSpecificUtilities'; +import type { CategoryCollectionContext } from '@/application/Parser/Executable/CategoryCollectionContext'; import { CategoryStub } from './CategoryStub'; export class CategoryParserStub { private configuredParseResults = new Map(); - private usedUtilities = new Array(); + private usedUtilities = new Array(); public get(): CategoryParser { return (category, utilities) => { @@ -28,7 +28,7 @@ export class CategoryParserStub { return this; } - public getUsedUtilities(): readonly CategoryCollectionSpecificUtilities[] { + public getUsedContext(): readonly CategoryCollectionContext[] { return this.usedUtilities; } } diff --git a/tests/unit/shared/Stubs/CodeValidationAnalyzerStub.ts b/tests/unit/shared/Stubs/CodeValidationAnalyzerStub.ts new file mode 100644 index 000000000..1349afc1e --- /dev/null +++ b/tests/unit/shared/Stubs/CodeValidationAnalyzerStub.ts @@ -0,0 +1,23 @@ +import type { CodeLine, InvalidCodeLine, CodeValidationAnalyzer } from '@/application/Parser/Executable/Script/Validation/Analyzers/CodeValidationAnalyzer'; +import type { ScriptingLanguage } from '@/domain/ScriptingLanguage'; + +export class CodeValidationAnalyzerStub { + public readonly receivedLines = new Array(); + + public readonly receivedLanguages = new Array(); + + private returnValue: InvalidCodeLine[] = []; + + public withReturnValue(lines: readonly InvalidCodeLine[]) { + this.returnValue = [...lines]; + return this; + } + + public get(): CodeValidationAnalyzer { + return (lines, language) => { + this.receivedLines.push(...[lines]); + this.receivedLanguages.push(language); + return this.returnValue; + }; + } +} diff --git a/tests/unit/shared/Stubs/CodeValidationRuleStub.ts b/tests/unit/shared/Stubs/CodeValidationRuleStub.ts deleted file mode 100644 index 73a48bc4c..000000000 --- a/tests/unit/shared/Stubs/CodeValidationRuleStub.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { ICodeLine } from '@/application/Parser/Executable/Script/Validation/ICodeLine'; -import type { ICodeValidationRule, IInvalidCodeLine } from '@/application/Parser/Executable/Script/Validation/ICodeValidationRule'; - -export class CodeValidationRuleStub implements ICodeValidationRule { - public readonly receivedLines = new Array(); - - private returnValue: IInvalidCodeLine[] = []; - - public withReturnValue(lines: readonly IInvalidCodeLine[]) { - this.returnValue = [...lines]; - return this; - } - - public analyze(lines: readonly ICodeLine[]): IInvalidCodeLine[] { - this.receivedLines.push(...[lines]); - return this.returnValue; - } -} diff --git a/tests/unit/shared/Stubs/CodeValidatorStub.ts b/tests/unit/shared/Stubs/CodeValidatorStub.ts index b68da1271..ac56f247b 100644 --- a/tests/unit/shared/Stubs/CodeValidatorStub.ts +++ b/tests/unit/shared/Stubs/CodeValidatorStub.ts @@ -1,34 +1,86 @@ import { expect } from 'vitest'; -import type { Constructible } from '@/TypeHelpers'; -import type { ICodeValidationRule } from '@/application/Parser/Executable/Script/Validation/ICodeValidationRule'; -import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator'; - -export class CodeValidatorStub implements ICodeValidator { - public callHistory = new Array<{ - code: string, - rules: readonly ICodeValidationRule[], - }>(); - - public throwIfInvalid( - code: string, - rules: readonly ICodeValidationRule[], - ): void { - this.callHistory.push({ - code, - rules: Array.from(rules), - }); +import type { CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator'; +import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import type { CodeValidationRule } from '@/application/Parser/Executable/Script/Validation/CodeValidationRule'; +import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; + +export class CodeValidatorStub { + public callHistory = new Array>(); + + public get(): CodeValidator { + return (...args) => { + this.callHistory.push(args); + }; + } + + public assertValidatedCodes( + validatedCodes: readonly string[], + ) { + expectExpectedCodes(this, validatedCodes); + } + + public assertValidatedRules( + rules: readonly CodeValidationRule[], + ) { + expectExpectedRules(this, rules); + } + + public assertValidatedLanguage( + language: ScriptingLanguage, + ) { + expectExpectedLanguage(this, language); } +} + +function expectExpectedCodes( + validator: CodeValidatorStub, + expectedCodes: readonly string[], +): void { + expect(validator.callHistory).to.have.lengthOf(expectedCodes.length, formatAssertionMessage([ + 'Mismatch in number of validated codes', + `Expected: ${expectedCodes.length}`, + `Actual: ${validator.callHistory.length}`, + ])); + const actualValidatedCodes = validator.callHistory.map((args) => { + const [code] = args; + return code; + }); + expect(actualValidatedCodes).to.have.members(expectedCodes, formatAssertionMessage([ + 'Mismatch in validated codes', + `Expected: ${JSON.stringify(expectedCodes)}`, + `Actual: ${JSON.stringify(actualValidatedCodes)}`, + ])); +} + +function expectExpectedRules( + validator: CodeValidatorStub, + expectedRules: readonly CodeValidationRule[], +): void { + for (const call of validator.callHistory) { + const [,,actualRules] = call; + expect(actualRules).to.have.lengthOf(expectedRules.length, formatAssertionMessage([ + 'Mismatch in number of validation rules for a call.', + `Expected: ${expectedRules.length}`, + `Actual: ${actualRules.length}`, + ])); + expect(actualRules).to.have.members(expectedRules, formatAssertionMessage([ + 'Mismatch in validation rules for for a call.', + `Expected: ${JSON.stringify(expectedRules)}`, + `Actual: ${JSON.stringify(actualRules)}`, + ])); + } +} - public assertHistory(expectation: { - validatedCodes: readonly (string | undefined)[], - rules: readonly Constructible[], - }) { - expect(this.callHistory).to.have.lengthOf(expectation.validatedCodes.length); - const actualValidatedCodes = this.callHistory.map((args) => args.code); - expect(actualValidatedCodes.sort()).deep.equal([...expectation.validatedCodes].sort()); - for (const call of this.callHistory) { - const actualRules = call.rules.map((rule) => rule.constructor); - expect(actualRules.sort()).to.deep.equal([...expectation.rules].sort()); - } +function expectExpectedLanguage( + validator: CodeValidatorStub, + expectedLanguage: ScriptingLanguage, +): void { + for (const call of validator.callHistory) { + const [,language] = call; + expect(language).to.equal(expectedLanguage, formatAssertionMessage([ + 'Mismatch in scripting language', + `Expected: ${ScriptingLanguage[expectedLanguage]}`, + `Actual: ${ScriptingLanguage[language]}`, + ])); } } diff --git a/tests/unit/shared/Stubs/LanguageSyntaxStub.ts b/tests/unit/shared/Stubs/LanguageSyntaxStub.ts index 16e0b0418..70d5a7088 100644 --- a/tests/unit/shared/Stubs/LanguageSyntaxStub.ts +++ b/tests/unit/shared/Stubs/LanguageSyntaxStub.ts @@ -1,6 +1,6 @@ -import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax'; +import type { LanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax'; -export class LanguageSyntaxStub implements ILanguageSyntax { +export class LanguageSyntaxStub implements LanguageSyntax { public commentDelimiters: string[] = []; public commonCodeParts: string[] = []; diff --git a/tests/unit/shared/Stubs/ScriptCompilerFactoryStub.ts b/tests/unit/shared/Stubs/ScriptCompilerFactoryStub.ts new file mode 100644 index 000000000..e3541de91 --- /dev/null +++ b/tests/unit/shared/Stubs/ScriptCompilerFactoryStub.ts @@ -0,0 +1,20 @@ +import type { ScriptCompilerFactory, ScriptCompilerInitParameters } from '@/application/Parser/Executable/Script/Compiler/ScriptCompilerFactory'; +import type { ScriptCompiler } from '@/application/Parser/Executable/Script/Compiler/ScriptCompiler'; +import { ScriptCompilerStub } from './ScriptCompilerStub'; + +export function createScriptCompilerFactorySpy(): { + readonly instance: ScriptCompilerFactory; + getInitParameters: ( + compiler: ScriptCompiler, + ) => ScriptCompilerInitParameters | undefined; +} { + const createdCompilers = new Map(); + return { + instance: (parameters) => { + const compiler = new ScriptCompilerStub(); + createdCompilers.set(compiler, parameters); + return compiler; + }, + getInitParameters: (category) => createdCompilers.get(category), + }; +} diff --git a/tests/unit/shared/Stubs/ScriptCompilerStub.ts b/tests/unit/shared/Stubs/ScriptCompilerStub.ts index 7413b543a..0b1914aa3 100644 --- a/tests/unit/shared/Stubs/ScriptCompilerStub.ts +++ b/tests/unit/shared/Stubs/ScriptCompilerStub.ts @@ -1,9 +1,9 @@ import type { ScriptData } from '@/application/collections/'; -import type { IScriptCompiler } from '@/application/Parser/Executable/Script/Compiler/IScriptCompiler'; import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode'; +import type { ScriptCompiler } from '@/application/Parser/Executable/Script/Compiler/ScriptCompiler'; import { ScriptCodeStub } from './ScriptCodeStub'; -export class ScriptCompilerStub implements IScriptCompiler { +export class ScriptCompilerStub implements ScriptCompiler { public compilableScripts = new Map(); public canCompile(script: ScriptData): boolean { diff --git a/tests/unit/shared/Stubs/SharedFunctionsParserStub.ts b/tests/unit/shared/Stubs/SharedFunctionsParserStub.ts index 734ac4d3f..fc6e99820 100644 --- a/tests/unit/shared/Stubs/SharedFunctionsParserStub.ts +++ b/tests/unit/shared/Stubs/SharedFunctionsParserStub.ts @@ -2,13 +2,13 @@ import type { FunctionData } from '@/application/collections/'; import { sequenceEqual } from '@/application/Common/Array'; import type { ISharedFunctionCollection } from '@/application/Parser/Executable/Script/Compiler/Function/ISharedFunctionCollection'; import type { SharedFunctionsParser } from '@/application/Parser/Executable/Script/Compiler/Function/SharedFunctionsParser'; -import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax'; +import type { ScriptingLanguage } from '@/domain/ScriptingLanguage'; import { SharedFunctionCollectionStub } from './SharedFunctionCollectionStub'; export function createSharedFunctionsParserStub() { const callHistory = new Array<{ readonly functions: readonly FunctionData[], - readonly syntax: ILanguageSyntax, + readonly language: ScriptingLanguage, }>(); const setupResults = new Array<{ @@ -26,11 +26,11 @@ export function createSharedFunctionsParserStub() { const parser: SharedFunctionsParser = ( functions: readonly FunctionData[], - syntax: ILanguageSyntax, + language: ScriptingLanguage, ) => { callHistory.push({ functions: Array.from(functions), - syntax, + language, }); const result = findResult(functions); return result || new SharedFunctionCollectionStub(); diff --git a/tests/unit/shared/Stubs/SyntaxFactoryStub.ts b/tests/unit/shared/Stubs/SyntaxFactoryStub.ts index 59e07964b..cb6965ffc 100644 --- a/tests/unit/shared/Stubs/SyntaxFactoryStub.ts +++ b/tests/unit/shared/Stubs/SyntaxFactoryStub.ts @@ -1,18 +1,31 @@ -import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax'; -import type { ISyntaxFactory } from '@/application/Parser/Executable/Script/Validation/Syntax/ISyntaxFactory'; -import type { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import type { LanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax'; +import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import type { SyntaxFactory } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/SyntaxFactory'; import { LanguageSyntaxStub } from './LanguageSyntaxStub'; -export function createSyntaxFactoryStub( - expectedLanguage?: ScriptingLanguage, - result?: ILanguageSyntax, -): ISyntaxFactory { - return { - create: (language: ScriptingLanguage) => { - if (expectedLanguage !== undefined && language !== expectedLanguage) { - throw new Error('unexpected language'); +interface PredeterminedSyntax { + readonly givenLanguage: ScriptingLanguage; + readonly predeterminedSyntax: LanguageSyntax; +} + +export class SyntaxFactoryStub { + private readonly predeterminedResults = new Array(); + + public withPredeterminedSyntax(scenario: PredeterminedSyntax): this { + this.predeterminedResults.push(scenario); + return this; + } + + public get(): SyntaxFactory { + return (language): LanguageSyntax => { + const results = this.predeterminedResults.filter((r) => r.givenLanguage === language); + if (results.length === 0) { + return new LanguageSyntaxStub(); + } + if (results.length > 1) { + throw new Error(`Logical error: More than single predetermined results for ${ScriptingLanguage[language]}`); } - return result ?? new LanguageSyntaxStub(); - }, - }; + return results[0].predeterminedSyntax; + }; + } }