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

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

View File

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

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 { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
import { CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator';
import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines';
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { createScriptCode, type ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
import { FunctionCallSequenceCompiler } from './Function/Call/Compiler/FunctionCallSequenceCompiler';
import { parseFunctionCalls } from './Function/Call/FunctionCallsParser';
import { parseSharedFunctions, type SharedFunctionsParser } from './Function/SharedFunctionsParser';
import type { CompiledCode } from './Function/Call/Compiler/CompiledCode';
import type { IScriptCompiler } from './IScriptCompiler';
import type { ISharedFunctionCollection } from './Function/ISharedFunctionCollection';
import type { FunctionCallCompiler } from './Function/Call/Compiler/FunctionCallCompiler';
interface ScriptCompilerUtilities {
readonly sharedFunctionsParser: SharedFunctionsParser;
readonly callCompiler: FunctionCallCompiler;
readonly codeValidator: ICodeValidator;
readonly wrapError: ErrorWithContextWrapper;
readonly scriptCodeFactory: ScriptCodeFactory;
}
const DefaultUtilities: ScriptCompilerUtilities = {
sharedFunctionsParser: parseSharedFunctions,
callCompiler: FunctionCallSequenceCompiler.instance,
codeValidator: CodeValidator.instance,
wrapError: wrapErrorWithAdditionalContext,
scriptCodeFactory: createScriptCode,
};
interface CategoryCollectionDataContext {
readonly functions: readonly FunctionData[];
readonly syntax: ILanguageSyntax;
}
export class ScriptCompiler implements IScriptCompiler {
private readonly functions: ISharedFunctionCollection;
constructor(
categoryContext: CategoryCollectionDataContext,
private readonly utilities: ScriptCompilerUtilities = DefaultUtilities,
) {
this.functions = this.utilities.sharedFunctionsParser(
categoryContext.functions,
categoryContext.syntax,
);
}
public canCompile(script: ScriptData): boolean {
return hasCall(script);
}
public compile(script: ScriptData): ScriptCode {
try {
if (!hasCall(script)) {
throw new Error('Script does include any calls.');
}
const calls = parseFunctionCalls(script.call);
const compiledCode = this.utilities.callCompiler.compileFunctionCalls(calls, this.functions);
validateCompiledCode(compiledCode, this.utilities.codeValidator);
return this.utilities.scriptCodeFactory(
compiledCode.code,
compiledCode.revertCode,
);
} catch (error) {
throw this.utilities.wrapError(error, `Failed to compile script: ${script.name}`);
}
}
}
function validateCompiledCode(compiledCode: CompiledCode, validator: ICodeValidator): void {
filterEmptyStrings([compiledCode.code, compiledCode.revertCode])
.forEach(
(code) => validator.throwIfInvalid(
code,
[new NoEmptyLines()],
),
);
}
function hasCall(data: ScriptData): data is ScriptData & CallInstruction {
return (data as CallInstruction).call !== undefined;
export interface ScriptCompiler {
canCompile(script: ScriptData): boolean;
compile(script: ScriptData): ScriptCode;
}

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

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 PowerShellCommonCodeParts = ['{', '}'];
export class BatchFileSyntax implements ILanguageSyntax {
export class BatchFileSyntax implements LanguageSyntax {
public readonly commentDelimiters = ['REM', '::'];
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 type { ICodeLine } from './ICodeLine';
import type { ICodeValidationRule, IInvalidCodeLine } from './ICodeValidationRule';
import type { ICodeValidator } from './ICodeValidator';
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { createValidationAnalyzers, type ValidationRuleAnalyzerFactory } from './ValidationRuleAnalyzerFactory';
import type { CodeLine, InvalidCodeLine } from './Analyzers/CodeValidationAnalyzer';
import type { CodeValidationRule } from './CodeValidationRule';
export class CodeValidator implements ICodeValidator {
public static readonly instance: ICodeValidator = new CodeValidator();
public throwIfInvalid(
export interface CodeValidator {
(
code: string,
rules: readonly ICodeValidationRule[],
): void {
if (rules.length === 0) { throw new Error('missing rules'); }
if (!code) {
return;
}
const lines = extractLines(code);
const invalidLines = rules.flatMap((rule) => rule.analyze(lines));
if (invalidLines.length === 0) {
return;
}
const errorText = `Errors with the code.\n${printLines(lines, invalidLines)}`;
throw new Error(errorText);
}
language: ScriptingLanguage,
rules: readonly CodeValidationRule[],
analyzerFactory?: ValidationRuleAnalyzerFactory,
): void;
}
function extractLines(code: string): ICodeLine[] {
export const validateCode: CodeValidator = (
code,
language,
rules,
analyzerFactory = createValidationAnalyzers,
) => {
const analyzers = analyzerFactory(rules);
if (!code) {
return;
}
const lines = extractLines(code);
const invalidLines = analyzers.flatMap((analyze) => analyze(lines, language));
if (invalidLines.length === 0) {
return;
}
const errorText = `Errors with the code.\n${formatLines(lines, invalidLines)}`;
throw new Error(errorText);
};
function extractLines(code: string): CodeLine[] {
const lines = splitTextIntoLines(code);
return lines.map((lineText, lineIndex): ICodeLine => ({
index: lineIndex + 1,
return lines.map((lineText, lineIndex): CodeLine => ({
lineNumber: lineIndex + 1,
text: lineText,
}));
}
function printLines(
lines: readonly ICodeLine[],
invalidLines: readonly IInvalidCodeLine[],
function formatLines(
lines: readonly CodeLine[],
invalidLines: readonly InvalidCodeLine[],
): string {
return lines.map((line) => {
const badLine = invalidLines.find((invalidLine) => invalidLine.index === line.index);
const badLine = invalidLines.find((invalidLine) => invalidLine.lineNumber === line.lineNumber);
if (!badLine) {
return `[${line.index}] ✅ ${line.text}`;
return `[${line.lineNumber}] ✅ ${line.text}`;
}
return `[${badLine.index}] ❌ ${line.text}\n\t⟶ ${badLine.error}`;
return `[${badLine.lineNumber}] ❌ ${line.text}\n\t⟶ ${badLine.error}`;
}).join('\n');
}

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