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:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user