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:
undergroundwires
2022-10-29 20:03:06 +02:00
parent f4a7ca76b8
commit e8199932b4
44 changed files with 1095 additions and 392 deletions

View 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');
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax';
const BatchFileCommonCodeParts = ['(', ')', 'else', '||'];
const PowerShellCommonCodeParts = ['{', '}'];
export class BatchFileSyntax implements ILanguageSyntax {
public readonly commentDelimiters = ['REM', '::'];
public readonly commonCodeParts = [...BatchFileCommonCodeParts, ...PowerShellCommonCodeParts];
}

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax';
export class ShellScriptSyntax implements ILanguageSyntax {
public readonly commentDelimiters = ['#'];
public readonly commonCodeParts = ['(', ')', 'else', 'fi'];
}

View File

@@ -0,0 +1,16 @@
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';
export class SyntaxFactory
extends ScriptingLanguageFactory<ILanguageSyntax>
implements ISyntaxFactory {
constructor() {
super();
this.registerGetter(ScriptingLanguage.batchfile, () => new BatchFileSyntax());
this.registerGetter(ScriptingLanguage.shellscript, () => new ShellScriptSyntax());
}
}