Add validation for max line length in compiler

This commit adds validation logic in compiler to check for max allowed
characters per line for scripts. This allows preventing bugs caused by
limitation of terminal emulators.

Other supporting changes:

- Rename/refactor related code for clarity and better maintainability.
- Drop `I` prefix from interfaces to align with latest convention.
- Refactor CodeValidator to be functional rather than object-oriented
  for simplicity.
- Refactor syntax definition construction to be functional and be part
  of rule for better separation of concerns.
- Refactored validation logic to use an enum-based factory pattern for
  improved maintainability and scalability.
This commit is contained in:
undergroundwires
2024-08-27 11:32:52 +02:00
parent db090f3696
commit dc5c87376b
65 changed files with 2217 additions and 1350 deletions

View File

@@ -7,7 +7,7 @@ import { createEnumParser, type EnumParser } from '../Common/Enum';
import { parseCategory, type CategoryParser } from './Executable/CategoryParser'; import { parseCategory, type CategoryParser } from './Executable/CategoryParser';
import { parseScriptingDefinition, type ScriptingDefinitionParser } from './ScriptingDefinition/ScriptingDefinitionParser'; import { parseScriptingDefinition, type ScriptingDefinitionParser } from './ScriptingDefinition/ScriptingDefinitionParser';
import { createTypeValidator, type TypeValidator } from './Common/TypeValidator'; 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 = ( export const parseCategoryCollection: CategoryCollectionParser = (
content, content,
@@ -16,9 +16,9 @@ export const parseCategoryCollection: CategoryCollectionParser = (
) => { ) => {
validateCollection(content, utilities.validator); validateCollection(content, utilities.validator);
const scripting = utilities.parseScriptingDefinition(content.scripting, projectDetails); 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( const categories = content.actions.map(
(action) => utilities.parseCategory(action, collectionUtilities), (action) => utilities.parseCategory(action, collectionContext),
); );
const os = utilities.osParser.parseEnum(content.os, 'os'); const os = utilities.osParser.parseEnum(content.os, 'os');
const collection = utilities.createCategoryCollection({ const collection = utilities.createCategoryCollection({
@@ -60,7 +60,7 @@ interface CategoryCollectionParserUtilities {
readonly osParser: EnumParser<OperatingSystem>; readonly osParser: EnumParser<OperatingSystem>;
readonly validator: TypeValidator; readonly validator: TypeValidator;
readonly parseScriptingDefinition: ScriptingDefinitionParser; readonly parseScriptingDefinition: ScriptingDefinitionParser;
readonly createUtilities: CategoryCollectionSpecificUtilitiesFactory; readonly createContext: CategoryCollectionContextFactory;
readonly parseCategory: CategoryParser; readonly parseCategory: CategoryParser;
readonly createCategoryCollection: CategoryCollectionFactory; readonly createCategoryCollection: CategoryCollectionFactory;
} }
@@ -69,7 +69,7 @@ const DefaultUtilities: CategoryCollectionParserUtilities = {
osParser: createEnumParser(OperatingSystem), osParser: createEnumParser(OperatingSystem),
validator: createTypeValidator(), validator: createTypeValidator(),
parseScriptingDefinition, parseScriptingDefinition,
createUtilities: createCollectionUtilities, createContext: createCategoryCollectionContext,
parseCategory, parseCategory,
createCategoryCollection: (...args) => new CategoryCollection(...args), createCategoryCollection: (...args) => new CategoryCollection(...args),
}; };

View File

@@ -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<FunctionData> | undefined,
language: ScriptingLanguage,
compilerFactory?: ScriptCompilerFactory,
): CategoryCollectionContext;
}
export const createCategoryCollectionContext: CategoryCollectionContextFactory = (
functionsData: ReadonlyArray<FunctionData> | undefined,
language: ScriptingLanguage,
compilerFactory: ScriptCompilerFactory = createScriptCompiler,
) => {
return {
compiler: compilerFactory({
categoryContext: {
functions: functionsData ?? [],
language,
},
}),
language,
};
};

View File

@@ -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<FunctionData> | 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<FunctionData> | undefined,
scripting: IScriptingDefinition,
syntaxFactory?: ISyntaxFactory,
): CategoryCollectionSpecificUtilities;
}

View File

@@ -9,16 +9,16 @@ import { parseDocs, type DocsParser } from './DocumentationParser';
import { parseScript, type ScriptParser } from './Script/ScriptParser'; import { parseScript, type ScriptParser } from './Script/ScriptParser';
import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from './Validation/ExecutableValidator'; import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from './Validation/ExecutableValidator';
import { ExecutableType } from './Validation/ExecutableType'; import { ExecutableType } from './Validation/ExecutableType';
import type { CategoryCollectionSpecificUtilities } from './CategoryCollectionSpecificUtilities'; import type { CategoryCollectionContext } from './CategoryCollectionContext';
export const parseCategory: CategoryParser = ( export const parseCategory: CategoryParser = (
category: CategoryData, category: CategoryData,
collectionUtilities: CategoryCollectionSpecificUtilities, collectionContext: CategoryCollectionContext,
categoryUtilities: CategoryParserUtilities = DefaultCategoryParserUtilities, categoryUtilities: CategoryParserUtilities = DefaultCategoryParserUtilities,
) => { ) => {
return parseCategoryRecursively({ return parseCategoryRecursively({
categoryData: category, categoryData: category,
collectionUtilities, collectionContext,
categoryUtilities, categoryUtilities,
}); });
}; };
@@ -26,14 +26,14 @@ export const parseCategory: CategoryParser = (
export interface CategoryParser { export interface CategoryParser {
( (
category: CategoryData, category: CategoryData,
collectionUtilities: CategoryCollectionSpecificUtilities, collectionContext: CategoryCollectionContext,
categoryUtilities?: CategoryParserUtilities, categoryUtilities?: CategoryParserUtilities,
): Category; ): Category;
} }
interface CategoryParseContext { interface CategoryParseContext {
readonly categoryData: CategoryData; readonly categoryData: CategoryData;
readonly collectionUtilities: CategoryCollectionSpecificUtilities; readonly collectionContext: CategoryCollectionContext;
readonly parentCategory?: CategoryData; readonly parentCategory?: CategoryData;
readonly categoryUtilities: CategoryParserUtilities; readonly categoryUtilities: CategoryParserUtilities;
} }
@@ -52,7 +52,7 @@ function parseCategoryRecursively(
children, children,
parent: context.categoryData, parent: context.categoryData,
categoryUtilities: context.categoryUtilities, categoryUtilities: context.categoryUtilities,
collectionUtilities: context.collectionUtilities, collectionContext: context.collectionContext,
}); });
} }
try { try {
@@ -104,7 +104,7 @@ interface ExecutableParseContext {
readonly data: ExecutableData; readonly data: ExecutableData;
readonly children: CategoryChildren; readonly children: CategoryChildren;
readonly parent: CategoryData; readonly parent: CategoryData;
readonly collectionUtilities: CategoryCollectionSpecificUtilities; readonly collectionContext: CategoryCollectionContext;
readonly categoryUtilities: CategoryParserUtilities; readonly categoryUtilities: CategoryParserUtilities;
} }
@@ -124,13 +124,13 @@ function parseUnknownExecutable(context: ExecutableParseContext) {
if (isCategory(context.data)) { if (isCategory(context.data)) {
const subCategory = parseCategoryRecursively({ const subCategory = parseCategoryRecursively({
categoryData: context.data, categoryData: context.data,
collectionUtilities: context.collectionUtilities, collectionContext: context.collectionContext,
parentCategory: context.parent, parentCategory: context.parent,
categoryUtilities: context.categoryUtilities, categoryUtilities: context.categoryUtilities,
}); });
context.children.subcategories.push(subCategory); context.children.subcategories.push(subCategory);
} else { // A script } 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); context.children.subscripts.push(script);
} }
} }

View File

