Relax and improve code validation
Rework code validation to be bound to a context and not context-independent. It means that the generated code is validated based on different phases during the compilation. This is done by moving validation from `ScriptCode` constructor to a different callable function. It removes duplicate detection for function calls once a call is fully compiled, but still checks for duplicates inside each function body that has inline code. This allows for having duplicates in final scripts (thus relaxing the duplicate detection), e.g., when multiple calls to the same function is made. It fixes non-duplicates (when using common syntax) being misrepresented as duplicate lines. It improves the output of errors, such as printing valid lines, to give more context. This improvement also fixes empty line validation not showing the right empty lines in the error output. Empty line validation shows tabs and whitespaces more clearly. Finally, it adds more tests including tests for existing logic, such as singleton factories.
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import type { FunctionData } from '@/application/collections/';
|
||||
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
|
||||
import { ILanguageSyntax } from '@/domain/ScriptCode';
|
||||
import { IScriptCompiler } from './Compiler/IScriptCompiler';
|
||||
import { ScriptCompiler } from './Compiler/ScriptCompiler';
|
||||
import { ICategoryCollectionParseContext } from './ICategoryCollectionParseContext';
|
||||
import { SyntaxFactory } from './Syntax/SyntaxFactory';
|
||||
import { ISyntaxFactory } from './Syntax/ISyntaxFactory';
|
||||
import { SyntaxFactory } from './Validation/Syntax/SyntaxFactory';
|
||||
import { ISyntaxFactory } from './Validation/Syntax/ISyntaxFactory';
|
||||
import { ILanguageSyntax } from './Validation/Syntax/ILanguageSyntax';
|
||||
|
||||
export class CategoryCollectionParseContext implements ICategoryCollectionParseContext {
|
||||
public readonly compiler: IScriptCompiler;
|
||||
|
||||
@@ -81,7 +81,7 @@ function compileCode(
|
||||
compiler: IExpressionsCompiler,
|
||||
): ICompiledFunctionCall {
|
||||
return {
|
||||
code: compiler.compileExpressions(code.do, args),
|
||||
code: compiler.compileExpressions(code.execute, args),
|
||||
revertCode: compiler.compileExpressions(code.revert, args),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ export enum FunctionBodyType {
|
||||
}
|
||||
|
||||
export interface IFunctionCode {
|
||||
readonly do: string;
|
||||
readonly execute: string;
|
||||
readonly revert?: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { FunctionData } from '@/application/collections/';
|
||||
import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax';
|
||||
import { ISharedFunctionCollection } from './ISharedFunctionCollection';
|
||||
|
||||
export interface ISharedFunctionsParser {
|
||||
parseFunctions(functions: readonly FunctionData[]): ISharedFunctionCollection;
|
||||
parseFunctions(
|
||||
functions: readonly FunctionData[],
|
||||
syntax: ILanguageSyntax,
|
||||
): ISharedFunctionCollection;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IFunctionCall } from './Call/IFunctionCall';
|
||||
|
||||
import {
|
||||
FunctionBodyType, IFunctionCode, ISharedFunction, ISharedFunctionBody,
|
||||
} from './ISharedFunction';
|
||||
@@ -25,7 +26,7 @@ export function createFunctionWithInlineCode(
|
||||
throw new Error(`undefined code in function "${name}"`);
|
||||
}
|
||||
const content: IFunctionCode = {
|
||||
do: code,
|
||||
execute: code,
|
||||
revert: revertCode,
|
||||
};
|
||||
return new SharedFunction(name, parameters, content, FunctionBodyType.Code);
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { FunctionData, InstructionHolder } from '@/application/collections/';
|
||||
import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax';
|
||||
import { CodeValidator } from '@/application/Parser/Script/Validation/CodeValidator';
|
||||
import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines';
|
||||
import { NoDuplicatedLines } from '@/application/Parser/Script/Validation/Rules/NoDuplicatedLines';
|
||||
import { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator';
|
||||
import { createFunctionWithInlineCode, createCallerFunction } from './SharedFunction';
|
||||
import { SharedFunctionCollection } from './SharedFunctionCollection';
|
||||
import { ISharedFunctionCollection } from './ISharedFunctionCollection';
|
||||
@@ -12,16 +17,20 @@ import { parseFunctionCalls } from './Call/FunctionCallParser';
|
||||
export class SharedFunctionsParser implements ISharedFunctionsParser {
|
||||
public static readonly instance: ISharedFunctionsParser = new SharedFunctionsParser();
|
||||
|
||||
constructor(private readonly codeValidator: ICodeValidator = CodeValidator.instance) { }
|
||||
|
||||
public parseFunctions(
|
||||
functions: readonly FunctionData[],
|
||||
syntax: ILanguageSyntax,
|
||||
): ISharedFunctionCollection {
|
||||
if (!syntax) { throw new Error('missing syntax'); }
|
||||
const collection = new SharedFunctionCollection();
|
||||
if (!functions || !functions.length) {
|
||||
return collection;
|
||||
}
|
||||
ensureValidFunctions(functions);
|
||||
return functions
|
||||
.map((func) => parseFunction(func))
|
||||
.map((func) => parseFunction(func, syntax, this.codeValidator))
|
||||
.reduce((acc, func) => {
|
||||
acc.addFunction(func);
|
||||
return acc;
|
||||
@@ -29,10 +38,15 @@ export class SharedFunctionsParser implements ISharedFunctionsParser {
|
||||
}
|
||||
}
|
||||
|
||||
function parseFunction(data: FunctionData): ISharedFunction {
|
||||
function parseFunction(
|
||||
data: FunctionData,
|
||||
syntax: ILanguageSyntax,
|
||||
validator: ICodeValidator,
|
||||
): ISharedFunction {
|
||||
const { name } = data;
|
||||
const parameters = parseParameters(data);
|
||||
if (hasCode(data)) {
|
||||
validateCode(data, syntax, validator);
|
||||
return createFunctionWithInlineCode(name, parameters, data.code, data.revertCode);
|
||||
}
|
||||
// Has call
|
||||
@@ -40,6 +54,19 @@ function parseFunction(data: FunctionData): ISharedFunction {
|
||||
return createCallerFunction(name, parameters, calls);
|
||||
}
|
||||
|
||||
function validateCode(
|
||||
data: FunctionData,
|
||||
syntax: ILanguageSyntax,
|
||||
validator: ICodeValidator,
|
||||
): void {
|
||||
[data.code, data.revertCode].forEach(
|
||||
(code) => validator.throwIfInvalid(
|
||||
code,
|
||||
[new NoEmptyLines(), new NoDuplicatedLines(syntax)],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function parseParameters(data: FunctionData): IReadOnlyFunctionParameterCollection {
|
||||
return (data.parameters || [])
|
||||
.map((parameter) => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { FunctionData, ScriptData } from '@/application/collections/';
|
||||
import { IScriptCode } from '@/domain/IScriptCode';
|
||||
import { ScriptCode, ILanguageSyntax } from '@/domain/ScriptCode';
|
||||
import { ScriptCode } from '@/domain/ScriptCode';
|
||||
import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax';
|
||||
import { CodeValidator } from '@/application/Parser/Script/Validation/CodeValidator';
|
||||
import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines';
|
||||
import { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator';
|
||||
import { IScriptCompiler } from './IScriptCompiler';
|
||||
import { ISharedFunctionCollection } from './Function/ISharedFunctionCollection';
|
||||
import { IFunctionCallCompiler } from './Function/Call/Compiler/IFunctionCallCompiler';
|
||||
@@ -8,18 +12,20 @@ import { FunctionCallCompiler } from './Function/Call/Compiler/FunctionCallCompi
|
||||
import { ISharedFunctionsParser } from './Function/ISharedFunctionsParser';
|
||||
import { SharedFunctionsParser } from './Function/SharedFunctionsParser';
|
||||
import { parseFunctionCalls } from './Function/Call/FunctionCallParser';
|
||||
import { ICompiledCode } from './Function/Call/Compiler/ICompiledCode';
|
||||
|
||||
export class ScriptCompiler implements IScriptCompiler {
|
||||
private readonly functions: ISharedFunctionCollection;
|
||||
|
||||
constructor(
|
||||
functions: readonly FunctionData[] | undefined,
|
||||
private readonly syntax: ILanguageSyntax,
|
||||
private readonly callCompiler: IFunctionCallCompiler = FunctionCallCompiler.instance,
|
||||
syntax: ILanguageSyntax,
|
||||
sharedFunctionsParser: ISharedFunctionsParser = SharedFunctionsParser.instance,
|
||||
private readonly callCompiler: IFunctionCallCompiler = FunctionCallCompiler.instance,
|
||||
private readonly codeValidator: ICodeValidator = CodeValidator.instance,
|
||||
) {
|
||||
if (!syntax) { throw new Error('missing syntax'); }
|
||||
this.functions = sharedFunctionsParser.parseFunctions(functions);
|
||||
this.functions = sharedFunctionsParser.parseFunctions(functions, syntax);
|
||||
}
|
||||
|
||||
public canCompile(script: ScriptData): boolean {
|
||||
@@ -35,13 +41,19 @@ export class ScriptCompiler implements IScriptCompiler {
|
||||
try {
|
||||
const calls = parseFunctionCalls(script.call);
|
||||
const compiledCode = this.callCompiler.compileCall(calls, this.functions);
|
||||
validateCompiledCode(compiledCode, this.codeValidator);
|
||||
return new ScriptCode(
|
||||
compiledCode.code,
|
||||
compiledCode.revertCode,
|
||||
this.syntax,
|
||||
);
|
||||
} catch (error) {
|
||||
throw Error(`Script "${script.name}" ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateCompiledCode(compiledCode: ICompiledCode, validator: ICodeValidator): void {
|
||||
[compiledCode.code, compiledCode.revertCode].forEach(
|
||||
(code) => validator.throwIfInvalid(code, [new NoEmptyLines()]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ILanguageSyntax } from '@/domain/ScriptCode';
|
||||
import { IScriptCompiler } from './Compiler/IScriptCompiler';
|
||||
import { ILanguageSyntax } from './Validation/Syntax/ILanguageSyntax';
|
||||
|
||||
export interface ICategoryCollectionParseContext {
|
||||
readonly compiler: IScriptCompiler;
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import type { ScriptData } from '@/application/collections/';
|
||||
import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines';
|
||||
import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax';
|
||||
import { Script } from '@/domain/Script';
|
||||
import { RecommendationLevel } from '@/domain/RecommendationLevel';
|
||||
import { IScriptCode } from '@/domain/IScriptCode';
|
||||
import { ScriptCode } from '@/domain/ScriptCode';
|
||||
import { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator';
|
||||
import { parseDocs } from '../DocumentationParser';
|
||||
import { createEnumParser, IEnumParser } from '../../Common/Enum';
|
||||
import { NodeType } from '../NodeValidation/NodeType';
|
||||
import { NodeValidator } from '../NodeValidation/NodeValidator';
|
||||
import { ICategoryCollectionParseContext } from './ICategoryCollectionParseContext';
|
||||
import { CodeValidator } from './Validation/CodeValidator';
|
||||
import { NoDuplicatedLines } from './Validation/Rules/NoDuplicatedLines';
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
export function parseScript(
|
||||
@@ -15,6 +20,7 @@ export function parseScript(
|
||||
context: ICategoryCollectionParseContext,
|
||||
levelParser = createEnumParser(RecommendationLevel),
|
||||
scriptFactory: ScriptFactoryType = ScriptFactory,
|
||||
codeValidator: ICodeValidator = CodeValidator.instance,
|
||||
): Script {
|
||||
const validator = new NodeValidator({ type: NodeType.Script, selfNode: data });
|
||||
validateScript(data, validator);
|
||||
@@ -22,7 +28,7 @@ export function parseScript(
|
||||
try {
|
||||
const script = scriptFactory(
|
||||
/* name: */ data.name,
|
||||
/* code: */ parseCode(data, context),
|
||||
/* code: */ parseCode(data, context, codeValidator),
|
||||
/* docs: */ parseDocs(data),
|
||||
/* level: */ parseLevel(data.recommend, levelParser),
|
||||
);
|
||||
@@ -42,11 +48,30 @@ function parseLevel(
|
||||
return parser.parseEnum(level, 'level');
|
||||
}
|
||||
|
||||
function parseCode(script: ScriptData, context: ICategoryCollectionParseContext): IScriptCode {
|
||||
function parseCode(
|
||||
script: ScriptData,
|
||||
context: ICategoryCollectionParseContext,
|
||||
codeValidator: ICodeValidator,
|
||||
): IScriptCode {
|
||||
if (context.compiler.canCompile(script)) {
|
||||
return context.compiler.compile(script);
|
||||
}
|
||||
return new ScriptCode(script.code, script.revertCode, context.syntax);
|
||||
const code = new ScriptCode(script.code, script.revertCode);
|
||||
validateHardcodedCodeWithoutCalls(code, codeValidator, context.syntax);
|
||||
return code;
|
||||
}
|
||||
|
||||
function validateHardcodedCodeWithoutCalls(
|
||||
scriptCode: ScriptCode,
|
||||
codeValidator: ICodeValidator,
|
||||
syntax: ILanguageSyntax,
|
||||
) {
|
||||
[scriptCode.execute, scriptCode.revert].forEach(
|
||||
(code) => codeValidator.throwIfInvalid(
|
||||
code,
|
||||
[new NoEmptyLines(), new NoDuplicatedLines(syntax)],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function validateScript(script: ScriptData, validator: NodeValidator) {
|
||||
|
||||
46
src/application/Parser/Script/Validation/CodeValidator.ts
Normal file
46
src/application/Parser/Script/Validation/CodeValidator.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { ICodeValidationRule, IInvalidCodeLine } from './ICodeValidationRule';
|
||||
import { ICodeValidator } from './ICodeValidator';
|
||||
import { ICodeLine } from './ICodeLine';
|
||||
|
||||
export class CodeValidator implements ICodeValidator {
|
||||
public static readonly instance: ICodeValidator = new CodeValidator();
|
||||
|
||||
public throwIfInvalid(
|
||||
code: string,
|
||||
rules: readonly ICodeValidationRule[],
|
||||
): void {
|
||||
if (!rules || rules.length === 0) { throw new Error('missing rules'); }
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
const lines = extractLines(code);
|
||||
const invalidLines = rules.flatMap((rule) => rule.analyze(lines));
|
||||
if (invalidLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
const errorText = `Errors with the code.\n${printLines(lines, invalidLines)}`;
|
||||
throw new Error(errorText);
|
||||
}
|
||||
}
|
||||
|
||||
function extractLines(code: string): ICodeLine[] {
|
||||
return code
|
||||
.split(/\r\n|\r|\n/)
|
||||
.map((lineText, lineIndex): ICodeLine => ({
|
||||
index: lineIndex + 1,
|
||||
text: lineText,
|
||||
}));
|
||||
}
|
||||
|
||||
function printLines(
|
||||
lines: readonly ICodeLine[],
|
||||
invalidLines: readonly IInvalidCodeLine[],
|
||||
): string {
|
||||
return lines.map((line) => {
|
||||
const badLine = invalidLines.find((invalidLine) => invalidLine.index === line.index);
|
||||
if (!badLine) {
|
||||
return `[${line.index}] ✅ ${line.text}`;
|
||||
}
|
||||
return `[${badLine.index}] ❌ ${line.text}\n\t⟶ ${badLine.error}`;
|
||||
}).join('\n');
|
||||
}
|
||||
4
src/application/Parser/Script/Validation/ICodeLine.ts
Normal file
4
src/application/Parser/Script/Validation/ICodeLine.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface ICodeLine {
|
||||
readonly index: number;
|
||||
readonly text: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ICodeLine } from './ICodeLine';
|
||||
|
||||
export interface IInvalidCodeLine {
|
||||
readonly index: number;
|
||||
readonly error: string;
|
||||
}
|
||||
|
||||
export interface ICodeValidationRule {
|
||||
analyze(lines: readonly ICodeLine[]): IInvalidCodeLine[];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ICodeValidationRule } from './ICodeValidationRule';
|
||||
|
||||
export interface ICodeValidator {
|
||||
throwIfInvalid(
|
||||
code: string,
|
||||
rules: readonly ICodeValidationRule[],
|
||||
): void;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax';
|
||||
import { ICodeLine } from '../ICodeLine';
|
||||
import { ICodeValidationRule, IInvalidCodeLine } from '../ICodeValidationRule';
|
||||
|
||||
export class NoDuplicatedLines implements ICodeValidationRule {
|
||||
constructor(private readonly syntax: ILanguageSyntax) {
|
||||
if (!syntax) { throw new Error('missing syntax'); }
|
||||
}
|
||||
|
||||
public analyze(lines: readonly ICodeLine[]): IInvalidCodeLine[] {
|
||||
return lines
|
||||
.map((line): IDuplicateAnalyzedLine => ({
|
||||
index: line.index,
|
||||
isIgnored: shouldIgnoreLine(line.text, this.syntax),
|
||||
occurrenceIndices: lines
|
||||
.filter((other) => other.text === line.text)
|
||||
.map((duplicatedLine) => duplicatedLine.index),
|
||||
}))
|
||||
.filter((line) => hasInvalidDuplicates(line))
|
||||
.map((line): IInvalidCodeLine => ({
|
||||
index: line.index,
|
||||
error: `Line is duplicated at line numbers ${line.occurrenceIndices.join(',')}.`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
interface IDuplicateAnalyzedLine {
|
||||
readonly index: number;
|
||||
readonly occurrenceIndices: readonly number[];
|
||||
readonly isIgnored: boolean;
|
||||
}
|
||||
|
||||
function hasInvalidDuplicates(line: IDuplicateAnalyzedLine): boolean {
|
||||
return !line.isIgnored && line.occurrenceIndices.length > 1;
|
||||
}
|
||||
|
||||
function shouldIgnoreLine(codeLine: string, syntax: ILanguageSyntax): boolean {
|
||||
const lowerCaseCodeLine = codeLine.toLowerCase();
|
||||
const isCommentLine = () => syntax.commentDelimiters.some(
|
||||
(delimiter) => lowerCaseCodeLine.startsWith(delimiter),
|
||||
);
|
||||
const consistsOfFrequentCommands = () => {
|
||||
const trimmed = lowerCaseCodeLine.trim().split(' ');
|
||||
return trimmed.every((part) => syntax.commonCodeParts.includes(part));
|
||||
};
|
||||
return isCommentLine() || consistsOfFrequentCommands();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ICodeLine } from '../ICodeLine';
|
||||
import { ICodeValidationRule, IInvalidCodeLine } from '../ICodeValidationRule';
|
||||
|
||||
export class NoEmptyLines implements ICodeValidationRule {
|
||||
public analyze(lines: readonly ICodeLine[]): IInvalidCodeLine[] {
|
||||
return lines
|
||||
.filter((line) => (line.text?.trim().length ?? 0) === 0)
|
||||
.map((line): IInvalidCodeLine => ({
|
||||
index: line.index,
|
||||
error: (() => {
|
||||
if (!line.text) {
|
||||
return 'Empty line';
|
||||
}
|
||||
const markedText = line.text
|
||||
.replaceAll(' ', '{whitespace}')
|
||||
.replaceAll('\t', '{tab}');
|
||||
return `Empty line: "${markedText}"`;
|
||||
})(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ILanguageSyntax } from '@/domain/ScriptCode';
|
||||
import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax';
|
||||
|
||||
const BatchFileCommonCodeParts = ['(', ')', 'else', '||'];
|
||||
const PowerShellCommonCodeParts = ['{', '}'];
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface ILanguageSyntax {
|
||||
readonly commentDelimiters: string[];
|
||||
readonly commonCodeParts: string[];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ILanguageSyntax } from '@/domain/ScriptCode';
|
||||
import { IScriptingLanguageFactory } from '@/application/Common/ScriptingLanguage/IScriptingLanguageFactory';
|
||||
import { ILanguageSyntax } from './ILanguageSyntax';
|
||||
|
||||
export type ISyntaxFactory = IScriptingLanguageFactory<ILanguageSyntax>;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ILanguageSyntax } from '@/domain/ScriptCode';
|
||||
import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax';
|
||||
|
||||
export class ShellScriptSyntax implements ILanguageSyntax {
|
||||
public readonly commentDelimiters = ['#'];
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ILanguageSyntax } from '@/domain/ScriptCode';
|
||||
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
|
||||
import { ScriptingLanguageFactory } from '@/application/Common/ScriptingLanguage/ScriptingLanguageFactory';
|
||||
import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax';
|
||||
import { BatchFileSyntax } from './BatchFileSyntax';
|
||||
import { ShellScriptSyntax } from './ShellScriptSyntax';
|
||||
import { ISyntaxFactory } from './ISyntaxFactory';
|
||||
Reference in New Issue
Block a user