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; + }; + } }