@@ -2,14 +2,12 @@ import type {
FunctionData, CodeInstruction, CodeFunctionData, CallFunctionData, FunctionData, CodeInstruction, CodeFunctionData, CallFunctionData,
CallInstruction, ParameterDefinitionData, CallInstruction, ParameterDefinitionData,
} from '@/application/collections/'; } from '@/application/collections/';
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax'; import { validateCode, type CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator';
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 { isArray, isNullOrUndefined, isPlainObject } from '@/TypeHelpers'; import { isArray, isNullOrUndefined, isPlainObject } from '@/TypeHelpers';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError'; import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings'; 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 { createFunctionWithInlineCode, createCallerFunction } from './SharedFunction';
import { SharedFunctionCollection } from './SharedFunctionCollection'; import { SharedFunctionCollection } from './SharedFunctionCollection';
import { parseFunctionCalls, type FunctionCallsParser } from './Call/FunctionCallsParser'; import { parseFunctionCalls, type FunctionCallsParser } from './Call/FunctionCallsParser';
@@ -23,14 +21,14 @@ import type { ISharedFunction } from './ISharedFunction';
export interface SharedFunctionsParser { export interface SharedFunctionsParser {
( (
functions: readonly FunctionData[], functions: readonly FunctionData[],
syntax: ILanguageSyntax, language: ScriptingLanguage,
utilities?: SharedFunctionsParsingUtilities, utilities?: SharedFunctionsParsingUtilities,
): ISharedFunctionCollection; ): ISharedFunctionCollection;
} }
export const parseSharedFunctions: SharedFunctionsParser = ( export const parseSharedFunctions: SharedFunctionsParser = (
functions: readonly FunctionData[], functions: readonly FunctionData[],
syntax: ILanguageSyntax, language: ScriptingLanguage,
utilities = DefaultUtilities, utilities = DefaultUtilities,
) => { ) => {
const collection = new SharedFunctionCollection(); const collection = new SharedFunctionCollection();
@@ -39,7 +37,7 @@ export const parseSharedFunctions: SharedFunctionsParser = (
} }
ensureValidFunctions(functions); ensureValidFunctions(functions);
return functions return functions
.map((func) => parseFunction(func, syntax, utilities)) .map((func) => parseFunction(func, language, utilities))
.reduce((acc, func) => { .reduce((acc, func) => {
acc.addFunction(func); acc.addFunction(func);
return acc; return acc;
@@ -49,7 +47,7 @@ export const parseSharedFunctions: SharedFunctionsParser = (
const DefaultUtilities: SharedFunctionsParsingUtilities = { const DefaultUtilities: SharedFunctionsParsingUtilities = {
wrapError: wrapErrorWithAdditionalContext, wrapError: wrapErrorWithAdditionalContext,
parseParameter: parseFunctionParameter, parseParameter: parseFunctionParameter,
codeValidator: CodeValidator.instance, codeValidator: validateCode,
createParameterCollection: createFunctionParameterCollection, createParameterCollection: createFunctionParameterCollection,
parseFunctionCalls, parseFunctionCalls,
}; };
@@ -57,20 +55,20 @@ const DefaultUtilities: SharedFunctionsParsingUtilities = {
interface SharedFunctionsParsingUtilities { interface SharedFunctionsParsingUtilities {
readonly wrapError: ErrorWithContextWrapper; readonly wrapError: ErrorWithContextWrapper;
readonly parseParameter: FunctionParameterParser; readonly parseParameter: FunctionParameterParser;
readonly codeValidator: ICodeValidator; readonly codeValidator: CodeValidator;
readonly createParameterCollection: FunctionParameterCollectionFactory; readonly createParameterCollection: FunctionParameterCollectionFactory;
readonly parseFunctionCalls: FunctionCallsParser; readonly parseFunctionCalls: FunctionCallsParser;
} }
function parseFunction( function parseFunction(
data: FunctionData, data: FunctionData,
syntax: ILanguageSyntax, language: ScriptingLanguage,
utilities: SharedFunctionsParsingUtilities, utilities: SharedFunctionsParsingUtilities,
): ISharedFunction { ): ISharedFunction {
const { name } = data; const { name } = data;
const parameters = parseParameters(data, utilities); const parameters = parseParameters(data, utilities);
if (hasCode(data)) { if (hasCode(data)) {
validateCode(data, syntax, utilities.codeValidator); validateNonEmptyCode(data, language, utilities.codeValidator);
return createFunctionWithInlineCode(name, parameters, data.code, data.revertCode); return createFunctionWithInlineCode(name, parameters, data.code, data.revertCode);
} }
// Has call // Has call
@@ -78,16 +76,20 @@ function parseFunction(
return createCallerFunction(name, parameters, calls); return createCallerFunction(name, parameters, calls);
} }
function validateCode( function validateNonEmptyCode(
data: CodeFunctionData, data: CodeFunctionData,
syntax: ILanguageSyntax, language: ScriptingLanguage,
validator: ICodeValidator, validate: CodeValidator,
): void { ): void {
filterEmptyStrings([data.code, data.revertCode]) filterEmptyStrings([data.code, data.revertCode])
.forEach( .forEach(
(code) => validator.throwIfInvalid( (code) => validate(
code, code,
[new NoEmptyLines(), new NoDuplicatedLines(syntax)], language,
[
CodeValidationRule.NoEmptyLines,
CodeValidationRule.NoDuplicatedLines,
],
), ),
); );
} }

View File

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

View File

@@ -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 { 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 { export interface ScriptCompiler {
readonly sharedFunctionsParser: SharedFunctionsParser; canCompile(script: ScriptData): boolean;
readonly callCompiler: FunctionCallCompiler; compile(script: ScriptData): ScriptCode;
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;
} }

View File

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

View File

@@ -1,9 +1,7 @@
import type { ScriptData, CodeScriptData, CallScriptData } from '@/application/collections/'; 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 { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode'; 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 { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import type { ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory'; import type { ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
import { createScriptCode } 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 { createEnumParser, type EnumParser } from '@/application/Common/Enum';
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings'; import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
import { createScript, type ScriptFactory } from '@/domain/Executables/Script/ScriptFactory'; 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 { parseDocs, type DocsParser } from '../DocumentationParser';
import { ExecutableType } from '../Validation/ExecutableType'; import { ExecutableType } from '../Validation/ExecutableType';
import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from '../Validation/ExecutableValidator'; import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from '../Validation/ExecutableValidator';
import { CodeValidator } from './Validation/CodeValidator'; import type { CategoryCollectionContext } from '../CategoryCollectionContext';
import { NoDuplicatedLines } from './Validation/Rules/NoDuplicatedLines';
import type { CategoryCollectionSpecificUtilities } from '../CategoryCollectionSpecificUtilities';
export interface ScriptParser { export interface ScriptParser {
( (
data: ScriptData, data: ScriptData,
collectionUtilities: CategoryCollectionSpecificUtilities, collectionContext: CategoryCollectionContext,
scriptUtilities?: ScriptParserUtilities, scriptUtilities?: ScriptParserUtilities,
): Script; ): Script;
} }
export const parseScript: ScriptParser = ( export const parseScript: ScriptParser = (
data, data,
collectionUtilities, collectionContext,
scriptUtilities = DefaultUtilities, scriptUtilities = DefaultUtilities,
) => { ) => {
const validator = scriptUtilities.createValidator({ const validator = scriptUtilities.createValidator({
@@ -42,7 +40,7 @@ export const parseScript: ScriptParser = (
name: data.name, name: data.name,
code: parseCode( code: parseCode(
data, data,
collectionUtilities, collectionContext,
scriptUtilities.codeValidator, scriptUtilities.codeValidator,
scriptUtilities.createCode, scriptUtilities.createCode,
), ),
@@ -70,29 +68,34 @@ function parseLevel(
function parseCode( function parseCode(
script: ScriptData, script: ScriptData,
collectionUtilities: CategoryCollectionSpecificUtilities, collectionContext: CategoryCollectionContext,
codeValidator: ICodeValidator, codeValidator: CodeValidator,
createCode: ScriptCodeFactory, createCode: ScriptCodeFactory,
): ScriptCode { ): ScriptCode {
if (collectionUtilities.compiler.canCompile(script)) { if (collectionContext.compiler.canCompile(script)) {
return collectionUtilities.compiler.compile(script); return collectionContext.compiler.compile(script);
} }
const codeScript = script as CodeScriptData; // Must be inline code if it cannot be compiled const codeScript = script as CodeScriptData; // Must be inline code if it cannot be compiled
const code = createCode(codeScript.code, codeScript.revertCode); const code = createCode(codeScript.code, codeScript.revertCode);
validateHardcodedCodeWithoutCalls(code, codeValidator, collectionUtilities.syntax); validateHardcodedCodeWithoutCalls(code, codeValidator, collectionContext.language);
return code; return code;
} }
function validateHardcodedCodeWithoutCalls( function validateHardcodedCodeWithoutCalls(
scriptCode: ScriptCode, scriptCode: ScriptCode,
validator: ICodeValidator, validate: CodeValidator,
syntax: ILanguageSyntax, language: ScriptingLanguage,
) { ) {
filterEmptyStrings([scriptCode.execute, scriptCode.revert]) filterEmptyStrings([scriptCode.execute, scriptCode.revert])
.forEach( .forEach(
(code) => validator.throwIfInvalid( (code) => validate(
code, code,
[new NoEmptyLines(), new NoDuplicatedLines(syntax)], language,
[
CodeValidationRule.NoEmptyLines,
CodeValidationRule.NoDuplicatedLines,
CodeValidationRule.NoTooLongLines,
],
), ),
); );
} }
@@ -126,7 +129,7 @@ function validateScript(
interface ScriptParserUtilities { interface ScriptParserUtilities {
readonly levelParser: EnumParser<RecommendationLevel>; readonly levelParser: EnumParser<RecommendationLevel>;
readonly createScript: ScriptFactory; readonly createScript: ScriptFactory;
readonly codeValidator: ICodeValidator; readonly codeValidator: CodeValidator;
readonly wrapError: ErrorWithContextWrapper; readonly wrapError: ErrorWithContextWrapper;
readonly createValidator: ExecutableValidatorFactory; readonly createValidator: ExecutableValidatorFactory;
readonly createCode: ScriptCodeFactory; readonly createCode: ScriptCodeFactory;
@@ -136,7 +139,7 @@ interface ScriptParserUtilities {
const DefaultUtilities: ScriptParserUtilities = { const DefaultUtilities: ScriptParserUtilities = {
levelParser: createEnumParser(RecommendationLevel), levelParser: createEnumParser(RecommendationLevel),
createScript, createScript,
codeValidator: CodeValidator.instance, codeValidator: validateCode,
wrapError: wrapErrorWithAdditionalContext, wrapError: wrapErrorWithAdditionalContext,
createValidator: createExecutableDataValidator, createValidator: createExecutableDataValidator,
createCode: createScriptCode, createCode: createScriptCode,

View File

@@ -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<CodeValidationAnalyzer>,
syntaxFactory?: SyntaxFactory,
]
): ReturnType<CodeValidationAnalyzer>;
};
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));
}

View File

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

View File

@@ -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}`);
}
}

View File

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

View File

@@ -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 BatchFileCommonCodeParts = ['(', ')', 'else', '||'];
const PowerShellCommonCodeParts = ['{', '}']; const PowerShellCommonCodeParts = ['{', '}'];
export class BatchFileSyntax implements ILanguageSyntax { export class BatchFileSyntax implements LanguageSyntax {
public readonly commentDelimiters = ['REM', '::']; public readonly commentDelimiters = ['REM', '::'];
public readonly commonCodeParts = [...BatchFileCommonCodeParts, ...PowerShellCommonCodeParts]; public readonly commonCodeParts = [...BatchFileCommonCodeParts, ...PowerShellCommonCodeParts];

View File

@@ -0,0 +1,4 @@
export interface LanguageSyntax {
readonly commentDelimiters: readonly string[];
readonly commonCodeParts: readonly string[];
}

View File

@@ -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'];
}

View File

@@ -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]}"`);
}
};

View File

@@ -0,0 +1,5 @@
export enum CodeValidationRule {
NoEmptyLines,
NoDuplicatedLines,
NoTooLongLines,
}

View File

@@ -1,46 +1,54 @@
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines'; import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import type { ICodeLine } from './ICodeLine'; import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import type { ICodeValidationRule, IInvalidCodeLine } from './ICodeValidationRule'; import { createValidationAnalyzers, type ValidationRuleAnalyzerFactory } from './ValidationRuleAnalyzerFactory';
import type { ICodeValidator } from './ICodeValidator'; import type { CodeLine, InvalidCodeLine } from './Analyzers/CodeValidationAnalyzer';
import type { CodeValidationRule } from './CodeValidationRule';
export class CodeValidator implements ICodeValidator { export interface CodeValidator {
public static readonly instance: ICodeValidator = new CodeValidator(); (
public throwIfInvalid(
code: string, code: string,
rules: readonly ICodeValidationRule[], language: ScriptingLanguage,
): void { rules: readonly CodeValidationRule[],
if (rules.length === 0) { throw new Error('missing rules'); } analyzerFactory?: ValidationRuleAnalyzerFactory,
if (!code) { ): void;
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[] { 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); const lines = splitTextIntoLines(code);
return lines.map((lineText, lineIndex): ICodeLine => ({ return lines.map((lineText, lineIndex): CodeLine => ({
index: lineIndex + 1, lineNumber: lineIndex + 1,
text: lineText, text: lineText,
})); }));
} }
function printLines( function formatLines(
lines: readonly ICodeLine[], lines: readonly CodeLine[],
invalidLines: readonly IInvalidCodeLine[], invalidLines: readonly InvalidCodeLine[],
): string { ): string {
return lines.map((line) => { return lines.map((line) => {
const badLine = invalidLines.find((invalidLine) => invalidLine.index === line.index); const badLine = invalidLines.find((invalidLine) => invalidLine.lineNumber === line.lineNumber);
if (!badLine) { 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'); }).join('\n');
} }

View File

@@ -1,4 +0,0 @@
export interface ICodeLine {
readonly index: number;
readonly text: string;
}

View File

@@ -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[];
}

View File

@@ -1,8 +0,0 @@
import type { ICodeValidationRule } from './ICodeValidationRule';
export interface ICodeValidator {
throwIfInvalid(
code: string,
rules: readonly ICodeValidationRule[],
): void;
}

View File

@@ -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();
}

View File

@@ -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}"`;
})(),
}));
}
}

View File

@@ -1,4 +0,0 @@
export interface ILanguageSyntax {
readonly commentDelimiters: string[];
readonly commonCodeParts: string[];
}

View File

@@ -1,4 +0,0 @@
import type { IScriptingLanguageFactory } from '@/application/Common/ScriptingLanguage/IScriptingLanguageFactory';
import type { ILanguageSyntax } from './ILanguageSyntax';
export type ISyntaxFactory = IScriptingLanguageFactory<ILanguageSyntax>;

View File

@@ -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'];
}

View File

@@ -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<ILanguageSyntax>
implements ISyntaxFactory {
constructor() {
super();
this.registerGetter(ScriptingLanguage.batchfile, () => new BatchFileSyntax());
this.registerGetter(ScriptingLanguage.shellscript, () => new ShellScriptSyntax());
}
}

View File

@@ -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<CodeValidationRule, number>();
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(', ')}`);
}
}

View File

@@ -10,7 +10,7 @@ export type CompositeCategoryCollectionValidator = CategoryCollectionValidator &
...Parameters<CategoryCollectionValidator>, ...Parameters<CategoryCollectionValidator>,
(readonly CategoryCollectionValidator[])?, (readonly CategoryCollectionValidator[])?,
] ]
): void; ): ReturnType<CategoryCollectionValidator>;
}; };
export const validateCategoryCollection: CompositeCategoryCollectionValidator = ( export const validateCategoryCollection: CompositeCategoryCollectionValidator = (

View File

@@ -6,7 +6,7 @@ import { OperatingSystem } from '@/domain/OperatingSystem';
import { EnumParserStub } from '@tests/unit/shared/Stubs/EnumParserStub'; import { EnumParserStub } from '@tests/unit/shared/Stubs/EnumParserStub';
import { ProjectDetailsStub } from '@tests/unit/shared/Stubs/ProjectDetailsStub'; import { ProjectDetailsStub } from '@tests/unit/shared/Stubs/ProjectDetailsStub';
import { getCategoryStub, CollectionDataStub } from '@tests/unit/shared/Stubs/CollectionDataStub'; 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 { createFunctionDataWithCode } from '@tests/unit/shared/Stubs/FunctionDataStub';
import type { CollectionData, ScriptingDefinitionData, FunctionData } from '@/application/collections/'; import type { CollectionData, ScriptingDefinitionData, FunctionData } from '@/application/collections/';
import type { ProjectDetails } from '@/domain/Project/ProjectDetails'; 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 { EnumParser } from '@/application/Common/Enum';
import type { ScriptingDefinitionParser } from '@/application/Parser/ScriptingDefinition/ScriptingDefinitionParser'; import type { ScriptingDefinitionParser } from '@/application/Parser/ScriptingDefinition/ScriptingDefinitionParser';
import { ScriptingDefinitionStub } from '@tests/unit/shared/Stubs/ScriptingDefinitionStub'; 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 { ScriptingDefinitionDataStub } from '@tests/unit/shared/Stubs/ScriptingDefinitionDataStub';
import { CategoryParserStub } from '@tests/unit/shared/Stubs/CategoryParserStub'; import { CategoryParserStub } from '@tests/unit/shared/Stubs/CategoryParserStub';
import { createCategoryCollectionFactorySpy } from '@tests/unit/shared/Stubs/CategoryCollectionFactoryStub'; import { createCategoryCollectionFactorySpy } from '@tests/unit/shared/Stubs/CategoryCollectionFactoryStub';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub'; import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import type { IScriptingDefinition } from '@/domain/IScriptingDefinition'; import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
describe('CategoryCollectionParser', () => { describe('CategoryCollectionParser', () => {
describe('parseCategoryCollection', () => { describe('parseCategoryCollection', () => {
@@ -86,12 +86,12 @@ describe('CategoryCollectionParser', () => {
expect(actualActions).to.have.lengthOf(expectedActions.length); expect(actualActions).to.have.lengthOf(expectedActions.length);
expect(actualActions).to.have.members(expectedActions); expect(actualActions).to.have.members(expectedActions);
}); });
describe('utilities', () => { describe('context', () => {
it('parses actions with correct utilities', () => { it('parses actions with correct context', () => {
// arrange // arrange
const expectedUtilities = new CategoryCollectionSpecificUtilitiesStub(); const expectedContext = new CategoryCollectionContextStub();
const utilitiesFactory: CategoryCollectionSpecificUtilitiesFactory = () => { const contextFactory: CategoryCollectionContextFactory = () => {
return expectedUtilities; return expectedContext;
}; };
const actionsData = [getCategoryStub('test1'), getCategoryStub('test2')]; const actionsData = [getCategoryStub('test1'), getCategoryStub('test2')];
const collectionData = new CollectionDataStub() const collectionData = new CollectionDataStub()
@@ -99,53 +99,54 @@ describe('CategoryCollectionParser', () => {
const categoryParserStub = new CategoryParserStub(); const categoryParserStub = new CategoryParserStub();
const context = new TestContext() const context = new TestContext()
.withData(collectionData) .withData(collectionData)
.withCollectionUtilitiesFactory(utilitiesFactory) .withCollectionContextFactory(contextFactory)
.withCategoryParser(categoryParserStub.get()); .withCategoryParser(categoryParserStub.get());
// act // act
context.parseCategoryCollection(); context.parseCategoryCollection();
// assert // assert
const usedUtilities = categoryParserStub.getUsedUtilities(); const actualContext = categoryParserStub.getUsedContext();
expect(usedUtilities).to.have.lengthOf(2); expect(actualContext).to.have.lengthOf(2);
expect(usedUtilities[0]).to.equal(expectedUtilities); expect(actualContext[0]).to.equal(expectedContext);
expect(usedUtilities[1]).to.equal(expectedUtilities); expect(actualContext[1]).to.equal(expectedContext);
}); });
describe('construction', () => { describe('construction', () => {
it('creates utilities with correct functions data', () => { it('creates with correct functions data', () => {
// arrange // arrange
const expectedFunctionsData = [createFunctionDataWithCode()]; const expectedFunctionsData = [createFunctionDataWithCode()];
const collectionData = new CollectionDataStub() const collectionData = new CollectionDataStub()
.withFunctions(expectedFunctionsData); .withFunctions(expectedFunctionsData);
let actualFunctionsData: ReadonlyArray<FunctionData> | undefined; let actualFunctionsData: ReadonlyArray<FunctionData> | undefined;
const utilitiesFactory: CategoryCollectionSpecificUtilitiesFactory = (data) => { const contextFactory: CategoryCollectionContextFactory = (data) => {
actualFunctionsData = data; actualFunctionsData = data;
return new CategoryCollectionSpecificUtilitiesStub(); return new CategoryCollectionContextStub();
}; };
const context = new TestContext() const context = new TestContext()
.withData(collectionData) .withData(collectionData)
.withCollectionUtilitiesFactory(utilitiesFactory); .withCollectionContextFactory(contextFactory);
// act // act
context.parseCategoryCollection(); context.parseCategoryCollection();
// assert // assert
expect(actualFunctionsData).to.equal(expectedFunctionsData); expect(actualFunctionsData).to.equal(expectedFunctionsData);
}); });
it('creates utilities with correct scripting definition', () => { it('creates with correct language', () => {
// arrange // arrange
const expectedScripting = new ScriptingDefinitionStub(); const expectedLanguage = ScriptingLanguage.batchfile;
const scriptingDefinitionParser: ScriptingDefinitionParser = () => { const scriptingDefinitionParser: ScriptingDefinitionParser = () => {
return expectedScripting; return new ScriptingDefinitionStub()
.withLanguage(expectedLanguage);
}; };
let actualScripting: IScriptingDefinition | undefined; let actualLanguage: ScriptingLanguage | undefined;
const utilitiesFactory: CategoryCollectionSpecificUtilitiesFactory = (_, scripting) => { const contextFactory: CategoryCollectionContextFactory = (_, language) => {
actualScripting = scripting; actualLanguage = language;
return new CategoryCollectionSpecificUtilitiesStub(); return new CategoryCollectionContextStub();
}; };
const context = new TestContext() const context = new TestContext()
.withCollectionUtilitiesFactory(utilitiesFactory) .withCollectionContextFactory(contextFactory)
.withScriptDefinitionParser(scriptingDefinitionParser); .withScriptDefinitionParser(scriptingDefinitionParser);
// act // act
context.parseCategoryCollection(); context.parseCategoryCollection();
// assert // assert
expect(actualScripting).to.equal(expectedScripting); expect(actualLanguage).to.equal(expectedLanguage);
}); });
}); });
}); });
@@ -245,9 +246,9 @@ class TestContext {
private osParser: EnumParser<OperatingSystem> = new EnumParserStub<OperatingSystem>() private osParser: EnumParser<OperatingSystem> = new EnumParserStub<OperatingSystem>()
.setupDefaultValue(OperatingSystem.Android); .setupDefaultValue(OperatingSystem.Android);
private collectionUtilitiesFactory private collectionContextFactory
: CategoryCollectionSpecificUtilitiesFactory = () => { : CategoryCollectionContextFactory = () => {
return new CategoryCollectionSpecificUtilitiesStub(); return new CategoryCollectionContextStub();
}; };
private scriptDefinitionParser: ScriptingDefinitionParser = () => new ScriptingDefinitionStub(); private scriptDefinitionParser: ScriptingDefinitionParser = () => new ScriptingDefinitionStub();
@@ -292,10 +293,10 @@ class TestContext {
return this; return this;
} }
public withCollectionUtilitiesFactory( public withCollectionContextFactory(
collectionUtilitiesFactory: CategoryCollectionSpecificUtilitiesFactory, collectionContextFactory: CategoryCollectionContextFactory,
): this { ): this {
this.collectionUtilitiesFactory = collectionUtilitiesFactory; this.collectionContextFactory = collectionContextFactory;
return this; return this;
} }
@@ -307,7 +308,7 @@ class TestContext {
osParser: this.osParser, osParser: this.osParser,
validator: this.validator, validator: this.validator,
parseScriptingDefinition: this.scriptDefinitionParser, parseScriptingDefinition: this.scriptDefinitionParser,
createUtilities: this.collectionUtilitiesFactory, createContext: this.collectionContextFactory,
parseCategory: this.categoryParser, parseCategory: this.categoryParser,
createCategoryCollection: this.categoryCollectionFactory, createCategoryCollection: this.categoryCollectionFactory,
}, },

View File

@@ -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<FunctionData>((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<typeof createCategoryCollectionContext> {
return createCategoryCollectionContext(
this.functionsData,
this.language,
this.scriptCompilerFactory,
);
}
}

View File

@@ -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<FunctionData>((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<typeof createCollectionUtilities> {
return createCollectionUtilities(
this.functionsData,
this.scripting,
this.syntaxFactory,
);
}
}

View File

@@ -3,7 +3,7 @@ import type { CategoryData, ExecutableData } from '@/application/collections/';
import { parseCategory } from '@/application/Parser/Executable/CategoryParser'; import { parseCategory } from '@/application/Parser/Executable/CategoryParser';
import { type ScriptParser } from '@/application/Parser/Executable/Script/ScriptParser'; import { type ScriptParser } from '@/application/Parser/Executable/Script/ScriptParser';
import { type DocsParser } from '@/application/Parser/Executable/DocumentationParser'; 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 { CategoryDataStub } from '@tests/unit/shared/Stubs/CategoryDataStub';
import { ExecutableType } from '@/application/Parser/Executable/Validation/ExecutableType'; import { ExecutableType } from '@/application/Parser/Executable/Validation/ExecutableType';
import { createScriptDataWithCall, createScriptDataWithCode } from '@tests/unit/shared/Stubs/ScriptDataStub'; import { createScriptDataWithCall, createScriptDataWithCode } from '@tests/unit/shared/Stubs/ScriptDataStub';
@@ -357,9 +357,9 @@ describe('CategoryParser', () => {
expect(actualParsedScripts.length).to.equal(expectedScripts.length); expect(actualParsedScripts.length).to.equal(expectedScripts.length);
expect(actualParsedScripts).to.have.members(expectedScripts); expect(actualParsedScripts).to.have.members(expectedScripts);
}); });
it('parses all scripts with correct utilities', () => { it('parses all scripts with correct context', () => {
// arrange // arrange
const expected = new CategoryCollectionSpecificUtilitiesStub(); const expectedContext = new CategoryCollectionContextStub();
const scriptParser = new ScriptParserStub(); const scriptParser = new ScriptParserStub();
const childrenData = [ const childrenData = [
createScriptDataWithCode(), createScriptDataWithCode(),
@@ -372,24 +372,24 @@ describe('CategoryParser', () => {
// act // act
const actualCategory = new TestContext() const actualCategory = new TestContext()
.withData(categoryData) .withData(categoryData)
.withCollectionUtilities(expected) .withCollectionContext(expectedContext)
.withScriptParser(scriptParser.get()) .withScriptParser(scriptParser.get())
.withCategoryFactory(categoryFactorySpy) .withCategoryFactory(categoryFactorySpy)
.parseCategory(); .parseCategory();
// assert // assert
const actualParsedScripts = getInitParameters(actualCategory)?.scripts; const actualParsedScripts = getInitParameters(actualCategory)?.scripts;
expectExists(actualParsedScripts); expectExists(actualParsedScripts);
const actualUtilities = actualParsedScripts.map( const actualContext = actualParsedScripts.map(
(s) => scriptParser.getParseParameters(s)[1], (s) => scriptParser.getParseParameters(s)[1],
); );
expect( expect(
actualUtilities.every( actualContext.every(
(actual) => actual === expected, (actual) => actual === expectedContext,
), ),
formatAssertionMessage([ formatAssertionMessage([
`Expected all elements to be ${JSON.stringify(expected)}`, `Expected all elements to be ${JSON.stringify(expectedContext)}`,
'All elements:', 'All elements:',
indentText(JSON.stringify(actualUtilities)), indentText(JSON.stringify(actualContext)),
]), ]),
).to.equal(true); ).to.equal(true);
}); });
@@ -464,8 +464,7 @@ describe('CategoryParser', () => {
class TestContext { class TestContext {
private data: CategoryData = new CategoryDataStub(); private data: CategoryData = new CategoryDataStub();
private collectionUtilities: private collectionContext: CategoryCollectionContextStub = new CategoryCollectionContextStub();
CategoryCollectionSpecificUtilitiesStub = new CategoryCollectionSpecificUtilitiesStub();
private categoryFactory: CategoryFactory = createCategoryFactorySpy().categoryFactorySpy; private categoryFactory: CategoryFactory = createCategoryFactorySpy().categoryFactorySpy;
@@ -482,10 +481,10 @@ class TestContext {
return this; return this;
} }
public withCollectionUtilities( public withCollectionContext(
collectionUtilities: CategoryCollectionSpecificUtilitiesStub, collectionContext: CategoryCollectionContextStub,
): this { ): this {
this.collectionUtilities = collectionUtilities; this.collectionContext = collectionContext;
return this; return this;
} }
@@ -517,7 +516,7 @@ class TestContext {
public parseCategory() { public parseCategory() {
return parseCategory( return parseCategory(
this.data, this.data,
this.collectionUtilities, this.collectionContext,
{ {
createCategory: this.categoryFactory, createCategory: this.categoryFactory,
wrapError: this.errorWrapper, wrapError: this.errorWrapper,

View File

@@ -9,12 +9,8 @@ import { createFunctionDataWithCall, createFunctionDataWithCode, createFunctionD
import { ParameterDefinitionDataStub } from '@tests/unit/shared/Stubs/ParameterDefinitionDataStub'; import { ParameterDefinitionDataStub } from '@tests/unit/shared/Stubs/ParameterDefinitionDataStub';
import { FunctionCallDataStub } from '@tests/unit/shared/Stubs/FunctionCallDataStub'; import { FunctionCallDataStub } from '@tests/unit/shared/Stubs/FunctionCallDataStub';
import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests'; 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 { 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 { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines';
import { NoDuplicatedLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines';
import type { ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError'; import type { ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub'; import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub';
import { itThrowsContextualError } from '@tests/unit/application/Parser/Common/ContextualErrorTester'; 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 { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall';
import type { FunctionParameterParser } from '@/application/Parser/Executable/Script/Compiler/Function/Parameter/FunctionParameterParser'; import type { FunctionParameterParser } from '@/application/Parser/Executable/Script/Compiler/Function/Parameter/FunctionParameterParser';
import { createFunctionParameterParserStub } from '@tests/unit/shared/Stubs/FunctionParameterParserStub'; 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'; import { expectCallsFunctionBody, expectCodeFunctionBody } from './ExpectFunctionBodyType';
describe('SharedFunctionsParser', () => { describe('SharedFunctionsParser', () => {
@@ -161,22 +159,53 @@ describe('SharedFunctionsParser', () => {
}); });
} }
}); });
it('validates function code as expected when code is defined', () => { describe('code validation', () => {
// arrange it('validates function code', () => {
const expectedRules = [NoEmptyLines, NoDuplicatedLines]; // arrange
const functionData = createFunctionDataWithCode() const expectedCode = 'expected code to be validated';
.withCode('expected code to be validated') const expectedRevertCode = 'expected revert code to be validated';
.withRevertCode('expected revert code to be validated'); const functionData = createFunctionDataWithCode()
const validator = new CodeValidatorStub(); .withCode(expectedCode)
// act .withRevertCode(expectedRevertCode);
new TestContext() const expectedCodes: readonly string[] = [expectedCode, expectedRevertCode];
.withFunctions([functionData]) const validator = new CodeValidatorStub();
.withValidator(validator) // act
.parseFunctions(); new TestContext()
// assert .withFunctions([functionData])
validator.assertHistory({ .withValidator(validator.get())
validatedCodes: [functionData.code, functionData.revertCode], .parseFunctions();
rules: expectedRules, // 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', () => { describe('parameter creation', () => {
@@ -406,9 +435,10 @@ describe('SharedFunctionsParser', () => {
}); });
class TestContext { 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()]; private functions: readonly FunctionData[] = [createFunctionDataWithCode()];
@@ -421,12 +451,12 @@ class TestContext {
private parameterCollectionFactory private parameterCollectionFactory
: FunctionParameterCollectionFactory = () => new FunctionParameterCollectionStub(); : FunctionParameterCollectionFactory = () => new FunctionParameterCollectionStub();
public withSyntax(syntax: ILanguageSyntax): this { public withLanguage(language: ScriptingLanguage): this {
this.syntax = syntax; this.language = language;
return this; return this;
} }
public withValidator(codeValidator: ICodeValidator): this { public withValidator(codeValidator: CodeValidator): this {
this.codeValidator = codeValidator; this.codeValidator = codeValidator;
return this; return this;
} }
@@ -461,7 +491,7 @@ class TestContext {
public parseFunctions(): ReturnType<typeof parseSharedFunctions> { public parseFunctions(): ReturnType<typeof parseSharedFunctions> {
return parseSharedFunctions( return parseSharedFunctions(
this.functions, this.functions,
this.syntax, this.language,
{ {
codeValidator: this.codeValidator, codeValidator: this.codeValidator,
wrapError: this.wrapError, wrapError: this.wrapError,

View File

@@ -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,
},
);
}
}

View File

@@ -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,
},
});
}
}

View File

@@ -8,12 +8,9 @@ import { ScriptCompilerStub } from '@tests/unit/shared/Stubs/ScriptCompilerStub'
import { createScriptDataWithCall, createScriptDataWithCode, createScriptDataWithoutCallOrCodes } from '@tests/unit/shared/Stubs/ScriptDataStub'; import { createScriptDataWithCall, createScriptDataWithCode, createScriptDataWithoutCallOrCodes } from '@tests/unit/shared/Stubs/ScriptDataStub';
import { EnumParserStub } from '@tests/unit/shared/Stubs/EnumParserStub'; import { EnumParserStub } from '@tests/unit/shared/Stubs/EnumParserStub';
import { ScriptCodeStub } from '@tests/unit/shared/Stubs/ScriptCodeStub'; import { ScriptCodeStub } from '@tests/unit/shared/Stubs/ScriptCodeStub';
import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub';
import type { EnumParser } from '@/application/Common/Enum'; 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 { 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 type { ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { ErrorWrapperStub } from '@tests/unit/shared/Stubs/ErrorWrapperStub'; import { ErrorWrapperStub } from '@tests/unit/shared/Stubs/ErrorWrapperStub';
import type { ExecutableValidatorFactory } from '@/application/Parser/Executable/Validation/ExecutableValidator'; 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 { createScriptFactorySpy } from '@tests/unit/shared/Stubs/ScriptFactoryStub';
import { expectExists } from '@tests/shared/Assertions/ExpectExists'; import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { itThrowsContextualError } from '@tests/unit/application/Parser/Common/ContextualErrorTester'; import { itThrowsContextualError } from '@tests/unit/application/Parser/Common/ContextualErrorTester';
import { CategoryCollectionSpecificUtilitiesStub } from '@tests/unit/shared/Stubs/CategoryCollectionSpecificUtilitiesStub'; import { CategoryCollectionContextStub } from '@tests/unit/shared/Stubs/CategoryCollectionContextStub';
import type { CategoryCollectionSpecificUtilities } from '@/application/Parser/Executable/CategoryCollectionSpecificUtilities'; import type { CategoryCollectionContext } from '@/application/Parser/Executable/CategoryCollectionContext';
import type { ObjectAssertion } from '@/application/Parser/Common/TypeValidator'; import type { ObjectAssertion } from '@/application/Parser/Common/TypeValidator';
import type { ExecutableId } from '@/domain/Executables/Identifiable'; import type { ExecutableId } from '@/domain/Executables/Identifiable';
import type { ScriptFactory } from '@/domain/Executables/Script/ScriptFactory'; 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 { itAsserts, itValidatesType, itValidatesName } from '../Validation/ExecutableValidationTester';
import { generateDataValidationTestScenarios } from '../Validation/DataValidationTestScenarioGenerator'; import { generateDataValidationTestScenarios } from '../Validation/DataValidationTestScenarioGenerator';
@@ -330,13 +329,13 @@ describe('ScriptParser', () => {
const script = createScriptDataWithCode(); const script = createScriptDataWithCode();
const compiler = new ScriptCompilerStub() const compiler = new ScriptCompilerStub()
.withCompileAbility(script, expectedCode); .withCompileAbility(script, expectedCode);
const collectionUtilities = new CategoryCollectionSpecificUtilitiesStub() const collectionContext = new CategoryCollectionContextStub()
.withCompiler(compiler); .withCompiler(compiler);
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy(); const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
// act // act
const actualScript = new TestContext() const actualScript = new TestContext()
.withData(script) .withData(script)
.withCollectionUtilities(collectionUtilities) .withCollectionContext(collectionContext)
.withScriptFactory(scriptFactorySpy) .withScriptFactory(scriptFactorySpy)
.parseScript(); .parseScript();
// assert // assert
@@ -344,33 +343,12 @@ describe('ScriptParser', () => {
expect(actualCode).to.equal(expectedCode); 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', () => { describe('validates a expected', () => {
it('validates script with inline code (that is not compiled)', () => { it('validates script with inline code (that is not compiled)', () => {
// arrange // arrange
const expectedRules = [
NoEmptyLines,
NoDuplicatedLines,
];
const expectedCode = 'expected code to be validated'; const expectedCode = 'expected code to be validated';
const expectedRevertCode = 'expected revert code to be validated'; const expectedRevertCode = 'expected revert code to be validated';
const expectedCodeCalls = [ const expectedCodeCalls: readonly string[] = [
expectedCode, expectedCode,
expectedRevertCode, expectedRevertCode,
]; ];
@@ -383,35 +361,55 @@ describe('ScriptParser', () => {
// act // act
new TestContext() new TestContext()
.withScriptCodeFactory(scriptCodeFactory) .withScriptCodeFactory(scriptCodeFactory)
.withCodeValidator(validator) .withCodeValidator(validator.get())
.parseScript(); .parseScript();
// assert // assert
validator.assertHistory({ validator.assertValidatedCodes(expectedCodeCalls);
validatedCodes: expectedCodeCalls,
rules: expectedRules,
});
}); });
it('does not validate compiled code', () => { it('does not validate compiled code', () => {
// arrange // arrange
const expectedRules = [];
const expectedCodeCalls = [];
const validator = new CodeValidatorStub(); const validator = new CodeValidatorStub();
const script = createScriptDataWithCall(); const script = createScriptDataWithCall();
const compiler = new ScriptCompilerStub() const compiler = new ScriptCompilerStub()
.withCompileAbility(script, new ScriptCodeStub()); .withCompileAbility(script, new ScriptCodeStub());
const collectionUtilities = new CategoryCollectionSpecificUtilitiesStub() const collectionContext = new CategoryCollectionContextStub()
.withCompiler(compiler); .withCompiler(compiler);
// act // act
new TestContext() new TestContext()
.withData(script) .withData(script)
.withCodeValidator(validator) .withCodeValidator(validator.get())
.withCollectionUtilities(collectionUtilities) .withCollectionContext(collectionContext)
.parseScript(); .parseScript();
// assert // assert
validator.assertHistory({ const calls = validator.callHistory;
validatedCodes: expectedCodeCalls, expect(calls).to.have.lengthOf(0);
rules: expectedRules, });
}); 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 { class TestContext {
private data: ScriptData = createScriptDataWithCode(); private data: ScriptData = createScriptDataWithCode();
private collectionUtilities private collectionContext
: CategoryCollectionSpecificUtilities = new CategoryCollectionSpecificUtilitiesStub(); : CategoryCollectionContext = new CategoryCollectionContextStub();
private levelParser: EnumParser<RecommendationLevel> = new EnumParserStub<RecommendationLevel>() private levelParser: EnumParser<RecommendationLevel> = new EnumParserStub<RecommendationLevel>()
.setupDefaultValue(RecommendationLevel.Standard); .setupDefaultValue(RecommendationLevel.Standard);
private scriptFactory: ScriptFactory = createScriptFactorySpy().scriptFactorySpy; private scriptFactory: ScriptFactory = createScriptFactorySpy().scriptFactorySpy;
private codeValidator: ICodeValidator = new CodeValidatorStub(); private codeValidator: CodeValidator = new CodeValidatorStub().get();
private errorWrapper: ErrorWithContextWrapper = new ErrorWrapperStub().get(); private errorWrapper: ErrorWithContextWrapper = new ErrorWrapperStub().get();
@@ -481,7 +479,7 @@ class TestContext {
defaultCodePrefix: TestContext.name, defaultCodePrefix: TestContext.name,
}); });
public withCodeValidator(codeValidator: ICodeValidator): this { public withCodeValidator(codeValidator: CodeValidator): this {
this.codeValidator = codeValidator; this.codeValidator = codeValidator;
return this; return this;
} }
@@ -491,10 +489,10 @@ class TestContext {
return this; return this;
} }
public withCollectionUtilities( public withCollectionContext(
collectionUtilities: CategoryCollectionSpecificUtilities, collectionContext: CategoryCollectionContext,
): this { ): this {
this.collectionUtilities = collectionUtilities; this.collectionContext = collectionContext;
return this; return this;
} }
@@ -531,7 +529,7 @@ class TestContext {
public parseScript(): ReturnType<typeof parseScript> { public parseScript(): ReturnType<typeof parseScript> {
return parseScript( return parseScript(
this.data, this.data,
this.collectionUtilities, this.collectionContext,
{ {
levelParser: this.levelParser, levelParser: this.levelParser,
createScript: this.scriptFactory, createScript: this.scriptFactory,

View File

@@ -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<number>[]
): 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,
);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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,
}
));
}

View File

@@ -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);
}

View File

@@ -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(),
);
});

View File

@@ -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);
});
});
}

View File

@@ -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(),
);
});

View File

@@ -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, Constructible<LanguageSyntax>> = {
[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);
});
});
});
});
});

View File

@@ -1,182 +1,226 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator'; import { CodeValidationAnalyzerStub } from '@tests/unit/shared/Stubs/CodeValidationAnalyzerStub';
import { CodeValidationRuleStub } from '@tests/unit/shared/Stubs/CodeValidationRuleStub'; import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
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 { indentText } from '@/application/Common/Text/IndentText'; 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('validateCode', () => {
describe('instance', () => { describe('does not throw if code is absent', () => {
itIsSingletonFactory({ itEachAbsentStringValue((absentValue) => {
getter: () => CodeValidator.instance, // arrange
expectedType: CodeValidator, const code = absentValue;
// act
const act = () => new TestContext()
.withCode(code)
.validate();
// 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);
});
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('throwIfInvalid', () => { it('analyzes lines for correct language', () => {
describe('does not throw if code is absent', () => { // arrange
itEachAbsentStringValue((absentValue) => { const expectedLanguage = ScriptingLanguage.batchfile;
// arrange const analyzers = [
const code = absentValue; new CodeValidationAnalyzerStub(),
const sut = new CodeValidator(); new CodeValidationAnalyzerStub(),
// act new CodeValidationAnalyzerStub(),
const act = () => sut.throwIfInvalid(code, [new CodeValidationRuleStub()]); ];
// assert const analyzerFactory: ValidationRuleAnalyzerFactory = () => analyzers.map((s) => s.get());
expect(act).to.not.throw(); // act
}, { excludeNull: true, excludeUndefined: true }); 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 if rules are empty', () => { it('throws error with combined invalid lines from multiple rules', () => {
itEachAbsentCollectionValue<ICodeValidationRule>((absentValue) => { // arrange
// arrange const firstError = 'firstError';
const expectedError = 'missing rules'; const secondError = 'firstError';
const rules = absentValue; const expectedError = constructExpectedValidationErrorMessage([
const sut = new CodeValidator(); { text: 'line1' },
// act { text: 'line2', error: firstError },
const act = () => sut.throwIfInvalid('code', rules); { text: 'line3' },
// assert { text: 'line4', error: secondError },
expect(act).to.throw(expectedError); ]);
}, { excludeUndefined: true, excludeNull: true }); const code = ['line1', 'line2', 'line3', 'line4'].join('\n');
}); const firstRuleError: readonly InvalidCodeLine[] = [
describe('splits lines as expected', () => { { lineNumber: 2, error: firstError },
it('supports all line separators', () => { ];
// arrange const secondRuleError: readonly InvalidCodeLine[] = [
const expectedLineTexts = ['line1', 'line2', 'line3', 'line4']; { lineNumber: 4, error: secondError },
const code = 'line1\r\nline2\rline3\nline4'; ];
const spy = new CodeValidationRuleStub(); const firstRule = new CodeValidationAnalyzerStub().withReturnValue(firstRuleError);
const sut = new CodeValidator(); const secondRule = new CodeValidationAnalyzerStub().withReturnValue(secondRuleError);
// act const analyzerFactory: ValidationRuleAnalyzerFactory = () => [
sut.throwIfInvalid(code, [spy]); firstRule, secondRule,
// expect ].map((s) => s.get());
expect(spy.receivedLines).has.lengthOf(1); // act
const actualLineTexts = spy.receivedLines[0].map((line) => line.text); const act = () => new TestContext()
expect(actualLineTexts).to.deep.equal(expectedLineTexts); .withCode(code)
}); .withAnalyzerFactory(analyzerFactory)
it('uses 1-indexed line numbering', () => { .validate();
// arrange // assert
const expectedIndexes = [1, 2, 3]; expect(act).to.throw(expectedError);
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);
});
});
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 { function constructExpectedValidationErrorMessage(
private lineCount = 0; 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<string>(); class TestContext {
private code = `[${TestContext.name}] code`;
public withOkLine(text: string) { private language: ScriptingLanguage = ScriptingLanguage.batchfile;
return this.withNumberedLine(`${text}`);
}
public withErrorLine(text: string, error: string) { private rules: readonly CodeValidationRule[] = [CodeValidationRule.NoDuplicatedLines];
return this
.withNumberedLine(`${text}`)
.withLine(indentText(`${error}`));
}
public buildError(): string { private analyzerFactory: ValidationRuleAnalyzerFactory = () => [
return [ new CodeValidationAnalyzerStub().get(),
'Errors with the code.', ];
...this.outputLines,
].join('\n');
}
private withLine(line: string) { public withCode(code: string): this {
this.outputLines.push(line); this.code = code;
return this; return this;
} }
private withNumberedLine(text: string) { public withRules(rules: readonly CodeValidationRule[]): this {
this.lineCount += 1; this.rules = rules;
const lineNumber = `[${this.lineCount}]`; return this;
return this.withLine(`${lineNumber} ${text}`); }
public withAnalyzerFactory(analyzerFactory: ValidationRuleAnalyzerFactory): this {
this.analyzerFactory = analyzerFactory;
return this;
}
public validate(): ReturnType<typeof validateCode> {
return validateCode(
this.code,
this.language,
this.rules,
this.analyzerFactory,
);
} }
} }

View File

@@ -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,
}
));
}

View File

@@ -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<number>[]
): IInvalidCodeLine[] {
return lines.flatMap((occurrenceIndices): readonly IInvalidCodeLine[] => occurrenceIndices
.map((index): IInvalidCodeLine => ({
index,
error: `Line is duplicated at line numbers ${occurrenceIndices.join(',')}.`,
})));
}

View File

@@ -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(),
},
]);
});
});

View File

@@ -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);
});
}
});
});

View File

@@ -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);
});

View File

@@ -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, CodeValidationAnalyzer> = {
[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, CodeValidationAnalyzer>([
[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<typeof createValidationAnalyzers> {
return createValidationAnalyzers(this.rules);
}
}

View File

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

View File

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

View File

@@ -1,13 +1,13 @@
import type { CategoryParser } from '@/application/Parser/Executable/CategoryParser'; import type { CategoryParser } from '@/application/Parser/Executable/CategoryParser';
import type { CategoryData } from '@/application/collections/'; import type { CategoryData } from '@/application/collections/';
import type { Category } from '@/domain/Executables/Category/Category'; 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'; import { CategoryStub } from './CategoryStub';
export class CategoryParserStub { export class CategoryParserStub {
private configuredParseResults = new Map<CategoryData, Category>(); private configuredParseResults = new Map<CategoryData, Category>();
private usedUtilities = new Array<CategoryCollectionSpecificUtilities>(); private usedUtilities = new Array<CategoryCollectionContext>();
public get(): CategoryParser { public get(): CategoryParser {
return (category, utilities) => { return (category, utilities) => {
@@ -28,7 +28,7 @@ export class CategoryParserStub {
return this; return this;
} }
public getUsedUtilities(): readonly CategoryCollectionSpecificUtilities[] { public getUsedContext(): readonly CategoryCollectionContext[] {
return this.usedUtilities; return this.usedUtilities;
} }
} }

View File

@@ -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<readonly CodeLine[]>();
public readonly receivedLanguages = new Array<ScriptingLanguage>();
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;
};
}
}

View File

@@ -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<readonly ICodeLine[]>();
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;
}
}

View File

@@ -1,34 +1,86 @@
import { expect } from 'vitest'; import { expect } from 'vitest';
import type { Constructible } from '@/TypeHelpers'; import type { CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator';
import type { ICodeValidationRule } from '@/application/Parser/Executable/Script/Validation/ICodeValidationRule'; import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator'; import type { CodeValidationRule } from '@/application/Parser/Executable/Script/Validation/CodeValidationRule';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
export class CodeValidatorStub implements ICodeValidator { export class CodeValidatorStub {
public callHistory = new Array<{ public callHistory = new Array<Parameters<CodeValidator>>();
code: string,
rules: readonly ICodeValidationRule[],
}>();
public throwIfInvalid( public get(): CodeValidator {
code: string, return (...args) => {
rules: readonly ICodeValidationRule[], this.callHistory.push(args);
): void { };
this.callHistory.push({
code,
rules: Array.from(rules),
});
} }
public assertHistory(expectation: { public assertValidatedCodes(
validatedCodes: readonly (string | undefined)[], validatedCodes: readonly string[],
rules: readonly Constructible<ICodeValidationRule>[], ) {
}) { expectExpectedCodes(this, validatedCodes);
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()); public assertValidatedRules(
for (const call of this.callHistory) { rules: readonly CodeValidationRule[],
const actualRules = call.rules.map((rule) => rule.constructor); ) {
expect(actualRules.sort()).to.deep.equal([...expectation.rules].sort()); 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)}`,
]));
}
}
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]}`,
]));
} }
} }

View File

@@ -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 commentDelimiters: string[] = [];
public commonCodeParts: string[] = []; public commonCodeParts: string[] = [];

View File

@@ -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<ScriptCompiler, ScriptCompilerInitParameters>();
return {
instance: (parameters) => {
const compiler = new ScriptCompilerStub();
createdCompilers.set(compiler, parameters);
return compiler;
},
getInitParameters: (category) => createdCompilers.get(category),
};
}

View File

@@ -1,9 +1,9 @@
import type { ScriptData } from '@/application/collections/'; 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 { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode';
import type { ScriptCompiler } from '@/application/Parser/Executable/Script/Compiler/ScriptCompiler';
import { ScriptCodeStub } from './ScriptCodeStub'; import { ScriptCodeStub } from './ScriptCodeStub';
export class ScriptCompilerStub implements IScriptCompiler { export class ScriptCompilerStub implements ScriptCompiler {
public compilableScripts = new Map<ScriptData, ScriptCode>(); public compilableScripts = new Map<ScriptData, ScriptCode>();
public canCompile(script: ScriptData): boolean { public canCompile(script: ScriptData): boolean {

View File

@@ -2,13 +2,13 @@ import type { FunctionData } from '@/application/collections/';
import { sequenceEqual } from '@/application/Common/Array'; import { sequenceEqual } from '@/application/Common/Array';
import type { ISharedFunctionCollection } from '@/application/Parser/Executable/Script/Compiler/Function/ISharedFunctionCollection'; import type { ISharedFunctionCollection } from '@/application/Parser/Executable/Script/Compiler/Function/ISharedFunctionCollection';
import type { SharedFunctionsParser } from '@/application/Parser/Executable/Script/Compiler/Function/SharedFunctionsParser'; 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'; import { SharedFunctionCollectionStub } from './SharedFunctionCollectionStub';
export function createSharedFunctionsParserStub() { export function createSharedFunctionsParserStub() {
const callHistory = new Array<{ const callHistory = new Array<{
readonly functions: readonly FunctionData[], readonly functions: readonly FunctionData[],
readonly syntax: ILanguageSyntax, readonly language: ScriptingLanguage,
}>(); }>();
const setupResults = new Array<{ const setupResults = new Array<{
@@ -26,11 +26,11 @@ export function createSharedFunctionsParserStub() {
const parser: SharedFunctionsParser = ( const parser: SharedFunctionsParser = (
functions: readonly FunctionData[], functions: readonly FunctionData[],
syntax: ILanguageSyntax, language: ScriptingLanguage,
) => { ) => {
callHistory.push({ callHistory.push({
functions: Array.from(functions), functions: Array.from(functions),
syntax, language,
}); });
const result = findResult(functions); const result = findResult(functions);
return result || new SharedFunctionCollectionStub(); return result || new SharedFunctionCollectionStub();

View File

@@ -1,18 +1,31 @@
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax'; import type { LanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax';
import type { ISyntaxFactory } from '@/application/Parser/Executable/Script/Validation/Syntax/ISyntaxFactory'; import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage'; import type { SyntaxFactory } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/SyntaxFactory';
import { LanguageSyntaxStub } from './LanguageSyntaxStub'; import { LanguageSyntaxStub } from './LanguageSyntaxStub';
export function createSyntaxFactoryStub( interface PredeterminedSyntax {
expectedLanguage?: ScriptingLanguage, readonly givenLanguage: ScriptingLanguage;
result?: ILanguageSyntax, readonly predeterminedSyntax: LanguageSyntax;
): ISyntaxFactory { }
return {
create: (language: ScriptingLanguage) => { export class SyntaxFactoryStub {
if (expectedLanguage !== undefined && language !== expectedLanguage) { private readonly predeterminedResults = new Array<PredeterminedSyntax>();
throw new Error('unexpected language');
} public withPredeterminedSyntax(scenario: PredeterminedSyntax): this {
return result ?? new LanguageSyntaxStub(); 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 results[0].predeterminedSyntax;
};
}
} }