diff --git a/src/application/Parser/Script/CategoryCollectionParseContext.ts b/src/application/Parser/Script/CategoryCollectionParseContext.ts index 2d0aedf5..fc02301f 100644 --- a/src/application/Parser/Script/CategoryCollectionParseContext.ts +++ b/src/application/Parser/Script/CategoryCollectionParseContext.ts @@ -1,11 +1,11 @@ import type { FunctionData } from '@/application/collections/'; import { IScriptingDefinition } from '@/domain/IScriptingDefinition'; -import { ILanguageSyntax } from '@/domain/ScriptCode'; import { IScriptCompiler } from './Compiler/IScriptCompiler'; import { ScriptCompiler } from './Compiler/ScriptCompiler'; import { ICategoryCollectionParseContext } from './ICategoryCollectionParseContext'; -import { SyntaxFactory } from './Syntax/SyntaxFactory'; -import { ISyntaxFactory } from './Syntax/ISyntaxFactory'; +import { SyntaxFactory } from './Validation/Syntax/SyntaxFactory'; +import { ISyntaxFactory } from './Validation/Syntax/ISyntaxFactory'; +import { ILanguageSyntax } from './Validation/Syntax/ILanguageSyntax'; export class CategoryCollectionParseContext implements ICategoryCollectionParseContext { public readonly compiler: IScriptCompiler; diff --git a/src/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompiler.ts b/src/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompiler.ts index 5db2fb88..c470c29a 100644 --- a/src/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompiler.ts +++ b/src/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompiler.ts @@ -81,7 +81,7 @@ function compileCode( compiler: IExpressionsCompiler, ): ICompiledFunctionCall { return { - code: compiler.compileExpressions(code.do, args), + code: compiler.compileExpressions(code.execute, args), revertCode: compiler.compileExpressions(code.revert, args), }; } diff --git a/src/application/Parser/Script/Compiler/Function/ISharedFunction.ts b/src/application/Parser/Script/Compiler/Function/ISharedFunction.ts index 8a3e7da5..09f3e4a9 100644 --- a/src/application/Parser/Script/Compiler/Function/ISharedFunction.ts +++ b/src/application/Parser/Script/Compiler/Function/ISharedFunction.ts @@ -19,6 +19,6 @@ export enum FunctionBodyType { } export interface IFunctionCode { - readonly do: string; + readonly execute: string; readonly revert?: string; } diff --git a/src/application/Parser/Script/Compiler/Function/ISharedFunctionsParser.ts b/src/application/Parser/Script/Compiler/Function/ISharedFunctionsParser.ts index 3a745438..56a01b45 100644 --- a/src/application/Parser/Script/Compiler/Function/ISharedFunctionsParser.ts +++ b/src/application/Parser/Script/Compiler/Function/ISharedFunctionsParser.ts @@ -1,6 +1,10 @@ import type { FunctionData } from '@/application/collections/'; +import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax'; import { ISharedFunctionCollection } from './ISharedFunctionCollection'; export interface ISharedFunctionsParser { - parseFunctions(functions: readonly FunctionData[]): ISharedFunctionCollection; + parseFunctions( + functions: readonly FunctionData[], + syntax: ILanguageSyntax, + ): ISharedFunctionCollection; } diff --git a/src/application/Parser/Script/Compiler/Function/SharedFunction.ts b/src/application/Parser/Script/Compiler/Function/SharedFunction.ts index 77335476..9945bbde 100644 --- a/src/application/Parser/Script/Compiler/Function/SharedFunction.ts +++ b/src/application/Parser/Script/Compiler/Function/SharedFunction.ts @@ -1,4 +1,5 @@ import { IFunctionCall } from './Call/IFunctionCall'; + import { FunctionBodyType, IFunctionCode, ISharedFunction, ISharedFunctionBody, } from './ISharedFunction'; @@ -25,7 +26,7 @@ export function createFunctionWithInlineCode( throw new Error(`undefined code in function "${name}"`); } const content: IFunctionCode = { - do: code, + execute: code, revert: revertCode, }; return new SharedFunction(name, parameters, content, FunctionBodyType.Code); diff --git a/src/application/Parser/Script/Compiler/Function/SharedFunctionsParser.ts b/src/application/Parser/Script/Compiler/Function/SharedFunctionsParser.ts index b0e44ea9..aa3f482a 100644 --- a/src/application/Parser/Script/Compiler/Function/SharedFunctionsParser.ts +++ b/src/application/Parser/Script/Compiler/Function/SharedFunctionsParser.ts @@ -1,4 +1,9 @@ import type { FunctionData, InstructionHolder } from '@/application/collections/'; +import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax'; +import { CodeValidator } from '@/application/Parser/Script/Validation/CodeValidator'; +import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines'; +import { NoDuplicatedLines } from '@/application/Parser/Script/Validation/Rules/NoDuplicatedLines'; +import { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator'; import { createFunctionWithInlineCode, createCallerFunction } from './SharedFunction'; import { SharedFunctionCollection } from './SharedFunctionCollection'; import { ISharedFunctionCollection } from './ISharedFunctionCollection'; @@ -12,16 +17,20 @@ import { parseFunctionCalls } from './Call/FunctionCallParser'; export class SharedFunctionsParser implements ISharedFunctionsParser { public static readonly instance: ISharedFunctionsParser = new SharedFunctionsParser(); + constructor(private readonly codeValidator: ICodeValidator = CodeValidator.instance) { } + public parseFunctions( functions: readonly FunctionData[], + syntax: ILanguageSyntax, ): ISharedFunctionCollection { + if (!syntax) { throw new Error('missing syntax'); } const collection = new SharedFunctionCollection(); if (!functions || !functions.length) { return collection; } ensureValidFunctions(functions); return functions - .map((func) => parseFunction(func)) + .map((func) => parseFunction(func, syntax, this.codeValidator)) .reduce((acc, func) => { acc.addFunction(func); return acc; @@ -29,10 +38,15 @@ export class SharedFunctionsParser implements ISharedFunctionsParser { } } -function parseFunction(data: FunctionData): ISharedFunction { +function parseFunction( + data: FunctionData, + syntax: ILanguageSyntax, + validator: ICodeValidator, +): ISharedFunction { const { name } = data; const parameters = parseParameters(data); if (hasCode(data)) { + validateCode(data, syntax, validator); return createFunctionWithInlineCode(name, parameters, data.code, data.revertCode); } // Has call @@ -40,6 +54,19 @@ function parseFunction(data: FunctionData): ISharedFunction { return createCallerFunction(name, parameters, calls); } +function validateCode( + data: FunctionData, + syntax: ILanguageSyntax, + validator: ICodeValidator, +): void { + [data.code, data.revertCode].forEach( + (code) => validator.throwIfInvalid( + code, + [new NoEmptyLines(), new NoDuplicatedLines(syntax)], + ), + ); +} + function parseParameters(data: FunctionData): IReadOnlyFunctionParameterCollection { return (data.parameters || []) .map((parameter) => { diff --git a/src/application/Parser/Script/Compiler/ScriptCompiler.ts b/src/application/Parser/Script/Compiler/ScriptCompiler.ts index 40819271..a20c4a09 100644 --- a/src/application/Parser/Script/Compiler/ScriptCompiler.ts +++ b/src/application/Parser/Script/Compiler/ScriptCompiler.ts @@ -1,6 +1,10 @@ import type { FunctionData, ScriptData } from '@/application/collections/'; import { IScriptCode } from '@/domain/IScriptCode'; -import { ScriptCode, ILanguageSyntax } from '@/domain/ScriptCode'; +import { ScriptCode } from '@/domain/ScriptCode'; +import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax'; +import { CodeValidator } from '@/application/Parser/Script/Validation/CodeValidator'; +import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines'; +import { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator'; import { IScriptCompiler } from './IScriptCompiler'; import { ISharedFunctionCollection } from './Function/ISharedFunctionCollection'; import { IFunctionCallCompiler } from './Function/Call/Compiler/IFunctionCallCompiler'; @@ -8,18 +12,20 @@ import { FunctionCallCompiler } from './Function/Call/Compiler/FunctionCallCompi import { ISharedFunctionsParser } from './Function/ISharedFunctionsParser'; import { SharedFunctionsParser } from './Function/SharedFunctionsParser'; import { parseFunctionCalls } from './Function/Call/FunctionCallParser'; +import { ICompiledCode } from './Function/Call/Compiler/ICompiledCode'; export class ScriptCompiler implements IScriptCompiler { private readonly functions: ISharedFunctionCollection; constructor( functions: readonly FunctionData[] | undefined, - private readonly syntax: ILanguageSyntax, - private readonly callCompiler: IFunctionCallCompiler = FunctionCallCompiler.instance, + syntax: ILanguageSyntax, sharedFunctionsParser: ISharedFunctionsParser = SharedFunctionsParser.instance, + private readonly callCompiler: IFunctionCallCompiler = FunctionCallCompiler.instance, + private readonly codeValidator: ICodeValidator = CodeValidator.instance, ) { if (!syntax) { throw new Error('missing syntax'); } - this.functions = sharedFunctionsParser.parseFunctions(functions); + this.functions = sharedFunctionsParser.parseFunctions(functions, syntax); } public canCompile(script: ScriptData): boolean { @@ -35,13 +41,19 @@ export class ScriptCompiler implements IScriptCompiler { try { const calls = parseFunctionCalls(script.call); const compiledCode = this.callCompiler.compileCall(calls, this.functions); + validateCompiledCode(compiledCode, this.codeValidator); return new ScriptCode( compiledCode.code, compiledCode.revertCode, - this.syntax, ); } catch (error) { throw Error(`Script "${script.name}" ${error.message}`); } } } + +function validateCompiledCode(compiledCode: ICompiledCode, validator: ICodeValidator): void { + [compiledCode.code, compiledCode.revertCode].forEach( + (code) => validator.throwIfInvalid(code, [new NoEmptyLines()]), + ); +} diff --git a/src/application/Parser/Script/ICategoryCollectionParseContext.ts b/src/application/Parser/Script/ICategoryCollectionParseContext.ts index 3683c955..0bac0f65 100644 --- a/src/application/Parser/Script/ICategoryCollectionParseContext.ts +++ b/src/application/Parser/Script/ICategoryCollectionParseContext.ts @@ -1,5 +1,5 @@ -import { ILanguageSyntax } from '@/domain/ScriptCode'; import { IScriptCompiler } from './Compiler/IScriptCompiler'; +import { ILanguageSyntax } from './Validation/Syntax/ILanguageSyntax'; export interface ICategoryCollectionParseContext { readonly compiler: IScriptCompiler; diff --git a/src/application/Parser/Script/ScriptParser.ts b/src/application/Parser/Script/ScriptParser.ts index 99a12a13..5e894602 100644 --- a/src/application/Parser/Script/ScriptParser.ts +++ b/src/application/Parser/Script/ScriptParser.ts @@ -1,13 +1,18 @@ import type { ScriptData } from '@/application/collections/'; +import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines'; +import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax'; import { Script } from '@/domain/Script'; import { RecommendationLevel } from '@/domain/RecommendationLevel'; import { IScriptCode } from '@/domain/IScriptCode'; import { ScriptCode } from '@/domain/ScriptCode'; +import { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator'; import { parseDocs } from '../DocumentationParser'; import { createEnumParser, IEnumParser } from '../../Common/Enum'; import { NodeType } from '../NodeValidation/NodeType'; import { NodeValidator } from '../NodeValidation/NodeValidator'; import { ICategoryCollectionParseContext } from './ICategoryCollectionParseContext'; +import { CodeValidator } from './Validation/CodeValidator'; +import { NoDuplicatedLines } from './Validation/Rules/NoDuplicatedLines'; // eslint-disable-next-line consistent-return export function parseScript( @@ -15,6 +20,7 @@ export function parseScript( context: ICategoryCollectionParseContext, levelParser = createEnumParser(RecommendationLevel), scriptFactory: ScriptFactoryType = ScriptFactory, + codeValidator: ICodeValidator = CodeValidator.instance, ): Script { const validator = new NodeValidator({ type: NodeType.Script, selfNode: data }); validateScript(data, validator); @@ -22,7 +28,7 @@ export function parseScript( try { const script = scriptFactory( /* name: */ data.name, - /* code: */ parseCode(data, context), + /* code: */ parseCode(data, context, codeValidator), /* docs: */ parseDocs(data), /* level: */ parseLevel(data.recommend, levelParser), ); @@ -42,11 +48,30 @@ function parseLevel( return parser.parseEnum(level, 'level'); } -function parseCode(script: ScriptData, context: ICategoryCollectionParseContext): IScriptCode { +function parseCode( + script: ScriptData, + context: ICategoryCollectionParseContext, + codeValidator: ICodeValidator, +): IScriptCode { if (context.compiler.canCompile(script)) { return context.compiler.compile(script); } - return new ScriptCode(script.code, script.revertCode, context.syntax); + const code = new ScriptCode(script.code, script.revertCode); + validateHardcodedCodeWithoutCalls(code, codeValidator, context.syntax); + return code; +} + +function validateHardcodedCodeWithoutCalls( + scriptCode: ScriptCode, + codeValidator: ICodeValidator, + syntax: ILanguageSyntax, +) { + [scriptCode.execute, scriptCode.revert].forEach( + (code) => codeValidator.throwIfInvalid( + code, + [new NoEmptyLines(), new NoDuplicatedLines(syntax)], + ), + ); } function validateScript(script: ScriptData, validator: NodeValidator) { diff --git a/src/application/Parser/Script/Validation/CodeValidator.ts b/src/application/Parser/Script/Validation/CodeValidator.ts new file mode 100644 index 00000000..130a93e7 --- /dev/null +++ b/src/application/Parser/Script/Validation/CodeValidator.ts @@ -0,0 +1,46 @@ +import { ICodeValidationRule, IInvalidCodeLine } from './ICodeValidationRule'; +import { ICodeValidator } from './ICodeValidator'; +import { ICodeLine } from './ICodeLine'; + +export class CodeValidator implements ICodeValidator { + public static readonly instance: ICodeValidator = new CodeValidator(); + + public throwIfInvalid( + code: string, + rules: readonly ICodeValidationRule[], + ): void { + if (!rules || 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); + } +} + +function extractLines(code: string): ICodeLine[] { + return code + .split(/\r\n|\r|\n/) + .map((lineText, lineIndex): ICodeLine => ({ + index: lineIndex + 1, + text: lineText, + })); +} + +function printLines( + lines: readonly ICodeLine[], + invalidLines: readonly IInvalidCodeLine[], +): string { + return lines.map((line) => { + const badLine = invalidLines.find((invalidLine) => invalidLine.index === line.index); + if (!badLine) { + return `[${line.index}] ✅ ${line.text}`; + } + return `[${badLine.index}] ❌ ${line.text}\n\t⟶ ${badLine.error}`; + }).join('\n'); +} diff --git a/src/application/Parser/Script/Validation/ICodeLine.ts b/src/application/Parser/Script/Validation/ICodeLine.ts new file mode 100644 index 00000000..6a371d6a --- /dev/null +++ b/src/application/Parser/Script/Validation/ICodeLine.ts @@ -0,0 +1,4 @@ +export interface ICodeLine { + readonly index: number; + readonly text: string; +} diff --git a/src/application/Parser/Script/Validation/ICodeValidationRule.ts b/src/application/Parser/Script/Validation/ICodeValidationRule.ts new file mode 100644 index 00000000..97843afe --- /dev/null +++ b/src/application/Parser/Script/Validation/ICodeValidationRule.ts @@ -0,0 +1,10 @@ +import { 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/Script/Validation/ICodeValidator.ts b/src/application/Parser/Script/Validation/ICodeValidator.ts new file mode 100644 index 00000000..95996739 --- /dev/null +++ b/src/application/Parser/Script/Validation/ICodeValidator.ts @@ -0,0 +1,8 @@ +import { ICodeValidationRule } from './ICodeValidationRule'; + +export interface ICodeValidator { + throwIfInvalid( + code: string, + rules: readonly ICodeValidationRule[], + ): void; +} diff --git a/src/application/Parser/Script/Validation/Rules/NoDuplicatedLines.ts b/src/application/Parser/Script/Validation/Rules/NoDuplicatedLines.ts new file mode 100644 index 00000000..ebea7dc6 --- /dev/null +++ b/src/application/Parser/Script/Validation/Rules/NoDuplicatedLines.ts @@ -0,0 +1,47 @@ +import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax'; +import { ICodeLine } from '../ICodeLine'; +import { ICodeValidationRule, IInvalidCodeLine } from '../ICodeValidationRule'; + +export class NoDuplicatedLines implements ICodeValidationRule { + constructor(private readonly syntax: ILanguageSyntax) { + if (!syntax) { throw new Error('missing syntax'); } + } + + 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/Script/Validation/Rules/NoEmptyLines.ts b/src/application/Parser/Script/Validation/Rules/NoEmptyLines.ts new file mode 100644 index 00000000..9d823a1f --- /dev/null +++ b/src/application/Parser/Script/Validation/Rules/NoEmptyLines.ts @@ -0,0 +1,21 @@ +import { ICodeLine } from '../ICodeLine'; +import { ICodeValidationRule, IInvalidCodeLine } from '../ICodeValidationRule'; + +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/Script/Syntax/BatchFileSyntax.ts b/src/application/Parser/Script/Validation/Syntax/BatchFileSyntax.ts similarity index 76% rename from src/application/Parser/Script/Syntax/BatchFileSyntax.ts rename to src/application/Parser/Script/Validation/Syntax/BatchFileSyntax.ts index 5df08024..e4c9e7f4 100644 --- a/src/application/Parser/Script/Syntax/BatchFileSyntax.ts +++ b/src/application/Parser/Script/Validation/Syntax/BatchFileSyntax.ts @@ -1,4 +1,4 @@ -import { ILanguageSyntax } from '@/domain/ScriptCode'; +import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax'; const BatchFileCommonCodeParts = ['(', ')', 'else', '||']; const PowerShellCommonCodeParts = ['{', '}']; diff --git a/src/application/Parser/Script/Validation/Syntax/ILanguageSyntax.ts b/src/application/Parser/Script/Validation/Syntax/ILanguageSyntax.ts new file mode 100644 index 00000000..8483261e --- /dev/null +++ b/src/application/Parser/Script/Validation/Syntax/ILanguageSyntax.ts @@ -0,0 +1,4 @@ +export interface ILanguageSyntax { + readonly commentDelimiters: string[]; + readonly commonCodeParts: string[]; +} diff --git a/src/application/Parser/Script/Syntax/ISyntaxFactory.ts b/src/application/Parser/Script/Validation/Syntax/ISyntaxFactory.ts similarity index 76% rename from src/application/Parser/Script/Syntax/ISyntaxFactory.ts rename to src/application/Parser/Script/Validation/Syntax/ISyntaxFactory.ts index 6fa487cc..292b980d 100644 --- a/src/application/Parser/Script/Syntax/ISyntaxFactory.ts +++ b/src/application/Parser/Script/Validation/Syntax/ISyntaxFactory.ts @@ -1,4 +1,4 @@ -import { ILanguageSyntax } from '@/domain/ScriptCode'; import { IScriptingLanguageFactory } from '@/application/Common/ScriptingLanguage/IScriptingLanguageFactory'; +import { ILanguageSyntax } from './ILanguageSyntax'; export type ISyntaxFactory = IScriptingLanguageFactory; diff --git a/src/application/Parser/Script/Syntax/ShellScriptSyntax.ts b/src/application/Parser/Script/Validation/Syntax/ShellScriptSyntax.ts similarity index 63% rename from src/application/Parser/Script/Syntax/ShellScriptSyntax.ts rename to src/application/Parser/Script/Validation/Syntax/ShellScriptSyntax.ts index 98a3aeac..6397fc81 100644 --- a/src/application/Parser/Script/Syntax/ShellScriptSyntax.ts +++ b/src/application/Parser/Script/Validation/Syntax/ShellScriptSyntax.ts @@ -1,4 +1,4 @@ -import { ILanguageSyntax } from '@/domain/ScriptCode'; +import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax'; export class ShellScriptSyntax implements ILanguageSyntax { public readonly commentDelimiters = ['#']; diff --git a/src/application/Parser/Script/Syntax/SyntaxFactory.ts b/src/application/Parser/Script/Validation/Syntax/SyntaxFactory.ts similarity index 87% rename from src/application/Parser/Script/Syntax/SyntaxFactory.ts rename to src/application/Parser/Script/Validation/Syntax/SyntaxFactory.ts index 0a2722ff..bf1f37d8 100644 --- a/src/application/Parser/Script/Syntax/SyntaxFactory.ts +++ b/src/application/Parser/Script/Validation/Syntax/SyntaxFactory.ts @@ -1,6 +1,6 @@ -import { ILanguageSyntax } from '@/domain/ScriptCode'; import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; import { ScriptingLanguageFactory } from '@/application/Common/ScriptingLanguage/ScriptingLanguageFactory'; +import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax'; import { BatchFileSyntax } from './BatchFileSyntax'; import { ShellScriptSyntax } from './ShellScriptSyntax'; import { ISyntaxFactory } from './ISyntaxFactory'; diff --git a/src/domain/ScriptCode.ts b/src/domain/ScriptCode.ts index b2e282cc..89791ac8 100644 --- a/src/domain/ScriptCode.ts +++ b/src/domain/ScriptCode.ts @@ -4,25 +4,18 @@ export class ScriptCode implements IScriptCode { constructor( public readonly execute: string, public readonly revert: string, - syntax: ILanguageSyntax, ) { - if (!syntax) { throw new Error('missing syntax'); } - validateCode(execute, syntax); - validateRevertCode(revert, execute, syntax); + validateCode(execute); + validateRevertCode(revert, execute); } } -export interface ILanguageSyntax { - readonly commentDelimiters: string[]; - readonly commonCodeParts: string[]; -} - -function validateRevertCode(revertCode: string, execute: string, syntax: ILanguageSyntax) { +function validateRevertCode(revertCode: string, execute: string) { if (!revertCode) { return; } try { - validateCode(revertCode, syntax); + validateCode(revertCode); if (execute === revertCode) { throw new Error('Code itself and its reverting code cannot be the same'); } @@ -31,54 +24,8 @@ function validateRevertCode(revertCode: string, execute: string, syntax: ILangua } } -function validateCode(code: string, syntax: ILanguageSyntax): void { +function validateCode(code: string): void { if (!code || code.length === 0) { throw new Error('missing code'); } - ensureNoEmptyLines(code); - ensureCodeHasUniqueLines(code, syntax); -} - -function ensureNoEmptyLines(code: string): void { - const lines = code.split(/\r\n|\r|\n/); - if (lines.some((line) => line.trim().length === 0)) { - throw Error(`Script has empty lines:\n${lines.map((part, index) => `\n (${index}) ${part || '❌'}`).join('')}`); - } -} - -function ensureCodeHasUniqueLines(code: string, syntax: ILanguageSyntax): void { - const allLines = code.split(/\r\n|\r|\n/); - const checkedLines = allLines.filter((line) => !shouldIgnoreLine(line, syntax)); - if (checkedLines.length === 0) { - return; - } - const duplicateLines = checkedLines.filter((e, i, a) => a.indexOf(e) !== i); - if (duplicateLines.length !== 0) { - throw Error(`Duplicates detected in script:\n${printDuplicatedLines(allLines)}`); - } -} - -function printDuplicatedLines(allLines: string[]) { - return allLines - .map((line, index) => { - const occurrenceIndices = allLines - .map((e, i) => (e === line ? i : '')) - .filter(String); - const isDuplicate = occurrenceIndices.length > 1; - const indicator = isDuplicate ? `❌ (${occurrenceIndices.join(',')})\t` : '✅ '; - return `${indicator}[${index}] ${line}`; - }) - .join('\n'); -} - -function shouldIgnoreLine(codeLine: string, syntax: ILanguageSyntax): boolean { - 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/tests/unit/application/Parser/Script/CategoryCollectionParseContext.spec.ts b/tests/unit/application/Parser/Script/CategoryCollectionParseContext.spec.ts index cf056808..06d452a6 100644 --- a/tests/unit/application/Parser/Script/CategoryCollectionParseContext.spec.ts +++ b/tests/unit/application/Parser/Script/CategoryCollectionParseContext.spec.ts @@ -1,14 +1,14 @@ import 'mocha'; import { expect } from 'chai'; -import { ISyntaxFactory } from '@/application/Parser/Script/Syntax/ISyntaxFactory'; +import { ISyntaxFactory } from '@/application/Parser/Script/Validation/Syntax/ISyntaxFactory'; import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; import { CategoryCollectionParseContext } from '@/application/Parser/Script/CategoryCollectionParseContext'; -import { ILanguageSyntax } from '@/domain/ScriptCode'; import { ScriptCompiler } from '@/application/Parser/Script/Compiler/ScriptCompiler'; import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub'; import { ScriptingDefinitionStub } from '@tests/unit/shared/Stubs/ScriptingDefinitionStub'; import { FunctionDataStub } from '@tests/unit/shared/Stubs/FunctionDataStub'; import { itEachAbsentCollectionValue, itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; +import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax'; describe('CategoryCollectionParseContext', () => { describe('ctor', () => { diff --git a/tests/unit/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompiler.spec.ts b/tests/unit/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompiler.spec.ts index 406386cf..c995fc2a 100644 --- a/tests/unit/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompiler.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompiler.spec.ts @@ -11,8 +11,15 @@ import { FunctionCallArgumentCollectionStub } from '@tests/unit/shared/Stubs/Fun import { FunctionCallStub } from '@tests/unit/shared/Stubs/FunctionCallStub'; import { ExpressionsCompilerStub } from '@tests/unit/shared/Stubs/ExpressionsCompilerStub'; import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; +import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests'; describe('FunctionCallCompiler', () => { + describe('instance', () => { + itIsSingleton({ + getter: () => FunctionCallCompiler.instance, + expectedType: FunctionCallCompiler, + }); + }); describe('compileCall', () => { describe('parameter validation', () => { describe('call', () => { @@ -172,7 +179,7 @@ describe('FunctionCallCompiler', () => { const args = new FunctionCallArgumentCollectionStub().withArguments(testCase.callArgs); const { code } = func.body; const expressionsCompilerMock = new ExpressionsCompilerStub() - .setup({ givenCode: code.do, givenArgs: args, result: expected.execute }) + .setup({ givenCode: code.execute, givenArgs: args, result: expected.execute }) .setup({ givenCode: code.revert, givenArgs: args, result: expected.revert }); const sut = new MockableFunctionCallCompiler(expressionsCompilerMock); // act @@ -209,7 +216,7 @@ describe('FunctionCallCompiler', () => { const expressionsCompilerMock = new ExpressionsCompilerStub() .setupToReturnFunctionCode(firstFunction, firstFunctionCallArgs) .setupToReturnFunctionCode(secondFunction, secondFunctionCallArgs); - const expectedExecute = `${firstFunction.body.code.do}\n${secondFunction.body.code.do}`; + const expectedExecute = `${firstFunction.body.code.execute}\n${secondFunction.body.code.execute}`; const expectedRevert = `${firstFunction.body.code.revert}\n${secondFunction.body.code.revert}`; const functions = new SharedFunctionCollectionStub() .withFunction(firstFunction) @@ -244,7 +251,7 @@ describe('FunctionCallCompiler', () => { }; const expressionsCompilerMock = new ExpressionsCompilerStub() .setup({ - givenCode: functions.deep.body.code.do, + givenCode: functions.deep.body.code.execute, givenArgs: emptyArgs, result: expected.code, }) @@ -312,7 +319,7 @@ describe('FunctionCallCompiler', () => { }) // set-up compiling of deep, compiled argument should be sent .setup({ - givenCode: scenario.deep.getFunction().body.code.do, + givenCode: scenario.deep.getFunction().body.code.execute, givenArgs: scenario.front.callArgs.expectedCallDeep(), result: expected.code, }) @@ -407,7 +414,7 @@ describe('FunctionCallCompiler', () => { }) // Compiling of third functions code with expected arguments .setup({ - givenCode: scenario.third.getFunction().body.code.do, + givenCode: scenario.third.getFunction().body.code.execute, givenArgs: scenario.second.callArgs.expectedToThird(), result: expected.code, }) @@ -491,7 +498,7 @@ describe('FunctionCallCompiler', () => { .setupToReturnFunctionCode(functions.call2.deep.getFunction(), emptyArgs); const sut = new MockableFunctionCallCompiler(expressionsCompilerMock); const expected = { - code: `${functions.call1.deep.getFunction().body.code.do}\n${functions.call2.deep.getFunction().body.code.do}`, + code: `${functions.call1.deep.getFunction().body.code.execute}\n${functions.call2.deep.getFunction().body.code.execute}`, revert: `${functions.call1.deep.getFunction().body.code.revert}\n${functions.call2.deep.getFunction().body.code.revert}`, }; // act diff --git a/tests/unit/application/Parser/Script/Compiler/Function/SharedFunction.spec.ts b/tests/unit/application/Parser/Script/Compiler/Function/SharedFunction.spec.ts index ac805042..79672916 100644 --- a/tests/unit/application/Parser/Script/Compiler/Function/SharedFunction.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Function/SharedFunction.spec.ts @@ -12,174 +12,174 @@ import { } from '@tests/unit/shared/TestCases/AbsentTests'; describe('SharedFunction', () => { - describe('name', () => { - runForEachFactoryMethod((build) => { - it('sets as expected', () => { - // arrange - const expected = 'expected-function-name'; - const builder = new SharedFunctionBuilder() - .withName(expected); - // act - const sut = build(builder); - // assert - expect(sut.name).equal(expected); - }); - it('throws when absent', () => { - itEachAbsentStringValue((absentValue) => { - // arrange - const expectedError = 'missing function name'; - const builder = new SharedFunctionBuilder() - .withName(absentValue); - // act - const act = () => build(builder); - // assert - expect(act).to.throw(expectedError); - }); - }); - }); - }); - describe('parameters', () => { - runForEachFactoryMethod((build) => { - it('sets as expected', () => { - // arrange - const expected = new FunctionParameterCollectionStub() - .withParameterName('test-parameter'); - const builder = new SharedFunctionBuilder() - .withParameters(expected); - // act - const sut = build(builder); - // assert - expect(sut.parameters).equal(expected); - }); - describe('throws if missing', () => { - itEachAbsentObjectValue((absentValue) => { - // arrange - const expectedError = 'missing parameters'; - const parameters = absentValue; - const builder = new SharedFunctionBuilder() - .withParameters(parameters); - // act - const act = () => build(builder); - // assert - expect(act).to.throw(expectedError); - }); - }); - }); - }); - describe('body', () => { - describe('createFunctionWithInlineCode', () => { - describe('code', () => { + describe('SharedFunction', () => { + describe('name', () => { + runForEachFactoryMethod((build) => { it('sets as expected', () => { // arrange - const expected = 'expected-code'; + const expected = 'expected-function-name'; + const builder = new SharedFunctionBuilder() + .withName(expected); // act - const sut = new SharedFunctionBuilder() - .withCode(expected) - .createFunctionWithInlineCode(); + const sut = build(builder); // assert - expect(sut.body.code.do).equal(expected); + expect(sut.name).equal(expected); }); - describe('throws if absent', () => { + it('throws when absent', () => { itEachAbsentStringValue((absentValue) => { // arrange - const functionName = 'expected-function-name'; - const expectedError = `undefined code in function "${functionName}"`; - const invalidValue = absentValue; + const expectedError = 'missing function name'; + const builder = new SharedFunctionBuilder() + .withName(absentValue); // act - const act = () => new SharedFunctionBuilder() - .withName(functionName) - .withCode(invalidValue) - .createFunctionWithInlineCode(); + const act = () => build(builder); // assert expect(act).to.throw(expectedError); }); }); }); - describe('revertCode', () => { - it('sets as expected', () => { - // arrange - const testData = [ - 'expected-revert-code', - ...AbsentStringTestCases.map((testCase) => testCase.absentValue), - ]; - for (const data of testData) { - // act - const sut = new SharedFunctionBuilder() - .withRevertCode(data) - .createFunctionWithInlineCode(); - // assert - expect(sut.body.code.revert).equal(data); - } - }); - }); - it('sets type as expected', () => { - // arrange - const expectedType = FunctionBodyType.Code; - // act - const sut = new SharedFunctionBuilder() - .createFunctionWithInlineCode(); - // assert - expect(sut.body.type).equal(expectedType); - }); - it('calls are undefined', () => { - // arrange - const expectedCalls = undefined; - // act - const sut = new SharedFunctionBuilder() - .createFunctionWithInlineCode(); - // assert - expect(sut.body.calls).equal(expectedCalls); - }); }); - describe('createCallerFunction', () => { - describe('callSequence', () => { + describe('parameters', () => { + runForEachFactoryMethod((build) => { it('sets as expected', () => { // arrange - const expected = [ - new FunctionCallStub().withFunctionName('firstFunction'), - new FunctionCallStub().withFunctionName('secondFunction'), - ]; + const expected = new FunctionParameterCollectionStub() + .withParameterName('test-parameter'); + const builder = new SharedFunctionBuilder() + .withParameters(expected); // act - const sut = new SharedFunctionBuilder() - .withCallSequence(expected) - .createCallerFunction(); + const sut = build(builder); // assert - expect(sut.body.calls).equal(expected); + expect(sut.parameters).equal(expected); }); describe('throws if missing', () => { - itEachAbsentCollectionValue((absentValue) => { + itEachAbsentObjectValue((absentValue) => { // arrange - const functionName = 'invalidFunction'; - const callSequence = absentValue; - const expectedError = `missing call sequence in function "${functionName}"`; + const expectedError = 'missing parameters'; + const parameters = absentValue; + const builder = new SharedFunctionBuilder() + .withParameters(parameters); // act - const act = () => new SharedFunctionBuilder() - .withName(functionName) - .withCallSequence(callSequence) - .createCallerFunction(); + const act = () => build(builder); // assert expect(act).to.throw(expectedError); }); }); }); - it('sets type as expected', () => { + }); + }); + describe('createFunctionWithInlineCode', () => { + describe('code', () => { + it('sets as expected', () => { // arrange - const expectedType = FunctionBodyType.Calls; + const expected = 'expected-code'; // act const sut = new SharedFunctionBuilder() - .createCallerFunction(); + .withCode(expected) + .createFunctionWithInlineCode(); // assert - expect(sut.body.type).equal(expectedType); + expect(sut.body.code.execute).equal(expected); }); - it('code is undefined', () => { + describe('throws if absent', () => { + itEachAbsentStringValue((absentValue) => { + // arrange + const functionName = 'expected-function-name'; + const expectedError = `undefined code in function "${functionName}"`; + const invalidValue = absentValue; + // act + const act = () => new SharedFunctionBuilder() + .withName(functionName) + .withCode(invalidValue) + .createFunctionWithInlineCode(); + // assert + expect(act).to.throw(expectedError); + }); + }); + }); + describe('revertCode', () => { + it('sets as expected', () => { // arrange - const expectedCode = undefined; + const testData = [ + 'expected-revert-code', + ...AbsentStringTestCases.map((testCase) => testCase.absentValue), + ]; + for (const data of testData) { + // act + const sut = new SharedFunctionBuilder() + .withRevertCode(data) + .createFunctionWithInlineCode(); + // assert + expect(sut.body.code.revert).equal(data); + } + }); + }); + it('sets type as expected', () => { + // arrange + const expectedType = FunctionBodyType.Code; + // act + const sut = new SharedFunctionBuilder() + .createFunctionWithInlineCode(); + // assert + expect(sut.body.type).equal(expectedType); + }); + it('calls are undefined', () => { + // arrange + const expectedCalls = undefined; + // act + const sut = new SharedFunctionBuilder() + .createFunctionWithInlineCode(); + // assert + expect(sut.body.calls).equal(expectedCalls); + }); + }); + describe('createCallerFunction', () => { + describe('callSequence', () => { + it('sets as expected', () => { + // arrange + const expected = [ + new FunctionCallStub().withFunctionName('firstFunction'), + new FunctionCallStub().withFunctionName('secondFunction'), + ]; // act const sut = new SharedFunctionBuilder() + .withCallSequence(expected) .createCallerFunction(); // assert - expect(sut.body.code).equal(expectedCode); + expect(sut.body.calls).equal(expected); }); + describe('throws if missing', () => { + itEachAbsentCollectionValue((absentValue) => { + // arrange + const functionName = 'invalidFunction'; + const callSequence = absentValue; + const expectedError = `missing call sequence in function "${functionName}"`; + // act + const act = () => new SharedFunctionBuilder() + .withName(functionName) + .withCallSequence(callSequence) + .createCallerFunction(); + // assert + expect(act).to.throw(expectedError); + }); + }); + }); + it('sets type as expected', () => { + // arrange + const expectedType = FunctionBodyType.Calls; + // act + const sut = new SharedFunctionBuilder() + .createCallerFunction(); + // assert + expect(sut.body.type).equal(expectedType); + }); + it('code is undefined', () => { + // arrange + const expectedCode = undefined; + // act + const sut = new SharedFunctionBuilder() + .createCallerFunction(); + // assert + expect(sut.body.code).equal(expectedCode); }); }); }); diff --git a/tests/unit/application/Parser/Script/Compiler/Function/SharedFunctionsParser.spec.ts b/tests/unit/application/Parser/Script/Compiler/Function/SharedFunctionsParser.spec.ts index 9ca484de..a225ac6f 100644 --- a/tests/unit/application/Parser/Script/Compiler/Function/SharedFunctionsParser.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Function/SharedFunctionsParser.spec.ts @@ -8,18 +8,46 @@ import { ParameterDefinitionDataStub } from '@tests/unit/shared/Stubs/ParameterD import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameter'; import { FunctionCallDataStub } from '@tests/unit/shared/Stubs/FunctionCallDataStub'; import { itEachAbsentCollectionValue, itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; +import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax'; +import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub'; +import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests'; +import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub'; +import { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator'; +import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines'; +import { NoDuplicatedLines } from '@/application/Parser/Script/Validation/Rules/NoDuplicatedLines'; describe('SharedFunctionsParser', () => { + describe('instance', () => { + itIsSingleton({ + getter: () => SharedFunctionsParser.instance, + expectedType: SharedFunctionsParser, + }); + }); describe('parseFunctions', () => { + describe('throws if syntax is missing', () => { + itEachAbsentObjectValue((absentValue) => { + // arrange + const expectedError = 'missing syntax'; + const syntax = absentValue; + // act + const act = () => new ParseFunctionsCallerWithDefaults() + .withSyntax(syntax) + .parseFunctions(); + // assert + expect(act).to.throw(expectedError); + }); + }); describe('validates functions', () => { describe('throws if one of the functions is undefined', () => { itEachAbsentObjectValue((absentValue) => { // arrange const expectedError = 'some functions are undefined'; const functions = [FunctionDataStub.createWithCode(), absentValue]; - const sut = new SharedFunctionsParser(); + const sut = new ParseFunctionsCallerWithDefaults(); // act - const act = () => sut.parseFunctions(functions); + const act = () => sut + .withFunctions(functions) + .parseFunctions(); // assert expect(act).to.throw(expectedError); }); @@ -32,9 +60,10 @@ describe('SharedFunctionsParser', () => { FunctionDataStub.createWithCode().withName(name), FunctionDataStub.createWithCode().withName(name), ]; - const sut = new SharedFunctionsParser(); // act - const act = () => sut.parseFunctions(functions); + const act = () => new ParseFunctionsCallerWithDefaults() + .withFunctions(functions) + .parseFunctions(); // assert expect(act).to.throw(expectedError); }); @@ -47,9 +76,10 @@ describe('SharedFunctionsParser', () => { FunctionDataStub.createWithoutCallOrCodes().withName('func-1').withCode(code), FunctionDataStub.createWithoutCallOrCodes().withName('func-2').withCode(code), ]; - const sut = new SharedFunctionsParser(); // act - const act = () => sut.parseFunctions(functions); + const act = () => new ParseFunctionsCallerWithDefaults() + .withFunctions(functions) + .parseFunctions(); // assert expect(act).to.throw(expectedError); }); @@ -63,9 +93,10 @@ describe('SharedFunctionsParser', () => { FunctionDataStub.createWithoutCallOrCodes() .withName('func-2').withCode('code-2').withRevertCode(revertCode), ]; - const sut = new SharedFunctionsParser(); // act - const act = () => sut.parseFunctions(functions); + const act = () => new ParseFunctionsCallerWithDefaults() + .withFunctions(functions) + .parseFunctions(); // assert expect(act).to.throw(expectedError); }); @@ -79,9 +110,10 @@ describe('SharedFunctionsParser', () => { .withName(functionName) .withCode('code') .withMockCall(); - const sut = new SharedFunctionsParser(); // act - const act = () => sut.parseFunctions([invalidFunction]); + const act = () => new ParseFunctionsCallerWithDefaults() + .withFunctions([invalidFunction]) + .parseFunctions(); // assert expect(act).to.throw(expectedError); }); @@ -91,9 +123,10 @@ describe('SharedFunctionsParser', () => { const expectedError = `neither "code" or "call" is defined in "${functionName}"`; const invalidFunction = FunctionDataStub.createWithoutCallOrCodes() .withName(functionName); - const sut = new SharedFunctionsParser(); // act - const act = () => sut.parseFunctions([invalidFunction]); + const act = () => new ParseFunctionsCallerWithDefaults() + .withFunctions([invalidFunction]) + .parseFunctions(); // assert expect(act).to.throw(expectedError); }); @@ -116,14 +149,34 @@ describe('SharedFunctionsParser', () => { .createWithCall() .withParametersObject(testCase.invalidType as never); const expectedError = `parameters must be an array of objects in function(s) "${func.name}"`; - const sut = new SharedFunctionsParser(); // act - const act = () => sut.parseFunctions([func]); + const act = () => new ParseFunctionsCallerWithDefaults() + .withFunctions([func]) + .parseFunctions(); // assert expect(act).to.throw(expectedError); }); } }); + it('validates function code as expected when code is defined', () => { + // arrange + const expectedRules = [NoEmptyLines, NoDuplicatedLines]; + const functionData = FunctionDataStub + .createWithCode() + .withCode('expected code to be validated') + .withRevertCode('expected revert code to be validated'); + const validator = new CodeValidatorStub(); + // act + new ParseFunctionsCallerWithDefaults() + .withFunctions([functionData]) + .withValidator(validator) + .parseFunctions(); + // assert + validator.assertHistory({ + validatedCodes: [functionData.code, functionData.revertCode], + rules: expectedRules, + }); + }); it('rethrows including function name when FunctionParameter throws', () => { // arrange const invalidParameterName = 'invalid function p@r4meter name'; @@ -139,8 +192,9 @@ describe('SharedFunctionsParser', () => { .withParameters(new ParameterDefinitionDataStub().withName(invalidParameterName)); // act - const sut = new SharedFunctionsParser(); - const act = () => sut.parseFunctions([functionData]); + const act = () => new ParseFunctionsCallerWithDefaults() + .withFunctions([functionData]) + .parseFunctions(); // assert expect(act).to.throw(expectedError); @@ -148,10 +202,10 @@ describe('SharedFunctionsParser', () => { }); describe('given empty functions, returns empty collection', () => { itEachAbsentCollectionValue((absentValue) => { - // arrange - const sut = new SharedFunctionsParser(); // act - const actual = sut.parseFunctions(absentValue); + const actual = new ParseFunctionsCallerWithDefaults() + .withFunctions(absentValue) + .parseFunctions(); // assert expect(actual).to.not.equal(undefined); }); @@ -169,9 +223,10 @@ describe('SharedFunctionsParser', () => { new ParameterDefinitionDataStub().withName('expectedParameter').withOptionality(true), new ParameterDefinitionDataStub().withName('expectedParameter2').withOptionality(false), ); - const sut = new SharedFunctionsParser(); // act - const collection = sut.parseFunctions([expected]); + const collection = new ParseFunctionsCallerWithDefaults() + .withFunctions([expected]) + .parseFunctions(); // expect const actual = collection.getFunctionByName(name); expectEqualName(expected, actual); @@ -188,9 +243,10 @@ describe('SharedFunctionsParser', () => { const data = FunctionDataStub.createWithoutCallOrCodes() .withName('caller-function') .withCall(call); - const sut = new SharedFunctionsParser(); // act - const collection = sut.parseFunctions([data]); + const collection = new ParseFunctionsCallerWithDefaults() + .withFunctions([data]) + .parseFunctions(); // expect const actual = collection.getFunctionByName(data.name); expectEqualName(data, actual); @@ -211,9 +267,10 @@ describe('SharedFunctionsParser', () => { const caller2 = FunctionDataStub.createWithoutCallOrCodes() .withName('caller-function-2') .withCall([call1, call2]); - const sut = new SharedFunctionsParser(); // act - const collection = sut.parseFunctions([caller1, caller2]); + const collection = new ParseFunctionsCallerWithDefaults() + .withFunctions([caller1, caller2]) + .parseFunctions(); // expect const compiledCaller1 = collection.getFunctionByName(caller1.name); expectEqualName(caller1, compiledCaller1); @@ -228,6 +285,34 @@ describe('SharedFunctionsParser', () => { }); }); +class ParseFunctionsCallerWithDefaults { + private syntax: ILanguageSyntax = new LanguageSyntaxStub(); + + private codeValidator: ICodeValidator = new CodeValidatorStub(); + + private functions: readonly FunctionData[] = [FunctionDataStub.createWithCode()]; + + public withSyntax(syntax: ILanguageSyntax) { + this.syntax = syntax; + return this; + } + + public withValidator(codeValidator: ICodeValidator) { + this.codeValidator = codeValidator; + return this; + } + + public withFunctions(functions: readonly FunctionData[]) { + this.functions = functions; + return this; + } + + public parseFunctions() { + const sut = new SharedFunctionsParser(this.codeValidator); + return sut.parseFunctions(this.functions, this.syntax); + } +} + function expectEqualName(expected: FunctionDataStub, actual: ISharedFunction): void { expect(actual.name).to.equal(expected.name); } @@ -250,7 +335,7 @@ function expectEqualFunctionWithInlineCode( ): void { expect(actual.body, `function "${actual.name}" has no body`); expect(actual.body.code, `function "${actual.name}" has no code`); - expect(actual.body.code.do).to.equal(expected.code); + expect(actual.body.code.execute).to.equal(expected.code); expect(actual.body.code.revert).to.equal(expected.revertCode); } diff --git a/tests/unit/application/Parser/Script/Compiler/ScriptCompiler.spec.ts b/tests/unit/application/Parser/Script/Compiler/ScriptCompiler.spec.ts index 3402a96a..4e77abe8 100644 --- a/tests/unit/application/Parser/Script/Compiler/ScriptCompiler.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/ScriptCompiler.spec.ts @@ -1,7 +1,7 @@ import 'mocha'; import { expect } from 'chai'; import type { FunctionData } from '@/application/collections/'; -import { ILanguageSyntax, ScriptCode } from '@/domain/ScriptCode'; +import { ScriptCode } from '@/domain/ScriptCode'; import { ScriptCompiler } from '@/application/Parser/Script/Compiler/ScriptCompiler'; import { ISharedFunctionsParser } from '@/application/Parser/Script/Compiler/Function/ISharedFunctionsParser'; import { ICompiledCode } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/ICompiledCode'; @@ -15,6 +15,10 @@ import { SharedFunctionCollectionStub } from '@tests/unit/shared/Stubs/SharedFun import { parseFunctionCalls } from '@/application/Parser/Script/Compiler/Function/Call/FunctionCallParser'; import { FunctionCallDataStub } from '@tests/unit/shared/Stubs/FunctionCallDataStub'; import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; +import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax'; +import { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator'; +import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub'; +import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines'; describe('ScriptCompiler', () => { describe('ctor', () => { @@ -111,28 +115,38 @@ describe('ScriptCompiler', () => { expect(code.execute).to.equal(expected.code); expect(code.revert).to.equal(expected.revertCode); }); - it('creates with expected syntax', () => { - // arrange - let isUsed = false; - const syntax: ILanguageSyntax = { - get commentDelimiters() { - isUsed = true; - return []; - }, - get commonCodeParts() { - isUsed = true; - return []; - }, - }; - const sut = new ScriptCompilerBuilder() - .withSomeFunctions() - .withSyntax(syntax) - .build(); - const scriptData = ScriptDataStub.createWithCall(); - // act - sut.compile(scriptData); - // assert - expect(isUsed).to.equal(true); + describe('parses functions as expected', () => { + it('parses functions with expected syntax', () => { + // arrange + const expected: ILanguageSyntax = new LanguageSyntaxStub(); + const parser = new SharedFunctionsParserStub(); + const sut = new ScriptCompilerBuilder() + .withSomeFunctions() + .withSyntax(expected) + .withSharedFunctionsParser(parser) + .build(); + const scriptData = ScriptDataStub.createWithCall(); + // act + sut.compile(scriptData); + // assert + expect(parser.callHistory.length).to.equal(1); + expect(parser.callHistory[0].syntax).to.equal(expected); + }); + it('parses given functions', () => { + // arrange + const expectedFunctions = [FunctionDataStub.createWithCode().withName('existing-func')]; + const parser = new SharedFunctionsParserStub(); + const sut = new ScriptCompilerBuilder() + .withFunctions(...expectedFunctions) + .withSharedFunctionsParser(parser) + .build(); + const scriptData = ScriptDataStub.createWithCall(); + // act + sut.compile(scriptData); + // assert + expect(parser.callHistory.length).to.equal(1); + expect(parser.callHistory[0].functions).to.deep.equal(expectedFunctions); + }); }); it('rethrows error with script name', () => { // arrange @@ -159,7 +173,7 @@ describe('ScriptCompiler', () => { const syntax = new LanguageSyntaxStub(); const invalidCode: ICompiledCode = { code: undefined, revertCode: undefined }; const realExceptionMessage = collectExceptionMessage( - () => new ScriptCode(invalidCode.code, invalidCode.revertCode, syntax), + () => new ScriptCode(invalidCode.code, invalidCode.revertCode), ); const expectedError = `Script "${scriptName}" ${realExceptionMessage}`; const callCompiler: IFunctionCallCompiler = { @@ -177,6 +191,26 @@ describe('ScriptCompiler', () => { // assert expect(act).to.throw(expectedError); }); + it('validates compiled code as expected', () => { + // arrange + const expectedRules = [ + NoEmptyLines, + // Allow duplicated lines to enable calling same function multiple times + ]; + const scriptData = ScriptDataStub.createWithCall(); + const validator = new CodeValidatorStub(); + const sut = new ScriptCompilerBuilder() + .withSomeFunctions() + .withCodeValidator(validator) + .build(); + // act + const compilationResult = sut.compile(scriptData); + // assert + validator.assertHistory({ + validatedCodes: [compilationResult.execute, compilationResult.revert], + rules: expectedRules, + }); + }); }); }); @@ -195,6 +229,8 @@ class ScriptCompilerBuilder { private callCompiler: IFunctionCallCompiler = new FunctionCallCompilerStub(); + private codeValidator: ICodeValidator = new CodeValidatorStub(); + public withFunctions(...functions: FunctionData[]): ScriptCompilerBuilder { this.functions = functions; return this; @@ -227,6 +263,13 @@ class ScriptCompilerBuilder { return this; } + public withCodeValidator( + codeValidator: ICodeValidator, + ): ScriptCompilerBuilder { + this.codeValidator = codeValidator; + return this; + } + public withFunctionCallCompiler(callCompiler: IFunctionCallCompiler): ScriptCompilerBuilder { this.callCompiler = callCompiler; return this; @@ -239,8 +282,9 @@ class ScriptCompilerBuilder { return new ScriptCompiler( this.functions, this.syntax, - this.callCompiler, this.sharedFunctionsParser, + this.callCompiler, + this.codeValidator, ); } } diff --git a/tests/unit/application/Parser/Script/ScriptParser.spec.ts b/tests/unit/application/Parser/Script/ScriptParser.spec.ts index 5e8f6e31..d58a4dc2 100644 --- a/tests/unit/application/Parser/Script/ScriptParser.spec.ts +++ b/tests/unit/application/Parser/Script/ScriptParser.spec.ts @@ -16,6 +16,10 @@ import { NodeType } from '@/application/Parser/NodeValidation/NodeType'; import { expectThrowsNodeError, ITestScenario, NodeValidationTestRunner } from '@tests/unit/application/Parser/NodeValidation/NodeValidatorTestRunner'; import { Script } from '@/domain/Script'; import { IEnumParser } from '@/application/Common/Enum'; +import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines'; +import { NoDuplicatedLines } from '@/application/Parser/Script/Validation/Rules/NoDuplicatedLines'; +import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub'; +import { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator'; describe('ScriptParser', () => { describe('parseScript', () => { @@ -155,6 +159,53 @@ describe('ScriptParser', () => { 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 validator = new CodeValidatorStub(); + const script = ScriptDataStub + .createWithCode() + .withCode('expected code to be validated') + .withRevertCode('expected revert code to be validated'); + // act + new TestBuilder() + .withData(script) + .withCodeValidator(validator) + .parseScript(); + // assert + validator.assertHistory({ + validatedCodes: [script.code, script.revertCode], + rules: expectedRules, + }); + }); + it('does not validate compiled code', () => { + // arrange + const expectedRules = []; + const expectedCodeCalls = []; + const validator = new CodeValidatorStub(); + const script = ScriptDataStub + .createWithCall(); + const compiler = new ScriptCompilerStub() + .withCompileAbility(script, new ScriptCodeStub()); + const parseContext = new CategoryCollectionParseContextStub() + .withCompiler(compiler); + // act + new TestBuilder() + .withData(script) + .withCodeValidator(validator) + .withContext(parseContext) + .parseScript(); + // assert + validator.assertHistory({ + validatedCodes: expectedCodeCalls, + rules: expectedRules, + }); + }); + }); }); describe('invalid script data', () => { describe('validates script data', () => { @@ -233,6 +284,13 @@ class TestBuilder { private factory: ScriptFactoryType = undefined; + private codeValidator: ICodeValidator = new CodeValidatorStub(); + + public withCodeValidator(codeValidator: ICodeValidator) { + this.codeValidator = codeValidator; + return this; + } + public withData(data: ScriptData) { this.data = data; return this; @@ -254,6 +312,6 @@ class TestBuilder { } public parseScript(): Script { - return parseScript(this.data, this.context, this.parser, this.factory); + return parseScript(this.data, this.context, this.parser, this.factory, this.codeValidator); } } diff --git a/tests/unit/application/Parser/Script/Validation/CodeValidator.spec.ts b/tests/unit/application/Parser/Script/Validation/CodeValidator.spec.ts new file mode 100644 index 00000000..ba310c73 --- /dev/null +++ b/tests/unit/application/Parser/Script/Validation/CodeValidator.spec.ts @@ -0,0 +1,169 @@ +import 'mocha'; +import { expect } from 'chai'; +import { CodeValidator } from '@/application/Parser/Script/Validation/CodeValidator'; +import { CodeValidationRuleStub } from '@tests/unit/shared/Stubs/CodeValidationRuleStub'; +import { itEachAbsentCollectionValue, itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests'; +import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests'; +import { ICodeLine } from '@/application/Parser/Script/Validation/ICodeLine'; +import { IInvalidCodeLine } from '@/application/Parser/Script/Validation/ICodeValidationRule'; + +describe('CodeValidator', () => { + describe('instance', () => { + itIsSingleton({ + getter: () => CodeValidator.instance, + expectedType: CodeValidator, + }); + }); + 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(); + }); + }); + 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); + }); + }); + 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('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); + }); + }); + 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); + }); + }); + }); +}); + +class ExpectedErrorBuilder { + private lineCount = 0; + + private outputLines = new Array(); + + public withOkLine(text: string) { + return this.withNumberedLine(`✅ ${text}`); + } + + public withErrorLine(text: string, error: string) { + return this + .withNumberedLine(`❌ ${text}`) + .withLine(`\t⟶ ${error}`); + } + + public buildError(): string { + return [ + 'Errors with the code.', + ...this.outputLines, + ].join('\n'); + } + + private withLine(line: string) { + this.outputLines.push(line); + return this; + } + + private withNumberedLine(text: string) { + this.lineCount += 1; + const lineNumber = `[${this.lineCount}]`; + return this.withLine(`${lineNumber} ${text}`); + } +} diff --git a/tests/unit/application/Parser/Script/Validation/Rules/CodeValidationRuleTestRunner.ts b/tests/unit/application/Parser/Script/Validation/Rules/CodeValidationRuleTestRunner.ts new file mode 100644 index 00000000..271cb1f4 --- /dev/null +++ b/tests/unit/application/Parser/Script/Validation/Rules/CodeValidationRuleTestRunner.ts @@ -0,0 +1,37 @@ +import 'mocha'; +import { expect } from 'chai'; +import { ICodeValidationRule, IInvalidCodeLine } from '@/application/Parser/Script/Validation/ICodeValidationRule'; +import { ICodeLine } from '@/application/Parser/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/Script/Validation/Rules/NoDuplicatedLines.spec.ts b/tests/unit/application/Parser/Script/Validation/Rules/NoDuplicatedLines.spec.ts new file mode 100644 index 00000000..c1fe2f93 --- /dev/null +++ b/tests/unit/application/Parser/Script/Validation/Rules/NoDuplicatedLines.spec.ts @@ -0,0 +1,93 @@ +import 'mocha'; +import { expect } from 'chai'; +import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; +import { NoDuplicatedLines } from '@/application/Parser/Script/Validation/Rules/NoDuplicatedLines'; +import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub'; +import { IInvalidCodeLine } from '@/application/Parser/Script/Validation/ICodeValidationRule'; +import { testCodeValidationRule } from './CodeValidationRuleTestRunner'; + +describe('NoDuplicatedLines', () => { + describe('ctor', () => { + describe('throws if syntax is missing', () => { + itEachAbsentObjectValue((absentValue) => { + // arrange + const expectedError = 'missing syntax'; + const syntax = absentValue; + // act + const act = () => new NoDuplicatedLines(syntax); + // assert + expect(act).to.throw(expectedError); + }); + }); + }); + 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/Script/Validation/Rules/NoEmptyLines.spec.ts b/tests/unit/application/Parser/Script/Validation/Rules/NoEmptyLines.spec.ts new file mode 100644 index 00000000..ef596700 --- /dev/null +++ b/tests/unit/application/Parser/Script/Validation/Rules/NoEmptyLines.spec.ts @@ -0,0 +1,51 @@ +import { NoEmptyLines } from '@/application/Parser/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 undefined and null lines', + codeLines: ['first line', undefined, 'third line', null], + 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/Script/Syntax/ConcreteSyntaxes.spec.ts b/tests/unit/application/Parser/Script/Validation/Syntax/ConcreteSyntaxes.spec.ts similarity index 71% rename from tests/unit/application/Parser/Script/Syntax/ConcreteSyntaxes.spec.ts rename to tests/unit/application/Parser/Script/Validation/Syntax/ConcreteSyntaxes.spec.ts index c0589813..5c730d1b 100644 --- a/tests/unit/application/Parser/Script/Syntax/ConcreteSyntaxes.spec.ts +++ b/tests/unit/application/Parser/Script/Validation/Syntax/ConcreteSyntaxes.spec.ts @@ -1,8 +1,8 @@ import 'mocha'; import { expect } from 'chai'; -import { ILanguageSyntax } from '@/domain/ScriptCode'; -import { BatchFileSyntax } from '@/application/Parser/Script/Syntax/BatchFileSyntax'; -import { ShellScriptSyntax } from '@/application/Parser/Script/Syntax/ShellScriptSyntax'; +import { ShellScriptSyntax } from '@/application/Parser/Script/Validation/Syntax/ShellScriptSyntax'; +import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax'; +import { BatchFileSyntax } from '@/application/Parser/Script/Validation/Syntax/BatchFileSyntax'; function getSystemsUnderTest(): ILanguageSyntax[] { return [new BatchFileSyntax(), new ShellScriptSyntax()]; diff --git a/tests/unit/application/Parser/Script/Syntax/SyntaxFactory.spec.ts b/tests/unit/application/Parser/Script/Validation/Syntax/SyntaxFactory.spec.ts similarity index 63% rename from tests/unit/application/Parser/Script/Syntax/SyntaxFactory.spec.ts rename to tests/unit/application/Parser/Script/Validation/Syntax/SyntaxFactory.spec.ts index 99abec0b..24845864 100644 --- a/tests/unit/application/Parser/Script/Syntax/SyntaxFactory.spec.ts +++ b/tests/unit/application/Parser/Script/Validation/Syntax/SyntaxFactory.spec.ts @@ -1,9 +1,9 @@ import 'mocha'; -import { SyntaxFactory } from '@/application/Parser/Script/Syntax/SyntaxFactory'; +import { SyntaxFactory } from '@/application/Parser/Script/Validation/Syntax/SyntaxFactory'; import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; -import { ShellScriptSyntax } from '@/application/Parser/Script/Syntax/ShellScriptSyntax'; -import { BatchFileSyntax } from '@/application/Parser/Script/Syntax/BatchFileSyntax'; +import { ShellScriptSyntax } from '@/application/Parser/Script/Validation/Syntax/ShellScriptSyntax'; import { ScriptingLanguageFactoryTestRunner } from '@tests/unit/application/Common/ScriptingLanguage/ScriptingLanguageFactoryTestRunner'; +import { BatchFileSyntax } from '@/application/Parser/Script/Validation/Syntax/BatchFileSyntax'; describe('SyntaxFactory', () => { const sut = new SyntaxFactory(); diff --git a/tests/unit/domain/ScriptCode.spec.ts b/tests/unit/domain/ScriptCode.spec.ts index 2327aa82..19efeca7 100644 --- a/tests/unit/domain/ScriptCode.spec.ts +++ b/tests/unit/domain/ScriptCode.spec.ts @@ -1,9 +1,7 @@ import 'mocha'; import { expect } from 'chai'; -import { ScriptCode, ILanguageSyntax } from '@/domain/ScriptCode'; -import { IScriptCode } from '@/domain/IScriptCode'; -import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub'; -import { AbsentStringTestCases, itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; +import { ScriptCode } from '@/domain/ScriptCode'; +import { AbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; describe('ScriptCode', () => { describe('code', () => { @@ -39,116 +37,35 @@ describe('ScriptCode', () => { }); } }); - describe('throws with invalid code in both "execute" or "revert"', () => { - // arrange - const testCases = [ - { - testName: 'cannot construct with duplicate lines', - code: 'duplicate\nduplicate\nunique\nduplicate', - expectedMessage: 'Duplicates detected in script:\n❌ (0,1,3)\t[0] duplicate\n❌ (0,1,3)\t[1] duplicate\n✅ [2] unique\n❌ (0,1,3)\t[3] duplicate', - }, - { - testName: 'cannot construct with empty lines', - code: 'line1\n\n\nline2', - expectedMessage: 'Script has empty lines:\n\n (0) line1\n (1) ❌\n (2) ❌\n (3) line2', - }, - ]; - // act - const actions = testCases.flatMap((testCase) => ([ - { - act: () => new ScriptCodeBuilder() - .withExecute(testCase.code) - .build(), - testName: `execute: ${testCase.testName}`, - expectedMessage: testCase.expectedMessage, - code: testCase.code, - }, - { - act: () => new ScriptCodeBuilder() - .withRevert(testCase.code) - .build(), - testName: `revert: ${testCase.testName}`, - expectedMessage: `(revert): ${testCase.expectedMessage}`, - code: testCase.code, - }, - ])); - // assert - for (const action of actions) { - it(action.testName, () => { - expect(action.act).to.throw(action.expectedMessage, `Code used: ${action.code}`); - }); - } - }); describe('sets as expected with valid "execute" or "revert"', () => { // arrange - const syntax = new LanguageSyntaxStub() - .withCommonCodeParts(')', 'else', '(') - .withCommentDelimiters('#', '//'); const testCases = [ { - testName: 'code is a valid string', + testName: 'code and revert code is given', code: 'valid code', + revertCode: 'valid revert-code', }, { - testName: 'code consists of common code parts', - code: syntax.commonCodeParts.join(' '), - }, - { - testName: 'code is a common code part', - code: syntax.commonCodeParts[0], - }, - { - testName: `code with duplicated comment lines (${syntax.commentDelimiters[0]})`, - code: `${syntax.commentDelimiters[0]} comment\n${syntax.commentDelimiters[0]} comment`, - }, - { - testName: `code with duplicated comment lines (${syntax.commentDelimiters[1]})`, - code: `${syntax.commentDelimiters[1]} comment\n${syntax.commentDelimiters[1]} comment`, + testName: 'only code is given but not revert code', + code: 'valid code', + revertCode: undefined, }, ]; - // act - const actions = testCases.flatMap((testCase) => ([ - { - testName: `execute: ${testCase.testName}`, - act: () => new ScriptCodeBuilder() - .withSyntax(syntax) - .withExecute(testCase.code) - .build(), - expect: (sut: IScriptCode) => sut.execute === testCase.code, - }, - { - testName: `revert: ${testCase.testName}`, - act: () => new ScriptCodeBuilder() - .withSyntax(syntax) - .withRevert(testCase.code) - .build(), - expect: (sut: IScriptCode) => sut.revert === testCase.code, - }, - ])); // assert - for (const action of actions) { - it(action.testName, () => { - const sut = action.act(); - expect(action.expect(sut)); + for (const testCase of testCases) { + it(testCase.testName, () => { + // act + const sut = new ScriptCodeBuilder() + .withExecute(testCase.code) + .withRevert(testCase.revertCode) + .build(); + // assert + expect(sut.execute).to.equal(testCase.code); + expect(sut.revert).to.equal(testCase.revertCode); }); } }); }); - describe('syntax', () => { - describe('throws if missing', () => { - itEachAbsentObjectValue((absentValue) => { - // arrange - const expectedError = 'missing syntax'; - const syntax = absentValue; - // act - const act = () => new ScriptCodeBuilder() - .withSyntax(syntax) - .build(); - // assert - expect(act).to.throw(expectedError); - }); - }); - }); }); class ScriptCodeBuilder { @@ -156,8 +73,6 @@ class ScriptCodeBuilder { public revert = ''; - public syntax: ILanguageSyntax = new LanguageSyntaxStub(); - public withExecute(execute: string) { this.execute = execute; return this; @@ -168,16 +83,10 @@ class ScriptCodeBuilder { return this; } - public withSyntax(syntax: ILanguageSyntax) { - this.syntax = syntax; - return this; - } - public build(): ScriptCode { return new ScriptCode( this.execute, this.revert, - this.syntax, ); } } diff --git a/tests/unit/shared/Stubs/CategoryCollectionParseContextStub.ts b/tests/unit/shared/Stubs/CategoryCollectionParseContextStub.ts index 89922000..5fa7adba 100644 --- a/tests/unit/shared/Stubs/CategoryCollectionParseContextStub.ts +++ b/tests/unit/shared/Stubs/CategoryCollectionParseContextStub.ts @@ -1,6 +1,6 @@ import { ICategoryCollectionParseContext } from '@/application/Parser/Script/ICategoryCollectionParseContext'; import { IScriptCompiler } from '@/application/Parser/Script/Compiler/IScriptCompiler'; -import { ILanguageSyntax } from '@/domain/ScriptCode'; +import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax'; import { ScriptCompilerStub } from './ScriptCompilerStub'; import { LanguageSyntaxStub } from './LanguageSyntaxStub'; diff --git a/tests/unit/shared/Stubs/CodeValidationRuleStub.ts b/tests/unit/shared/Stubs/CodeValidationRuleStub.ts new file mode 100644 index 00000000..a1f0c7e3 --- /dev/null +++ b/tests/unit/shared/Stubs/CodeValidationRuleStub.ts @@ -0,0 +1,18 @@ +import { ICodeLine } from '@/application/Parser/Script/Validation/ICodeLine'; +import { ICodeValidationRule, IInvalidCodeLine } from '@/application/Parser/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 new file mode 100644 index 00000000..977102de --- /dev/null +++ b/tests/unit/shared/Stubs/CodeValidatorStub.ts @@ -0,0 +1,34 @@ +import { expect } from 'chai'; +import { ICodeValidationRule } from '@/application/Parser/Script/Validation/ICodeValidationRule'; +import { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator'; +import { Type } from '../Type'; + +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), + }); + } + + public assertHistory(expected: { + validatedCodes: readonly string[], + rules: readonly Type[], + }) { + expect(this.callHistory).to.have.lengthOf(expected.validatedCodes.length); + const actualValidatedCodes = this.callHistory.map((args) => args.code); + expect(actualValidatedCodes.sort()).deep.equal([...expected.validatedCodes].sort()); + for (const call of this.callHistory) { + const actualRules = call.rules.map((rule) => rule.constructor); + expect(actualRules.sort()).to.deep.equal([...expected.rules].sort()); + } + } +} diff --git a/tests/unit/shared/Stubs/ExpressionsCompilerStub.ts b/tests/unit/shared/Stubs/ExpressionsCompilerStub.ts index 6c6f124f..9a64a3aa 100644 --- a/tests/unit/shared/Stubs/ExpressionsCompilerStub.ts +++ b/tests/unit/shared/Stubs/ExpressionsCompilerStub.ts @@ -20,7 +20,7 @@ export class ExpressionsCompilerStub implements IExpressionsCompiler { givenArgs: FunctionCallArgumentCollectionStub, ) { return this - .setup({ givenCode: func.body.code.do, givenArgs, result: func.body.code.do }) + .setup({ givenCode: func.body.code.execute, givenArgs, result: func.body.code.execute }) .setup({ givenCode: func.body.code.revert, givenArgs, result: func.body.code.revert }); } diff --git a/tests/unit/shared/Stubs/FunctionCodeStub.ts b/tests/unit/shared/Stubs/FunctionCodeStub.ts index 0bbb9e20..71313b85 100644 --- a/tests/unit/shared/Stubs/FunctionCodeStub.ts +++ b/tests/unit/shared/Stubs/FunctionCodeStub.ts @@ -1,12 +1,12 @@ import { IFunctionCode } from '@/application/Parser/Script/Compiler/Function/ISharedFunction'; export class FunctionCodeStub implements IFunctionCode { - public do = 'do code (function-code-stub)'; + public execute = 'execute code (function-code-stub)'; public revert? = 'revert code (function-code-stub)'; - public withDo(code: string) { - this.do = code; + public withExecute(code: string) { + this.execute = code; return this; } diff --git a/tests/unit/shared/Stubs/LanguageSyntaxStub.ts b/tests/unit/shared/Stubs/LanguageSyntaxStub.ts index 6055f2ec..e71704f7 100644 --- a/tests/unit/shared/Stubs/LanguageSyntaxStub.ts +++ b/tests/unit/shared/Stubs/LanguageSyntaxStub.ts @@ -1,4 +1,4 @@ -import { ILanguageSyntax } from '@/domain/ScriptCode'; +import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax'; export class LanguageSyntaxStub implements ILanguageSyntax { public commentDelimiters = []; diff --git a/tests/unit/shared/Stubs/SharedFunctionStub.ts b/tests/unit/shared/Stubs/SharedFunctionStub.ts index 94f269b8..f3a2e4bd 100644 --- a/tests/unit/shared/Stubs/SharedFunctionStub.ts +++ b/tests/unit/shared/Stubs/SharedFunctionStub.ts @@ -26,7 +26,7 @@ export class SharedFunctionStub implements ISharedFunction { return { type: this.bodyType, code: this.bodyType === FunctionBodyType.Code ? { - do: this.code, + execute: this.code, revert: this.revertCode, } : undefined, calls: this.bodyType === FunctionBodyType.Calls ? this.calls : undefined, diff --git a/tests/unit/shared/Stubs/SharedFunctionsParserStub.ts b/tests/unit/shared/Stubs/SharedFunctionsParserStub.ts index 8bcf925f..2ec94901 100644 --- a/tests/unit/shared/Stubs/SharedFunctionsParserStub.ts +++ b/tests/unit/shared/Stubs/SharedFunctionsParserStub.ts @@ -2,9 +2,15 @@ import type { FunctionData } from '@/application/collections/'; import { sequenceEqual } from '@/application/Common/Array'; import { ISharedFunctionCollection } from '@/application/Parser/Script/Compiler/Function/ISharedFunctionCollection'; import { ISharedFunctionsParser } from '@/application/Parser/Script/Compiler/Function/ISharedFunctionsParser'; +import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax'; import { SharedFunctionCollectionStub } from './SharedFunctionCollectionStub'; export class SharedFunctionsParserStub implements ISharedFunctionsParser { + public callHistory = new Array<{ + functions: readonly FunctionData[], + syntax: ILanguageSyntax, + }>(); + private setupResults = new Array<{ functions: readonly FunctionData[], result: ISharedFunctionCollection, @@ -14,7 +20,14 @@ export class SharedFunctionsParserStub implements ISharedFunctionsParser { this.setupResults.push({ functions, result }); } - public parseFunctions(functions: readonly FunctionData[]): ISharedFunctionCollection { + public parseFunctions( + functions: readonly FunctionData[], + syntax: ILanguageSyntax, + ): ISharedFunctionCollection { + this.callHistory.push({ + functions: Array.from(functions), + syntax, + }); const result = this.findResult(functions); return result || new SharedFunctionCollectionStub(); } diff --git a/tests/unit/shared/TestCases/SingletonTests.ts b/tests/unit/shared/TestCases/SingletonTests.ts new file mode 100644 index 00000000..8ebb7539 --- /dev/null +++ b/tests/unit/shared/TestCases/SingletonTests.ts @@ -0,0 +1,24 @@ +import 'mocha'; +import { expect } from 'chai'; +import { Type } from '../Type'; + +interface ISingletonTestData { + getter: () => T; + expectedType: Type; +} + +export function itIsSingleton(test: ISingletonTestData): void { + it('gets the expected type', () => { + // act + const instance = test.getter(); + // assert + expect(instance).to.be.instanceOf(test.expectedType); + }); + it('multiple calls get the same instance', () => { + // act + const instance1 = test.getter(); + const instance2 = test.getter(); + // assert + expect(instance1).to.equal(instance2); + }); +} diff --git a/tests/unit/shared/Type.ts b/tests/unit/shared/Type.ts new file mode 100644 index 00000000..9cc35558 --- /dev/null +++ b/tests/unit/shared/Type.ts @@ -0,0 +1,5 @@ +// eslint-disable-next-line @typescript-eslint/ban-types +export type Type = Function & { + prototype: T, + apply: (this: unknown, args: TArgs) => void +};