diff --git a/libs/execution/src/lib/debugging/debug-log-visitor.ts b/libs/execution/src/lib/debugging/debug-log-visitor.ts index 804a79b33..3a80a71b3 100644 --- a/libs/execution/src/lib/debugging/debug-log-visitor.ts +++ b/libs/execution/src/lib/debugging/debug-log-visitor.ts @@ -6,6 +6,7 @@ import { type WrapperFactoryProvider, internalValueToString, } from '@jvalue/jayvee-language-server'; +import { either } from 'fp-ts'; import { type Logger } from '../logging/logger'; import { type Workbook } from '../types'; @@ -15,6 +16,7 @@ import { type TextFile } from '../types/io-types/filesystem-node-file-text'; import { type IoTypeVisitor } from '../types/io-types/io-type-implementation'; import { type Sheet } from '../types/io-types/sheet'; import { type Table } from '../types/io-types/table'; +import { findLineBounds } from '../util/string-util'; import { type DebugGranularity } from './debug-configuration'; @@ -115,18 +117,31 @@ export class DebugLogVisitor implements IoTypeVisitor { this.logPeekComment(); } - visitTextFile(binaryFile: TextFile): void { - if (this.debugGranularity === 'minimal') { - return; - } - - for (let i = 0; i < binaryFile.content.length; ++i) { - if (i > this.PEEK_NUMBER_OF_LINES) { - break; + visitTextFile(textFile: TextFile): void { + switch (this.debugGranularity) { + case 'minimal': + return; + + case 'exhaustive': + this.log(textFile.content); + return; + + case 'peek': { + // BUG: /\r?\n/ might not be the correct line break + const result = findLineBounds( + [this.PEEK_NUMBER_OF_LINES - 1], + /\r?\n/, + textFile.content, + ); + const { start: firsNonIncludedLineStart } = (either.isRight(result) + ? result.right[0] + : undefined) ?? { + start: textFile.content.length, + }; + this.log(textFile.content.slice(0, firsNonIncludedLineStart)); + this.logPeekComment(); } - this.log(`[Line ${i}] ${binaryFile.content[i] ?? ''}`); } - this.logPeekComment(); } private logPeekComment(): void { diff --git a/libs/execution/src/lib/util/index.ts b/libs/execution/src/lib/util/index.ts index 66a9803b0..9e67d5d9d 100644 --- a/libs/execution/src/lib/util/index.ts +++ b/libs/execution/src/lib/util/index.ts @@ -4,3 +4,4 @@ export * from './implements-static-decorator'; export * from './file-util'; +export * from './string-util'; diff --git a/libs/execution/src/lib/util/string-util.spec.ts b/libs/execution/src/lib/util/string-util.spec.ts new file mode 100644 index 000000000..606529486 --- /dev/null +++ b/libs/execution/src/lib/util/string-util.spec.ts @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: 2025 Friedrich-Alexander-Universitat Erlangen-Nurnberg +// +// SPDX-License-Identifier: AGPL-3.0-only + +// eslint-disable-next-line unicorn/prefer-node-protocol +import { AssertionError } from 'assert'; + +import { either } from 'fp-ts'; + +import { ensureGlobal, findLineBounds } from './string-util'; + +describe('Validation of string-util', () => { + describe('Function ensureGlobal', () => { + it('should make a non global RegExp global', () => { + const result = ensureGlobal(/someregex/); + + expect(result.global).toBe(true); + expect(result.source).toBe('someregex'); + }); + it('should keep a global RegExp global', () => { + const result = ensureGlobal(/someregex/g); + + expect(result.global).toBe(true); + expect(result.source).toBe('someregex'); + }); + }); + describe('Function findLineBounds', () => { + it('should return empty array for empty array', () => { + const result = findLineBounds([], /\r?\n/, 'some text'); + + expect(either.isRight(result)).toBe(true); + assert(either.isRight(result)); + expect(result.right).toStrictEqual([]); + }); + it('should return first non existent lineIdx', () => { + const result = findLineBounds( + [0, 30, 300], + /\r?\n/, + `some text + +`, + ); + + expect(either.isLeft(result)).toBe(true); + assert(either.isLeft(result)); + expect(result.left.firstNonExistentLineIdx).toBe(30); + expect(result.left.existingBounds).toStrictEqual([ + { start: 0, length: 10 }, + ]); + }); + it('should return the entire string if there is no newline', () => { + const result = findLineBounds( + [0, 1], + /\r?\n/, + 'some text without a newline', + ); + + expect(either.isLeft(result)).toBe(true); + assert(either.isLeft(result)); + expect(result.left.firstNonExistentLineIdx).toBe(1); + expect(result.left.existingBounds).toStrictEqual([ + { start: 0, length: 27 }, + ]); + }); + it('should correctly map multiple indices', () => { + const result = findLineBounds( + [0, 1, 2, 3], + /\r?\n/, + `some +text with +newlines +`, + ); + + expect(either.isLeft(result)).toBe(true); + assert(either.isLeft(result)); + expect(result.left.firstNonExistentLineIdx).toBe(3); + expect(result.left.existingBounds).toStrictEqual([ + { start: 0, length: 5 }, + { start: 5, length: 11 }, + { start: 16, length: 9 }, + ]); + }); + it('should throw an error on out of order indices', () => { + expect(() => findLineBounds([1, 0], /\r?\n/, '')).toThrowError( + AssertionError, + ); + }); + }); +}); diff --git a/libs/execution/src/lib/util/string-util.ts b/libs/execution/src/lib/util/string-util.ts new file mode 100644 index 000000000..b9a78ae38 --- /dev/null +++ b/libs/execution/src/lib/util/string-util.ts @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: 2025 Friedrich-Alexander-Universitat Erlangen-Nurnberg +// +// SPDX-License-Identifier: AGPL-3.0-only + +// eslint-disable-next-line unicorn/prefer-node-protocol +import assert from 'assert'; + +import { either } from 'fp-ts'; + +export function ensureGlobal(regex: RegExp): RegExp { + if (regex.global) { + return regex; + } + + return RegExp(regex.source, regex.flags + 'g'); +} + +function isSortedAscending(numbers: number[]): boolean { + return numbers.every((lineIdx, i, arr) => { + if (i === 0) { + return true; + } + const prev = arr[i - 1]; + assert(prev !== undefined); + return prev <= lineIdx; + }); +} + +function findSingleLineBounds( + searchIdx: number, + lineBreakPattern: RegExp, + text: string, +): { start: number; length: number } | undefined { + let currentLineIdx = 0; + let currentLineStart = 0; + + for (const lineBreak of text.matchAll(ensureGlobal(lineBreakPattern))) { + assert(currentLineIdx <= searchIdx); + if (currentLineIdx < searchIdx) { + currentLineIdx += 1; + currentLineStart += lineBreak.index + 1; + continue; + } + + const lineLengthWithoutNewline = lineBreak.index - currentLineStart; + return { + start: currentLineStart, + length: lineLengthWithoutNewline + 1, + }; + } + + // HINT: Line with idx `lineIdx` not found. + if (currentLineIdx !== searchIdx) { + return undefined; + } + return { + start: currentLineStart, + length: text.length - currentLineStart, + }; +} + +type Bounds = { start: number; length: number }[]; + +/** + * Map line idxs to line bounds. + * + * @param lineIdxs the indices of the lines to find bounds for. MUST be sorted in ASCENDING order. + * @param lineBreakPattern the pattern that marks a new line. + * @param text the text containing newlines. + * @returns a new array which contains either the bounds for the requested line or undefined + * + * @example + * let [{start, length}, outOfBounds ] = findLineBounds("some\ntext\n", /\r?\n/, [0, 300]); + * assert(inclusiveStart === 0); + * assert(length === 5); + * assert(outOfBounds === undefined); + */ +export function findLineBounds( + lineIdxs: number[], + lineBreakPattern: RegExp, + text: string, +): either.Either< + { existingBounds: Bounds; firstNonExistentLineIdx: number }, + Bounds +> { + assert(isSortedAscending(lineIdxs)); + let lineIdxOffset = 0; + let charIdxOffset = 0; + + const bounds: { start: number; length: number }[] = []; + + for (const searchIdx of lineIdxs) { + if (searchIdx > 0 && text.length === 0) { + return either.left({ + existingBounds: bounds, + firstNonExistentLineIdx: searchIdx, + }); + } + assert(searchIdx >= lineIdxOffset); + const tmp = findSingleLineBounds( + searchIdx - lineIdxOffset, + lineBreakPattern, + text, + ); + if (tmp === undefined) { + return either.left({ + existingBounds: bounds, + firstNonExistentLineIdx: searchIdx, + }); + } + + const { start, length } = tmp; + + bounds.push({ + start: charIdxOffset + start, + length, + }); + + charIdxOffset += start + length; + lineIdxOffset = searchIdx + 1; + text = text.slice(length); + } + + return either.right(bounds); +} diff --git a/libs/extensions/std/exec/src/text-line-deleter-executor.spec.ts b/libs/extensions/std/exec/src/text-line-deleter-executor.spec.ts index 12bbd5f6e..bff0fa582 100644 --- a/libs/extensions/std/exec/src/text-line-deleter-executor.spec.ts +++ b/libs/extensions/std/exec/src/text-line-deleter-executor.spec.ts @@ -125,7 +125,7 @@ Test File expect(R.isOk(result)).toEqual(false); if (R.isErr(result)) { expect(result.left.message).toEqual( - 'Line 1 does not exist in the text file, only 0 line(s) are present', + 'Line 1 does not exist in the text file.', ); } }); diff --git a/libs/extensions/std/exec/src/text-line-deleter-executor.ts b/libs/extensions/std/exec/src/text-line-deleter-executor.ts index 8ba1ba952..34f220b42 100644 --- a/libs/extensions/std/exec/src/text-line-deleter-executor.ts +++ b/libs/extensions/std/exec/src/text-line-deleter-executor.ts @@ -2,6 +2,9 @@ // // SPDX-License-Identifier: AGPL-3.0-only +// eslint-disable-next-line unicorn/prefer-node-protocol +import assert from 'assert'; + import * as R from '@jvalue/jayvee-execution'; import { AbstractBlockExecutor, @@ -11,40 +14,7 @@ import { implementsStatic, } from '@jvalue/jayvee-execution'; import { IOType } from '@jvalue/jayvee-language-server'; - -// eslint-disable-next-line @typescript-eslint/require-await -async function deleteLines( - lines: string[], - deleteIdxs: number[], - context: ExecutionContext, -): Promise> { - let lineIdx = 0; - for (const deleteIdx of deleteIdxs) { - if (deleteIdx > lines.length) { - return R.err({ - message: `Line ${deleteIdx} does not exist in the text file, only ${lines.length} line(s) are present`, - diagnostic: { - node: context.getOrFailProperty('lines').value, - property: 'values', - index: lineIdx, - }, - }); - } - ++lineIdx; - } - - const distinctLines = new Set(deleteIdxs); - const sortedLines = [...distinctLines].sort((a, b) => a - b); - - context.logger.logDebug(`Deleting line(s) ${sortedLines.join(', ')}`); - - const reversedLines = sortedLines.reverse(); - for (const lineToDelete of reversedLines) { - lines.splice(lineToDelete - 1, 1); - } - - return R.ok(lines); -} +import { either } from 'fp-ts'; @implementsStatic() export class TextLineDeleterExecutor extends AbstractBlockExecutor< @@ -57,23 +27,68 @@ export class TextLineDeleterExecutor extends AbstractBlockExecutor< super(IOType.TEXT_FILE, IOType.TEXT_FILE); } + // eslint-disable-next-line @typescript-eslint/require-await async doExecute( file: TextFile, context: ExecutionContext, ): Promise> { - const deleteIdxs = context.getPropertyValue( + const lineIdxs = context.getPropertyValue( 'lines', context.valueTypeProvider.createCollectionValueTypeOf( context.valueTypeProvider.Primitives.Integer, ), ); + if (lineIdxs[0] !== undefined && file.content === '') { + return R.err({ + message: `Line ${lineIdxs[0]} does not exist in the text file.`, + diagnostic: { + node: context.getOrFailProperty('lines').value, + property: 'values', + index: 0, + }, + }); + } + const lineBreakPattern = context.getPropertyValue( 'lineBreak', context.valueTypeProvider.Primitives.Regex, ); - return R.transformTextFileLines(file, lineBreakPattern, (lines) => - deleteLines(lines, deleteIdxs, context), + const distinctLines = new Set(lineIdxs); + const sortedLines = [...distinctLines].sort((a, b) => a - b); + + context.logger.logDebug(`Deleting line(s) ${sortedLines.join(', ')}`); + + const result = R.findLineBounds( + sortedLines.map((lineIdx) => { + assert(lineIdx > 0); + return lineIdx - 1; + }), + lineBreakPattern, + file.content, + ); + + if (either.isLeft(result)) { + return R.err({ + message: `Line ${result.left.firstNonExistentLineIdx} does not exist in the text file.`, + diagnostic: { + node: context.getOrFailProperty('lines').value, + property: 'values', + index: lineIdxs.indexOf(result.left.firstNonExistentLineIdx), + }, + }); + } + + let remainingOldContent = file.content; + let newContent = ''; + for (const { start, length } of result.right) { + newContent += remainingOldContent.substring(0, start); + remainingOldContent = remainingOldContent.substring(length); + } + newContent += remainingOldContent; + + return R.ok( + new TextFile(file.name, file.extension, file.mimeType, newContent), ); } } diff --git a/libs/extensions/std/exec/src/text-range-selector-executor.ts b/libs/extensions/std/exec/src/text-range-selector-executor.ts index de5bb9261..c017de794 100644 --- a/libs/extensions/std/exec/src/text-range-selector-executor.ts +++ b/libs/extensions/std/exec/src/text-range-selector-executor.ts @@ -2,6 +2,9 @@ // // SPDX-License-Identifier: AGPL-3.0-only +// eslint-disable-next-line unicorn/prefer-node-protocol +import assert from 'assert'; + import * as R from '@jvalue/jayvee-execution'; import { AbstractBlockExecutor, @@ -11,6 +14,7 @@ import { implementsStatic, } from '@jvalue/jayvee-execution'; import { IOType } from '@jvalue/jayvee-language-server'; +import { either } from 'fp-ts'; @implementsStatic() export class TextRangeSelectorExecutor extends AbstractBlockExecutor< @@ -23,6 +27,7 @@ export class TextRangeSelectorExecutor extends AbstractBlockExecutor< super(IOType.TEXT_FILE, IOType.TEXT_FILE); } + // eslint-disable-next-line @typescript-eslint/require-await async doExecute( file: TextFile, context: ExecutionContext, @@ -40,16 +45,48 @@ export class TextRangeSelectorExecutor extends AbstractBlockExecutor< context.valueTypeProvider.Primitives.Regex, ); - // eslint-disable-next-line @typescript-eslint/require-await - return R.transformTextFileLines(file, lineBreakPattern, async (lines) => { - context.logger.logDebug( - `Selecting lines from ${lineFrom} to ${ - lineTo === Number.MAX_SAFE_INTEGER || lineTo >= lines.length - ? 'the end' - : `${lineTo}` - }`, - ); - return R.ok(lines.slice(lineFrom - 1, lineTo)); - }); + context.logger.logDebug( + `Selecting lines from ${lineFrom} to ${ + lineTo === Number.MAX_SAFE_INTEGER ? 'the end' : `${lineTo}` + }`, + ); + + const result = R.findLineBounds( + [lineFrom - 1, lineTo], + lineBreakPattern, + file.content, + ); + + let start: number | undefined = undefined; + let end: number | undefined = undefined; + if (either.isLeft(result)) { + switch (result.left.firstNonExistentLineIdx) { + case lineFrom - 1: { + break; + } + case lineTo: + start = result.left.existingBounds[0]?.start; + assert(start !== undefined); + end = file.content.length; + break; + default: + assert(false, 'Unreachable'); + } + } else { + const [boundFrom, boundTo] = result.right; + assert(boundFrom !== undefined); + assert(boundTo !== undefined); + start = boundFrom.start; + end = boundTo.start; + } + + const newContent = + start !== undefined && end !== undefined + ? file.content.substring(start, end) + : ''; + + return R.ok( + new TextFile(file.name, file.extension, file.mimeType, newContent), + ); } }