From 851917e049c41c679644ddbe8ad4b6e45e5c8f35 Mon Sep 17 00:00:00 2001 From: undergroundwires Date: Thu, 18 Jul 2024 20:49:21 +0200 Subject: [PATCH] Refactor text utilities and expand their usage This commit refactors existing text utility functions into the application layer for broad reuse and integrates them across the codebase. Initially, these utilities were confined to test code, which limited their application. Changes: - Move text utilities to the application layer. - Centralize text utilities into dedicated files for better maintainability. - Improve robustness of utility functions with added type checks. - Replace duplicated logic with centralized utility functions throughout the codebase. - Expand unit tests to cover refactored code parts. --- scripts/verify-build-artifacts.js | 2 +- .../Common/Text/FilterEmptyStrings.ts | 25 ++++ src/application/Common/Text/IndentText.ts | 29 ++++ .../Common/Text/SplitTextIntoLines.ts | 11 ++ .../State/Code/Event/CodeChangedEvent.ts | 5 +- .../State/Code/Generation/CodeBuilder.ts | 3 +- .../Pipes/PipeDefinitions/InlinePowerShell.ts | 9 +- .../NewlineCodeSegmentMerger.ts | 9 +- .../Strategies/InlineFunctionCallCompiler.ts | 9 +- .../Function/SharedFunctionsParser.ts | 10 +- .../Script/Compiler/ScriptCompiler.ts | 5 +- .../Parser/Executable/Script/ScriptParser.ts | 4 +- .../Script/Validation/CodeValidator.ts | 12 +- .../Documentation/DocumentationText.vue | 4 +- .../app/check-for-errors.ts | 5 +- .../system-capture/window-title-capture.ts | 28 ++-- .../check-desktop-runtime-errors/main.ts | 2 +- .../utils/run-command.ts | 2 +- .../ExponentialBackOffRetryHandler.ts | 2 +- .../StatusChecker/FetchFollow.ts | 2 +- .../external-urls/StatusChecker/Requestor.ts | 2 +- .../StatusChecker/TlsFingerprintRandomizer.ts | 2 +- .../external-urls/StatusChecker/UrlStatus.ts | 2 +- .../TestExecutionDetailsLogger.ts | 2 +- tests/checks/external-urls/main.spec.ts | 2 +- tests/shared/Assertions/ExpectDeepIncludes.ts | 2 +- tests/shared/Text.ts | 29 ---- .../Common/Text/FilterEmptyStrings.spec.ts | 99 +++++++++++++ .../Common/Text/IndentText.spec.ts | 130 ++++++++++++++++++ .../Common/Text/SplitTextIntoLines.spec.ts | 96 +++++++++++++ .../State/Code/Event/CodeChangedEvent.spec.ts | 29 +++- .../State/Code/Generation/CodeBuilder.spec.ts | 31 +++-- .../Script/ExpectEqualSelectedScripts.ts | 5 +- .../Parser/Common/ContextualError.spec.ts | 3 +- .../Parser/Common/ContextualErrorTester.ts | 2 +- .../Parser/Executable/CategoryParser.spec.ts | 2 +- .../Parser/Regex/RegexParser.spec.ts | 2 +- .../Script/Validation/CodeValidator.spec.ts | 16 ++- .../Validation/ExecutableValidationTester.ts | 2 +- .../collections/NoUnintentedInlining.spec.ts | 4 +- .../UseNodeStateChangeAggregator.spec.ts | 5 +- .../UseExpandCollapseAnimation.spec.ts | 3 +- tests/unit/shared/Stubs/ErrorWrapperStub.ts | 4 +- tests/unit/shared/Stubs/IsArrayStub.ts | 14 ++ tests/unit/shared/Stubs/IsStringStub.ts | 14 ++ 45 files changed, 563 insertions(+), 117 deletions(-) create mode 100644 src/application/Common/Text/FilterEmptyStrings.ts create mode 100644 src/application/Common/Text/IndentText.ts create mode 100644 src/application/Common/Text/SplitTextIntoLines.ts delete mode 100644 tests/shared/Text.ts create mode 100644 tests/unit/application/Common/Text/FilterEmptyStrings.spec.ts create mode 100644 tests/unit/application/Common/Text/IndentText.spec.ts create mode 100644 tests/unit/application/Common/Text/SplitTextIntoLines.spec.ts create mode 100644 tests/unit/shared/Stubs/IsArrayStub.ts create mode 100644 tests/unit/shared/Stubs/IsStringStub.ts diff --git a/scripts/verify-build-artifacts.js b/scripts/verify-build-artifacts.js index 4c32dabe..b29354aa 100644 --- a/scripts/verify-build-artifacts.js +++ b/scripts/verify-build-artifacts.js @@ -91,7 +91,7 @@ async function verifyFilesExist(directoryPath, filePatterns) { if (!match) { die( `No file matches the pattern ${pattern.source} in directory \`${directoryPath}\``, - `\nFiles in directory:\n${files.map((file) => `\t- ${file}`).join('\n')}`, + `\nFiles in directory:\n${files.map((file) => `- ${file}`).join('\n')}`, ); } } diff --git a/src/application/Common/Text/FilterEmptyStrings.ts b/src/application/Common/Text/FilterEmptyStrings.ts new file mode 100644 index 00000000..aca4e2a9 --- /dev/null +++ b/src/application/Common/Text/FilterEmptyStrings.ts @@ -0,0 +1,25 @@ +import { isArray } from '@/TypeHelpers'; + +export type OptionalString = string | undefined | null; + +export function filterEmptyStrings( + texts: readonly OptionalString[], + isArrayType: typeof isArray = isArray, +): string[] { + if (!isArrayType(texts)) { + throw new Error(`Invalid input: Expected an array, but received type ${typeof texts}.`); + } + assertArrayItemsAreStringLike(texts); + return texts + .filter((title): title is string => Boolean(title)); +} + +function assertArrayItemsAreStringLike( + texts: readonly unknown[], +): asserts texts is readonly OptionalString[] { + const invalidItems = texts.filter((item) => !(typeof item === 'string' || item === undefined || item === null)); + if (invalidItems.length > 0) { + const invalidTypes = invalidItems.map((item) => typeof item).join(', '); + throw new Error(`Invalid array items: Expected items as string, undefined, or null. Received invalid types: ${invalidTypes}.`); + } +} diff --git a/src/application/Common/Text/IndentText.ts b/src/application/Common/Text/IndentText.ts new file mode 100644 index 00000000..ed7d104a --- /dev/null +++ b/src/application/Common/Text/IndentText.ts @@ -0,0 +1,29 @@ +import { isString } from '@/TypeHelpers'; +import { splitTextIntoLines } from './SplitTextIntoLines'; + +export function indentText( + text: string, + indentLevel = 1, + utilities: TextIndentationUtilities = DefaultUtilities, +): string { + if (!utilities.isStringType(text)) { + throw new Error(`Indentation error: The input must be a string. Received type: ${typeof text}.`); + } + if (indentLevel <= 0) { + throw new Error(`Indentation error: The indent level must be a positive integer. Received: ${indentLevel}.`); + } + const indentation = '\t'.repeat(indentLevel); + return utilities.splitIntoLines(text) + .map((line) => (line ? `${indentation}${line}` : line)) + .join('\n'); +} + +interface TextIndentationUtilities { + readonly splitIntoLines: typeof splitTextIntoLines; + readonly isStringType: typeof isString; +} + +const DefaultUtilities: TextIndentationUtilities = { + splitIntoLines: splitTextIntoLines, + isStringType: isString, +}; diff --git a/src/application/Common/Text/SplitTextIntoLines.ts b/src/application/Common/Text/SplitTextIntoLines.ts new file mode 100644 index 00000000..a57871c9 --- /dev/null +++ b/src/application/Common/Text/SplitTextIntoLines.ts @@ -0,0 +1,11 @@ +import { isString } from '@/TypeHelpers'; + +export function splitTextIntoLines( + text: string, + isStringType = isString, +): string[] { + if (!isStringType(text)) { + throw new Error(`Line splitting error: Expected a string but received type '${typeof text}'.`); + } + return text.split(/\r\n|\r|\n/); +} diff --git a/src/application/Context/State/Code/Event/CodeChangedEvent.ts b/src/application/Context/State/Code/Event/CodeChangedEvent.ts index aa99a78e..916483f2 100644 --- a/src/application/Context/State/Code/Event/CodeChangedEvent.ts +++ b/src/application/Context/State/Code/Event/CodeChangedEvent.ts @@ -1,6 +1,7 @@ import type { Script } from '@/domain/Executables/Script/Script'; import type { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition'; import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript'; +import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines'; import type { ICodeChangedEvent } from './ICodeChangedEvent'; export class CodeChangedEvent implements ICodeChangedEvent { @@ -52,12 +53,12 @@ export class CodeChangedEvent implements ICodeChangedEvent { } function ensureAllPositionsExist(script: string, positions: ReadonlyArray) { - const totalLines = script.split(/\r\n|\r|\n/).length; + const totalLines = splitTextIntoLines(script).length; const missingPositions = positions.filter((position) => position.endLine > totalLines); if (missingPositions.length > 0) { throw new Error( `Out of range script end line: "${missingPositions.map((pos) => pos.endLine).join('", "')}"` - + `(total code lines: ${totalLines}).`, + + ` (total code lines: ${totalLines}).`, ); } } diff --git a/src/application/Context/State/Code/Generation/CodeBuilder.ts b/src/application/Context/State/Code/Generation/CodeBuilder.ts index 55cc5be3..1ab2a672 100644 --- a/src/application/Context/State/Code/Generation/CodeBuilder.ts +++ b/src/application/Context/State/Code/Generation/CodeBuilder.ts @@ -1,3 +1,4 @@ +import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines'; import type { ICodeBuilder } from './ICodeBuilder'; const TotalFunctionSeparatorChars = 58; @@ -15,7 +16,7 @@ export abstract class CodeBuilder implements ICodeBuilder { this.lines.push(''); return this; } - const lines = code.match(/[^\r\n]+/g); + const lines = splitTextIntoLines(code); if (lines) { this.lines.push(...lines); } diff --git a/src/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShell.ts b/src/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShell.ts index 79745677..f586d24d 100644 --- a/src/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShell.ts +++ b/src/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShell.ts @@ -1,3 +1,4 @@ +import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines'; import type { IPipe } from '../IPipe'; export class InlinePowerShell implements IPipe { @@ -89,10 +90,6 @@ function inlineComments(code: string): string { */ } -function getLines(code: string): string[] { - return (code?.split(/\r\n|\r|\n/) || []); -} - /* Merges inline here-strings to a single lined string with Windows line terminator (\r\n) https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules?view=powershell-7.4#here-strings @@ -102,7 +99,7 @@ function mergeHereStrings(code: string) { return code.replaceAll(regex, (_$, quotes, scope) => { const newString = getHereStringHandler(quotes); const escaped = scope.replaceAll(quotes, newString.escapedQuotes); - const lines = getLines(escaped); + const lines = splitTextIntoLines(escaped); const inlined = lines.join(newString.separator); const quoted = `${newString.quotesAround}${inlined}${newString.quotesAround}`; return quoted; @@ -159,7 +156,7 @@ function mergeLinesWithBacktick(code: string) { } function mergeNewLines(code: string) { - return getLines(code) + return splitTextIntoLines(code) .map((line) => line.trim()) .filter((line) => line.length > 0) .join('; '); diff --git a/src/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/CodeSegmentJoin/NewlineCodeSegmentMerger.ts b/src/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/CodeSegmentJoin/NewlineCodeSegmentMerger.ts index 74fa0b12..fd0b97e5 100644 --- a/src/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/CodeSegmentJoin/NewlineCodeSegmentMerger.ts +++ b/src/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/CodeSegmentJoin/NewlineCodeSegmentMerger.ts @@ -1,3 +1,4 @@ +import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings'; import type { CompiledCode } from '../CompiledCode'; import type { CodeSegmentMerger } from './CodeSegmentMerger'; @@ -8,11 +9,9 @@ export class NewlineCodeSegmentMerger implements CodeSegmentMerger { } return { code: joinCodeParts(codeSegments.map((f) => f.code)), - revertCode: joinCodeParts( - codeSegments - .map((f) => f.revertCode) - .filter((code): code is string => Boolean(code)), - ), + revertCode: joinCodeParts(filterEmptyStrings( + codeSegments.map((f) => f.revertCode), + )), }; } } diff --git a/src/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/SingleCall/Strategies/InlineFunctionCallCompiler.ts b/src/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/SingleCall/Strategies/InlineFunctionCallCompiler.ts index 9f4cfe63..57b03161 100644 --- a/src/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/SingleCall/Strategies/InlineFunctionCallCompiler.ts +++ b/src/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/SingleCall/Strategies/InlineFunctionCallCompiler.ts @@ -3,6 +3,7 @@ import type { IExpressionsCompiler } from '@/application/Parser/Executable/Scrip import { FunctionBodyType, type ISharedFunction } from '@/application/Parser/Executable/Script/Compiler/Function/ISharedFunction'; import type { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall'; import type { CompiledCode } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/CompiledCode'; +import { indentText } from '@/application/Common/Text/IndentText'; import type { SingleCallCompilerStrategy } from '../SingleCallCompilerStrategy'; export class InlineFunctionCallCompiler implements SingleCallCompilerStrategy { @@ -22,10 +23,12 @@ export class InlineFunctionCallCompiler implements SingleCallCompilerStrategy { if (calledFunction.body.type !== FunctionBodyType.Code) { throw new Error([ 'Unexpected function body type.', - `\tExpected: "${FunctionBodyType[FunctionBodyType.Code]}"`, - `\tActual: "${FunctionBodyType[calledFunction.body.type]}"`, + indentText([ + `Expected: "${FunctionBodyType[FunctionBodyType.Code]}"`, + `Actual: "${FunctionBodyType[calledFunction.body.type]}"`, + ].join('\n')), 'Function:', - `\t${JSON.stringify(callToFunction)}`, + indentText(JSON.stringify(callToFunction)), ].join('\n')); } const { code } = calledFunction.body; diff --git a/src/application/Parser/Executable/Script/Compiler/Function/SharedFunctionsParser.ts b/src/application/Parser/Executable/Script/Compiler/Function/SharedFunctionsParser.ts index 934b4cdf..7b3c79fa 100644 --- a/src/application/Parser/Executable/Script/Compiler/Function/SharedFunctionsParser.ts +++ b/src/application/Parser/Executable/Script/Compiler/Function/SharedFunctionsParser.ts @@ -9,6 +9,7 @@ import { NoDuplicatedLines } from '@/application/Parser/Executable/Script/Valida import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator'; import { isArray, isNullOrUndefined, isPlainObject } from '@/TypeHelpers'; import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError'; +import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings'; import { createFunctionWithInlineCode, createCallerFunction } from './SharedFunction'; import { SharedFunctionCollection } from './SharedFunctionCollection'; import { parseFunctionCalls, type FunctionCallsParser } from './Call/FunctionCallsParser'; @@ -82,8 +83,7 @@ function validateCode( syntax: ILanguageSyntax, validator: ICodeValidator, ): void { - [data.code, data.revertCode] - .filter((code): code is string => Boolean(code)) + filterEmptyStrings([data.code, data.revertCode]) .forEach( (code) => validator.throwIfInvalid( code, @@ -204,9 +204,9 @@ function ensureNoDuplicateCode(functions: readonly FunctionData[]) { if (duplicateCodes.length > 0) { throw new Error(`duplicate "code" in functions: ${printList(duplicateCodes)}`); } - const duplicateRevertCodes = getDuplicates(callFunctions - .map((func) => func.revertCode) - .filter((code): code is string => Boolean(code))); + const duplicateRevertCodes = getDuplicates(filterEmptyStrings( + callFunctions.map((func) => func.revertCode), + )); if (duplicateRevertCodes.length > 0) { throw new Error(`duplicate "revertCode" in functions: ${printList(duplicateRevertCodes)}`); } diff --git a/src/application/Parser/Executable/Script/Compiler/ScriptCompiler.ts b/src/application/Parser/Executable/Script/Compiler/ScriptCompiler.ts index 5ad8d6c9..fc24b59a 100644 --- a/src/application/Parser/Executable/Script/Compiler/ScriptCompiler.ts +++ b/src/application/Parser/Executable/Script/Compiler/ScriptCompiler.ts @@ -6,6 +6,7 @@ import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/ 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'; @@ -71,9 +72,7 @@ export class ScriptCompiler implements IScriptCompiler { } function validateCompiledCode(compiledCode: CompiledCode, validator: ICodeValidator): void { - [compiledCode.code, compiledCode.revertCode] - .filter((code): code is string => Boolean(code)) - .map((code) => code as string) + filterEmptyStrings([compiledCode.code, compiledCode.revertCode]) .forEach( (code) => validator.throwIfInvalid( code, diff --git a/src/application/Parser/Executable/Script/ScriptParser.ts b/src/application/Parser/Executable/Script/ScriptParser.ts index e248bde1..446227c5 100644 --- a/src/application/Parser/Executable/Script/ScriptParser.ts +++ b/src/application/Parser/Executable/Script/ScriptParser.ts @@ -10,6 +10,7 @@ import type { ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptC import { createScriptCode } from '@/domain/Executables/Script/Code/ScriptCodeFactory'; import type { Script } from '@/domain/Executables/Script/Script'; import { createEnumParser, type EnumParser } from '@/application/Common/Enum'; +import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings'; import { parseDocs, type DocsParser } from '../DocumentationParser'; import { ExecutableType } from '../Validation/ExecutableType'; import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from '../Validation/ExecutableValidator'; @@ -86,8 +87,7 @@ function validateHardcodedCodeWithoutCalls( validator: ICodeValidator, syntax: ILanguageSyntax, ) { - [scriptCode.execute, scriptCode.revert] - .filter((code): code is string => Boolean(code)) + filterEmptyStrings([scriptCode.execute, scriptCode.revert]) .forEach( (code) => validator.throwIfInvalid( code, diff --git a/src/application/Parser/Executable/Script/Validation/CodeValidator.ts b/src/application/Parser/Executable/Script/Validation/CodeValidator.ts index f3778ab3..4cb612f3 100644 --- a/src/application/Parser/Executable/Script/Validation/CodeValidator.ts +++ b/src/application/Parser/Executable/Script/Validation/CodeValidator.ts @@ -1,3 +1,4 @@ +import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines'; import type { ICodeLine } from './ICodeLine'; import type { ICodeValidationRule, IInvalidCodeLine } from './ICodeValidationRule'; import type { ICodeValidator } from './ICodeValidator'; @@ -24,12 +25,11 @@ export class CodeValidator implements ICodeValidator { } function extractLines(code: string): ICodeLine[] { - return code - .split(/\r\n|\r|\n/) - .map((lineText, lineIndex): ICodeLine => ({ - index: lineIndex + 1, - text: lineText, - })); + const lines = splitTextIntoLines(code); + return lines.map((lineText, lineIndex): ICodeLine => ({ + index: lineIndex + 1, + text: lineText, + })); } function printLines( diff --git a/src/presentation/components/Scripts/View/Tree/NodeContent/Documentation/DocumentationText.vue b/src/presentation/components/Scripts/View/Tree/NodeContent/Documentation/DocumentationText.vue index 21a29688..854c1a7b 100644 --- a/src/presentation/components/Scripts/View/Tree/NodeContent/Documentation/DocumentationText.vue +++ b/src/presentation/components/Scripts/View/Tree/NodeContent/Documentation/DocumentationText.vue @@ -8,6 +8,7 @@