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

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