From 3ea7eb8425a1f45fdf1c3a5e46f5afceed989381 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 20:10:28 +0000 Subject: [PATCH 1/6] Initial plan From 4cd25ab2a674eeb0bf9fd4ece74d39ca7685446d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 20:23:18 +0000 Subject: [PATCH 2/6] Refactor Text class to module namespace object pattern Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> --- common/reviews/api/node-core-library.api.md | 89 ++++-- libraries/node-core-library/src/Text.ts | 292 +----------------- .../node-core-library/src/test/Text.test.ts | 14 +- .../node-core-library/src/text/convertTo.ts | 14 + .../src/text/convertToCrLf.ts | 12 + .../node-core-library/src/text/convertToLf.ts | 14 + .../src/text/ensureTrailingNewline.ts | 18 ++ .../src/text/escapeRegExp.ts | 10 + .../node-core-library/src/text/getNewline.ts | 45 +++ libraries/node-core-library/src/text/index.ts | 21 ++ .../node-core-library/src/text/padEnd.ts | 23 ++ .../node-core-library/src/text/padStart.ts | 23 ++ .../src/text/readLinesFromIterable.ts | 104 +++++++ .../node-core-library/src/text/replaceAll.ts | 14 + .../node-core-library/src/text/reverse.ts | 11 + .../src/text/splitByNewLines.ts | 24 ++ .../src/text/truncateWithEllipsis.ts | 26 ++ 17 files changed, 438 insertions(+), 316 deletions(-) create mode 100644 libraries/node-core-library/src/text/convertTo.ts create mode 100644 libraries/node-core-library/src/text/convertToCrLf.ts create mode 100644 libraries/node-core-library/src/text/convertToLf.ts create mode 100644 libraries/node-core-library/src/text/ensureTrailingNewline.ts create mode 100644 libraries/node-core-library/src/text/escapeRegExp.ts create mode 100644 libraries/node-core-library/src/text/getNewline.ts create mode 100644 libraries/node-core-library/src/text/index.ts create mode 100644 libraries/node-core-library/src/text/padEnd.ts create mode 100644 libraries/node-core-library/src/text/padStart.ts create mode 100644 libraries/node-core-library/src/text/readLinesFromIterable.ts create mode 100644 libraries/node-core-library/src/text/replaceAll.ts create mode 100644 libraries/node-core-library/src/text/reverse.ts create mode 100644 libraries/node-core-library/src/text/splitByNewLines.ts create mode 100644 libraries/node-core-library/src/text/truncateWithEllipsis.ts diff --git a/common/reviews/api/node-core-library.api.md b/common/reviews/api/node-core-library.api.md index cc01c92c873..a753b14acd0 100644 --- a/common/reviews/api/node-core-library.api.md +++ b/common/reviews/api/node-core-library.api.md @@ -61,6 +61,15 @@ export type Brand = T & { __brand: BrandTag; }; +// @public +function convertTo(input: string, newlineKind: NewlineKind): string; + +// @public +function convertToCrLf(input: string): string; + +// @public +function convertToLf(input: string): string; + declare namespace Disposables { export { polyfillDisposeSymbols @@ -74,6 +83,9 @@ export enum Encoding { Utf8 = "utf8" } +// @public +function ensureTrailingNewline(s: string, newlineKind?: NewlineKind): string; + // @public export class Enum { static getKeyByNumber; @@ -249,6 +264,9 @@ export type FolderItem = fs.Dirent; // @public function getHomeFolder(): string; +// @public +function getNewline(newlineKind: NewlineKind): string; + // @public export interface IAsyncParallelismOptions { allowOversubscription?: boolean; @@ -848,6 +866,12 @@ export class PackageNameParser { validate(packageName: string): void; } +// @public +function padEnd(s: string, minimumLength: number, paddingCharacter?: string): string; + +// @public +function padStart(s: string, minimumLength: number, paddingCharacter?: string): string; + // @public export class Path { static convertToBackslashes(inputPath: string): string; @@ -894,6 +918,12 @@ export class ProtectableMap { get size(): number; } +// @public +function readLinesFromIterable(iterable: Iterable, options?: IReadLinesFromIterableOptions): Generator; + +// @public +function readLinesFromIterableAsync(iterable: AsyncIterable, options?: IReadLinesFromIterableOptions): AsyncGenerator; + // @public export class RealNodeModulePathResolver { constructor(options?: IRealNodeModulePathResolverOptions); @@ -901,6 +931,12 @@ export class RealNodeModulePathResolver { readonly realNodeModulePath: (input: string) => string; } +// @public +function replaceAll(input: string, searchValue: string, replaceValue: string): string; + +// @public +function reverse(s: string): string; + // @public export class Sort { static compareByValue(x: any, y: any): number; @@ -913,6 +949,15 @@ export class Sort { static sortSetBy(set: Set, keySelector: (element: T) => any, keyComparer?: (x: T, y: T) => number): void; } +// @public +function splitByNewLines(s: undefined): undefined; + +// @public +function splitByNewLines(s: string): string[]; + +// @public +function splitByNewLines(s: string | undefined): string[] | undefined; + // @public export class StringBuilder implements IStringBuilder { constructor(); @@ -927,27 +972,31 @@ export class SubprocessTerminator { static readonly RECOMMENDED_OPTIONS: ISubprocessOptions; } -// @public -export class Text { - static convertTo(input: string, newlineKind: NewlineKind): string; - static convertToCrLf(input: string): string; - static convertToLf(input: string): string; - static ensureTrailingNewline(s: string, newlineKind?: NewlineKind): string; - static escapeRegExp(literal: string): string; - static getNewline(newlineKind: NewlineKind): string; - static padEnd(s: string, minimumLength: number, paddingCharacter?: string): string; - static padStart(s: string, minimumLength: number, paddingCharacter?: string): string; - static readLinesFromIterable(iterable: Iterable, options?: IReadLinesFromIterableOptions): Generator; - static readLinesFromIterableAsync(iterable: AsyncIterable, options?: IReadLinesFromIterableOptions): AsyncGenerator; - static replaceAll(input: string, searchValue: string, replaceValue: string): string; - static reverse(s: string): string; - static splitByNewLines(s: undefined): undefined; - // (undocumented) - static splitByNewLines(s: string): string[]; - // (undocumented) - static splitByNewLines(s: string | undefined): string[] | undefined; - static truncateWithEllipsis(s: string, maximumLength: number): string; +declare namespace Text { + export { + replaceAll, + convertToCrLf, + convertToLf, + convertTo, + NewlineKind, + getNewline, + padEnd, + padStart, + truncateWithEllipsis, + ensureTrailingNewline, + escapeRegExp, + Encoding, + IReadLinesFromIterableOptions, + readLinesFromIterableAsync, + readLinesFromIterable, + reverse, + splitByNewLines + } } +export { Text } + +// @public +function truncateWithEllipsis(s: string, maximumLength: number): string; // @public export class TypeUuid { diff --git a/libraries/node-core-library/src/Text.ts b/libraries/node-core-library/src/Text.ts index 4630a9328d8..113d8de02e7 100644 --- a/libraries/node-core-library/src/Text.ts +++ b/libraries/node-core-library/src/Text.ts @@ -1,293 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import * as os from 'node:os'; +import * as Text from './text/index'; -/** - * The allowed types of encodings, as supported by Node.js - * @public - */ -export enum Encoding { - Utf8 = 'utf8' -} - -/** - * Enumeration controlling conversion of newline characters. - * @public - */ -export enum NewlineKind { - /** - * Windows-style newlines - */ - CrLf = '\r\n', - - /** - * POSIX-style newlines - * - * @remarks - * POSIX is a registered trademark of the Institute of Electrical and Electronic Engineers, Inc. - */ - Lf = '\n', - - /** - * Default newline type for this operating system (`os.EOL`). - */ - OsDefault = 'os' -} - -/** - * Options used when calling the {@link Text.readLinesFromIterable} or - * {@link Text.readLinesFromIterableAsync} methods. - * - * @public - */ -export interface IReadLinesFromIterableOptions { - /** - * The encoding of the input iterable. The default is utf8. - */ - encoding?: Encoding; - - /** - * If true, empty lines will not be returned. The default is false. - */ - ignoreEmptyLines?: boolean; -} - -interface IReadLinesFromIterableState { - remaining: string; -} - -const NEWLINE_REGEX: RegExp = /\r\n|\n\r|\r|\n/g; -const NEWLINE_AT_END_REGEX: RegExp = /(\r\n|\n\r|\r|\n)$/; - -function* readLinesFromChunk( - // eslint-disable-next-line @rushstack/no-new-null - chunk: string | Buffer | null, - encoding: Encoding, - ignoreEmptyLines: boolean, - state: IReadLinesFromIterableState -): Generator { - if (!chunk) { - return; - } - const remaining: string = state.remaining + (typeof chunk === 'string' ? chunk : chunk.toString(encoding)); - let startIndex: number = 0; - const matches: IterableIterator = remaining.matchAll(NEWLINE_REGEX); - for (const match of matches) { - const endIndex: number = match.index!; - if (startIndex !== endIndex || !ignoreEmptyLines) { - yield remaining.substring(startIndex, endIndex); - } - startIndex = endIndex + match[0].length; - } - state.remaining = remaining.substring(startIndex); -} - -/** - * Operations for working with strings that contain text. - * - * @remarks - * The utilities provided by this class are intended to be simple, small, and very - * broadly applicable. - * - * @public - */ -export class Text { - private static readonly _newLineRegEx: RegExp = NEWLINE_REGEX; - private static readonly _newLineAtEndRegEx: RegExp = NEWLINE_AT_END_REGEX; - - /** - * Returns the same thing as targetString.replace(searchValue, replaceValue), except that - * all matches are replaced, rather than just the first match. - * @param input - The string to be modified - * @param searchValue - The value to search for - * @param replaceValue - The replacement text - */ - public static replaceAll(input: string, searchValue: string, replaceValue: string): string { - return input.split(searchValue).join(replaceValue); - } - - /** - * Converts all newlines in the provided string to use Windows-style CRLF end of line characters. - */ - public static convertToCrLf(input: string): string { - return input.replace(Text._newLineRegEx, '\r\n'); - } - - /** - * Converts all newlines in the provided string to use POSIX-style LF end of line characters. - * - * POSIX is a registered trademark of the Institute of Electrical and Electronic Engineers, Inc. - */ - public static convertToLf(input: string): string { - return input.replace(Text._newLineRegEx, '\n'); - } - - /** - * Converts all newlines in the provided string to use the specified newline type. - */ - public static convertTo(input: string, newlineKind: NewlineKind): string { - return input.replace(Text._newLineRegEx, Text.getNewline(newlineKind)); - } - - /** - * Returns the newline character sequence for the specified `NewlineKind`. - */ - public static getNewline(newlineKind: NewlineKind): string { - switch (newlineKind) { - case NewlineKind.CrLf: - return '\r\n'; - case NewlineKind.Lf: - return '\n'; - case NewlineKind.OsDefault: - return os.EOL; - default: - throw new Error('Unsupported newline kind'); - } - } - - /** - * Append characters to the end of a string to ensure the result has a minimum length. - * @remarks - * If the string length already exceeds the minimum length, then the string is unchanged. - * The string is not truncated. - */ - public static padEnd(s: string, minimumLength: number, paddingCharacter: string = ' '): string { - if (paddingCharacter.length !== 1) { - throw new Error('The paddingCharacter parameter must be a single character.'); - } - - if (s.length < minimumLength) { - const paddingArray: string[] = new Array(minimumLength - s.length); - paddingArray.unshift(s); - return paddingArray.join(paddingCharacter); - } else { - return s; - } - } - - /** - * Append characters to the start of a string to ensure the result has a minimum length. - * @remarks - * If the string length already exceeds the minimum length, then the string is unchanged. - * The string is not truncated. - */ - public static padStart(s: string, minimumLength: number, paddingCharacter: string = ' '): string { - if (paddingCharacter.length !== 1) { - throw new Error('The paddingCharacter parameter must be a single character.'); - } - - if (s.length < minimumLength) { - const paddingArray: string[] = new Array(minimumLength - s.length); - paddingArray.push(s); - return paddingArray.join(paddingCharacter); - } else { - return s; - } - } - - /** - * If the string is longer than maximumLength characters, truncate it to that length - * using "..." to indicate the truncation. - * - * @remarks - * For example truncateWithEllipsis('1234578', 5) would produce '12...'. - */ - public static truncateWithEllipsis(s: string, maximumLength: number): string { - if (maximumLength < 0) { - throw new Error('The maximumLength cannot be a negative number'); - } - - if (s.length <= maximumLength) { - return s; - } - - if (s.length <= 3) { - return s.substring(0, maximumLength); - } - - return s.substring(0, maximumLength - 3) + '...'; - } - - /** - * Returns the input string with a trailing `\n` character appended, if not already present. - */ - public static ensureTrailingNewline(s: string, newlineKind: NewlineKind = NewlineKind.Lf): string { - // Is there already a newline? - if (Text._newLineAtEndRegEx.test(s)) { - return s; // yes, no change - } - return s + newlineKind; // no, add it - } - - /** - * Escapes a string so that it can be treated as a literal string when used in a regular expression. - */ - public static escapeRegExp(literal: string): string { - return literal.replace(/[^A-Za-z0-9_]/g, '\\$&'); - } - - /** - * Read lines from an iterable object that returns strings or buffers, and return a generator that - * produces the lines as strings. The lines will not include the newline characters. - * - * @param iterable - An iterable object that returns strings or buffers - * @param options - Options used when reading the lines from the provided iterable - */ - public static async *readLinesFromIterableAsync( - iterable: AsyncIterable, - options: IReadLinesFromIterableOptions = {} - ): AsyncGenerator { - const { encoding = Encoding.Utf8, ignoreEmptyLines = false } = options; - const state: IReadLinesFromIterableState = { remaining: '' }; - for await (const chunk of iterable) { - yield* readLinesFromChunk(chunk, encoding, ignoreEmptyLines, state); - } - const remaining: string = state.remaining; - if (remaining.length) { - yield remaining; - } - } - - /** - * Read lines from an iterable object that returns strings or buffers, and return a generator that - * produces the lines as strings. The lines will not include the newline characters. - * - * @param iterable - An iterable object that returns strings or buffers - * @param options - Options used when reading the lines from the provided iterable - */ - public static *readLinesFromIterable( - // eslint-disable-next-line @rushstack/no-new-null - iterable: Iterable, - options: IReadLinesFromIterableOptions = {} - ): Generator { - const { encoding = Encoding.Utf8, ignoreEmptyLines = false } = options; - const state: IReadLinesFromIterableState = { remaining: '' }; - for (const chunk of iterable) { - yield* readLinesFromChunk(chunk, encoding, ignoreEmptyLines, state); - } - const remaining: string = state.remaining; - if (remaining.length) { - yield remaining; - } - } - - /** - * Returns a new string that is the input string with the order of characters reversed. - */ - public static reverse(s: string): string { - // Benchmarks of several algorithms: https://jsbench.me/4bkfflcm2z - return s.split('').reduce((newString, char) => char + newString, ''); - } - - /** - * Splits the provided string by newlines. Note that leading and trailing newlines will produce - * leading or trailing empty string array entries. - */ - public static splitByNewLines(s: undefined): undefined; - public static splitByNewLines(s: string): string[]; - public static splitByNewLines(s: string | undefined): string[] | undefined; - public static splitByNewLines(s: string | undefined): string[] | undefined { - return s?.split(/\r?\n/); - } -} +export { Text }; +export { Encoding, NewlineKind, type IReadLinesFromIterableOptions } from './text/index'; diff --git a/libraries/node-core-library/src/test/Text.test.ts b/libraries/node-core-library/src/test/Text.test.ts index a4567b25f98..096fd993de3 100644 --- a/libraries/node-core-library/src/test/Text.test.ts +++ b/libraries/node-core-library/src/test/Text.test.ts @@ -3,8 +3,8 @@ import { Text } from '../Text'; -describe(Text.name, () => { - describe(Text.padEnd.name, () => { +describe('Text', () => { + describe('padEnd', () => { it("Throws an exception if the padding character isn't a single character", () => { expect(() => Text.padEnd('123', 1, '')).toThrow(); expect(() => Text.padEnd('123', 1, ' ')).toThrow(); @@ -28,7 +28,7 @@ describe(Text.name, () => { }); }); - describe(Text.padStart.name, () => { + describe('padStart', () => { it("Throws an exception if the padding character isn't a single character", () => { expect(() => Text.padStart('123', 1, '')).toThrow(); expect(() => Text.padStart('123', 1, ' ')).toThrow(); @@ -52,7 +52,7 @@ describe(Text.name, () => { }); }); - describe(Text.truncateWithEllipsis.name, () => { + describe('truncateWithEllipsis', () => { it('Throws an exception if the maximum length is less than zero', () => { expect(() => Text.truncateWithEllipsis('123', -1)).toThrow(); }); @@ -74,7 +74,7 @@ describe(Text.name, () => { }); }); - describe(Text.convertToLf.name, () => { + describe('convertToLf', () => { it('degenerate adjacent newlines', () => { expect(Text.convertToLf('')).toEqual(''); expect(Text.convertToLf('\n')).toEqual('\n'); @@ -99,7 +99,7 @@ describe(Text.name, () => { }); }); - describe(Text.escapeRegExp.name, () => { + describe('escapeRegExp', () => { it('escapes special characters', () => { expect(Text.escapeRegExp('')).toEqual(''); expect(Text.escapeRegExp('abc')).toEqual('abc'); @@ -120,7 +120,7 @@ describe(Text.name, () => { }); }); - describe(Text.splitByNewLines.name, () => { + describe('splitByNewLines', () => { it('splits a string by newlines', () => { expect(Text.splitByNewLines(undefined)).toEqual(undefined); expect(Text.splitByNewLines('')).toEqual(['']); diff --git a/libraries/node-core-library/src/text/convertTo.ts b/libraries/node-core-library/src/text/convertTo.ts new file mode 100644 index 00000000000..124717f55eb --- /dev/null +++ b/libraries/node-core-library/src/text/convertTo.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { type NewlineKind, getNewline } from './getNewline'; + +const NEWLINE_REGEX: RegExp = /\r\n|\n\r|\r|\n/g; + +/** + * Converts all newlines in the provided string to use the specified newline type. + * @public + */ +export function convertTo(input: string, newlineKind: NewlineKind): string { + return input.replace(NEWLINE_REGEX, getNewline(newlineKind)); +} diff --git a/libraries/node-core-library/src/text/convertToCrLf.ts b/libraries/node-core-library/src/text/convertToCrLf.ts new file mode 100644 index 00000000000..e2dcc32294c --- /dev/null +++ b/libraries/node-core-library/src/text/convertToCrLf.ts @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const NEWLINE_REGEX: RegExp = /\r\n|\n\r|\r|\n/g; + +/** + * Converts all newlines in the provided string to use Windows-style CRLF end of line characters. + * @public + */ +export function convertToCrLf(input: string): string { + return input.replace(NEWLINE_REGEX, '\r\n'); +} diff --git a/libraries/node-core-library/src/text/convertToLf.ts b/libraries/node-core-library/src/text/convertToLf.ts new file mode 100644 index 00000000000..53e18214155 --- /dev/null +++ b/libraries/node-core-library/src/text/convertToLf.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const NEWLINE_REGEX: RegExp = /\r\n|\n\r|\r|\n/g; + +/** + * Converts all newlines in the provided string to use POSIX-style LF end of line characters. + * + * POSIX is a registered trademark of the Institute of Electrical and Electronic Engineers, Inc. + * @public + */ +export function convertToLf(input: string): string { + return input.replace(NEWLINE_REGEX, '\n'); +} diff --git a/libraries/node-core-library/src/text/ensureTrailingNewline.ts b/libraries/node-core-library/src/text/ensureTrailingNewline.ts new file mode 100644 index 00000000000..7e31e231e83 --- /dev/null +++ b/libraries/node-core-library/src/text/ensureTrailingNewline.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { NewlineKind } from './getNewline'; + +const NEWLINE_AT_END_REGEX: RegExp = /(\r\n|\n\r|\r|\n)$/; + +/** + * Returns the input string with a trailing `\n` character appended, if not already present. + * @public + */ +export function ensureTrailingNewline(s: string, newlineKind: NewlineKind = NewlineKind.Lf): string { + // Is there already a newline? + if (NEWLINE_AT_END_REGEX.test(s)) { + return s; // yes, no change + } + return s + newlineKind; // no, add it +} diff --git a/libraries/node-core-library/src/text/escapeRegExp.ts b/libraries/node-core-library/src/text/escapeRegExp.ts new file mode 100644 index 00000000000..d2d49d814e0 --- /dev/null +++ b/libraries/node-core-library/src/text/escapeRegExp.ts @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * Escapes a string so that it can be treated as a literal string when used in a regular expression. + * @public + */ +export function escapeRegExp(literal: string): string { + return literal.replace(/[^A-Za-z0-9_]/g, '\\$&'); +} diff --git a/libraries/node-core-library/src/text/getNewline.ts b/libraries/node-core-library/src/text/getNewline.ts new file mode 100644 index 00000000000..606ae5d6080 --- /dev/null +++ b/libraries/node-core-library/src/text/getNewline.ts @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as os from 'node:os'; + +/** + * Enumeration controlling conversion of newline characters. + * @public + */ +export enum NewlineKind { + /** + * Windows-style newlines + */ + CrLf = '\r\n', + + /** + * POSIX-style newlines + * + * @remarks + * POSIX is a registered trademark of the Institute of Electrical and Electronic Engineers, Inc. + */ + Lf = '\n', + + /** + * Default newline type for this operating system (`os.EOL`). + */ + OsDefault = 'os' +} + +/** + * Returns the newline character sequence for the specified `NewlineKind`. + * @public + */ +export function getNewline(newlineKind: NewlineKind): string { + switch (newlineKind) { + case NewlineKind.CrLf: + return '\r\n'; + case NewlineKind.Lf: + return '\n'; + case NewlineKind.OsDefault: + return os.EOL; + default: + throw new Error('Unsupported newline kind'); + } +} diff --git a/libraries/node-core-library/src/text/index.ts b/libraries/node-core-library/src/text/index.ts new file mode 100644 index 00000000000..14d78a4b50d --- /dev/null +++ b/libraries/node-core-library/src/text/index.ts @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +export { replaceAll } from './replaceAll'; +export { convertToCrLf } from './convertToCrLf'; +export { convertToLf } from './convertToLf'; +export { convertTo } from './convertTo'; +export { NewlineKind, getNewline } from './getNewline'; +export { padEnd } from './padEnd'; +export { padStart } from './padStart'; +export { truncateWithEllipsis } from './truncateWithEllipsis'; +export { ensureTrailingNewline } from './ensureTrailingNewline'; +export { escapeRegExp } from './escapeRegExp'; +export { + Encoding, + type IReadLinesFromIterableOptions, + readLinesFromIterableAsync, + readLinesFromIterable +} from './readLinesFromIterable'; +export { reverse } from './reverse'; +export { splitByNewLines } from './splitByNewLines'; diff --git a/libraries/node-core-library/src/text/padEnd.ts b/libraries/node-core-library/src/text/padEnd.ts new file mode 100644 index 00000000000..fa07fd9ad1d --- /dev/null +++ b/libraries/node-core-library/src/text/padEnd.ts @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * Append characters to the end of a string to ensure the result has a minimum length. + * @remarks + * If the string length already exceeds the minimum length, then the string is unchanged. + * The string is not truncated. + * @public + */ +export function padEnd(s: string, minimumLength: number, paddingCharacter: string = ' '): string { + if (paddingCharacter.length !== 1) { + throw new Error('The paddingCharacter parameter must be a single character.'); + } + + if (s.length < minimumLength) { + const paddingArray: string[] = new Array(minimumLength - s.length); + paddingArray.unshift(s); + return paddingArray.join(paddingCharacter); + } else { + return s; + } +} diff --git a/libraries/node-core-library/src/text/padStart.ts b/libraries/node-core-library/src/text/padStart.ts new file mode 100644 index 00000000000..13df8c744b7 --- /dev/null +++ b/libraries/node-core-library/src/text/padStart.ts @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * Append characters to the start of a string to ensure the result has a minimum length. + * @remarks + * If the string length already exceeds the minimum length, then the string is unchanged. + * The string is not truncated. + * @public + */ +export function padStart(s: string, minimumLength: number, paddingCharacter: string = ' '): string { + if (paddingCharacter.length !== 1) { + throw new Error('The paddingCharacter parameter must be a single character.'); + } + + if (s.length < minimumLength) { + const paddingArray: string[] = new Array(minimumLength - s.length); + paddingArray.push(s); + return paddingArray.join(paddingCharacter); + } else { + return s; + } +} diff --git a/libraries/node-core-library/src/text/readLinesFromIterable.ts b/libraries/node-core-library/src/text/readLinesFromIterable.ts new file mode 100644 index 00000000000..ec9233ee48c --- /dev/null +++ b/libraries/node-core-library/src/text/readLinesFromIterable.ts @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * The allowed types of encodings, as supported by Node.js + * @public + */ +export enum Encoding { + Utf8 = 'utf8' +} + +/** + * Options used when calling the readLinesFromIterable or + * readLinesFromIterableAsync methods. + * + * @public + */ +export interface IReadLinesFromIterableOptions { + /** + * The encoding of the input iterable. The default is utf8. + */ + encoding?: Encoding; + + /** + * If true, empty lines will not be returned. The default is false. + */ + ignoreEmptyLines?: boolean; +} + +interface IReadLinesFromIterableState { + remaining: string; +} + +const NEWLINE_REGEX: RegExp = /\r\n|\n\r|\r|\n/g; + +function* readLinesFromChunk( + // eslint-disable-next-line @rushstack/no-new-null + chunk: string | Buffer | null, + encoding: Encoding, + ignoreEmptyLines: boolean, + state: IReadLinesFromIterableState +): Generator { + if (!chunk) { + return; + } + const remaining: string = state.remaining + (typeof chunk === 'string' ? chunk : chunk.toString(encoding)); + let startIndex: number = 0; + const matches: IterableIterator = remaining.matchAll(NEWLINE_REGEX); + for (const match of matches) { + const endIndex: number = match.index!; + if (startIndex !== endIndex || !ignoreEmptyLines) { + yield remaining.substring(startIndex, endIndex); + } + startIndex = endIndex + match[0].length; + } + state.remaining = remaining.substring(startIndex); +} + +/** + * Read lines from an iterable object that returns strings or buffers, and return a generator that + * produces the lines as strings. The lines will not include the newline characters. + * + * @param iterable - An iterable object that returns strings or buffers + * @param options - Options used when reading the lines from the provided iterable + * @public + */ +export async function* readLinesFromIterableAsync( + iterable: AsyncIterable, + options: IReadLinesFromIterableOptions = {} +): AsyncGenerator { + const { encoding = Encoding.Utf8, ignoreEmptyLines = false } = options; + const state: IReadLinesFromIterableState = { remaining: '' }; + for await (const chunk of iterable) { + yield* readLinesFromChunk(chunk, encoding, ignoreEmptyLines, state); + } + const remaining: string = state.remaining; + if (remaining.length) { + yield remaining; + } +} + +/** + * Read lines from an iterable object that returns strings or buffers, and return a generator that + * produces the lines as strings. The lines will not include the newline characters. + * + * @param iterable - An iterable object that returns strings or buffers + * @param options - Options used when reading the lines from the provided iterable + * @public + */ +export function* readLinesFromIterable( + // eslint-disable-next-line @rushstack/no-new-null + iterable: Iterable, + options: IReadLinesFromIterableOptions = {} +): Generator { + const { encoding = Encoding.Utf8, ignoreEmptyLines = false } = options; + const state: IReadLinesFromIterableState = { remaining: '' }; + for (const chunk of iterable) { + yield* readLinesFromChunk(chunk, encoding, ignoreEmptyLines, state); + } + const remaining: string = state.remaining; + if (remaining.length) { + yield remaining; + } +} diff --git a/libraries/node-core-library/src/text/replaceAll.ts b/libraries/node-core-library/src/text/replaceAll.ts new file mode 100644 index 00000000000..4d67d8ca63b --- /dev/null +++ b/libraries/node-core-library/src/text/replaceAll.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * Returns the same thing as targetString.replace(searchValue, replaceValue), except that + * all matches are replaced, rather than just the first match. + * @param input - The string to be modified + * @param searchValue - The value to search for + * @param replaceValue - The replacement text + * @public + */ +export function replaceAll(input: string, searchValue: string, replaceValue: string): string { + return input.split(searchValue).join(replaceValue); +} diff --git a/libraries/node-core-library/src/text/reverse.ts b/libraries/node-core-library/src/text/reverse.ts new file mode 100644 index 00000000000..feae0919d02 --- /dev/null +++ b/libraries/node-core-library/src/text/reverse.ts @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * Returns a new string that is the input string with the order of characters reversed. + * @public + */ +export function reverse(s: string): string { + // Benchmarks of several algorithms: https://jsbench.me/4bkfflcm2z + return s.split('').reduce((newString, char) => char + newString, ''); +} diff --git a/libraries/node-core-library/src/text/splitByNewLines.ts b/libraries/node-core-library/src/text/splitByNewLines.ts new file mode 100644 index 00000000000..61306846063 --- /dev/null +++ b/libraries/node-core-library/src/text/splitByNewLines.ts @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * Splits the provided string by newlines. Note that leading and trailing newlines will produce + * leading or trailing empty string array entries. + * @public + */ +export function splitByNewLines(s: undefined): undefined; +/** + * Splits the provided string by newlines. Note that leading and trailing newlines will produce + * leading or trailing empty string array entries. + * @public + */ +export function splitByNewLines(s: string): string[]; +/** + * Splits the provided string by newlines. Note that leading and trailing newlines will produce + * leading or trailing empty string array entries. + * @public + */ +export function splitByNewLines(s: string | undefined): string[] | undefined; +export function splitByNewLines(s: string | undefined): string[] | undefined { + return s?.split(/\r?\n/); +} diff --git a/libraries/node-core-library/src/text/truncateWithEllipsis.ts b/libraries/node-core-library/src/text/truncateWithEllipsis.ts new file mode 100644 index 00000000000..6e75bb2f341 --- /dev/null +++ b/libraries/node-core-library/src/text/truncateWithEllipsis.ts @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * If the string is longer than maximumLength characters, truncate it to that length + * using "..." to indicate the truncation. + * + * @remarks + * For example truncateWithEllipsis('1234578', 5) would produce '12...'. + * @public + */ +export function truncateWithEllipsis(s: string, maximumLength: number): string { + if (maximumLength < 0) { + throw new Error('The maximumLength cannot be a negative number'); + } + + if (s.length <= maximumLength) { + return s; + } + + if (s.length <= 3) { + return s.substring(0, maximumLength); + } + + return s.substring(0, maximumLength - 3) + '...'; +} From 99f979ed659ab24d02817fb1f611e15a353661b4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 20:30:26 +0000 Subject: [PATCH 3/6] Complete Text refactoring, document FileSystem complexity Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> --- .../src/fileSystem/errorChecks.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 libraries/node-core-library/src/fileSystem/errorChecks.ts diff --git a/libraries/node-core-library/src/fileSystem/errorChecks.ts b/libraries/node-core-library/src/fileSystem/errorChecks.ts new file mode 100644 index 00000000000..e97e9626c88 --- /dev/null +++ b/libraries/node-core-library/src/fileSystem/errorChecks.ts @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * Returns true if the error object indicates the file or folder already exists (`EEXIST`). + * @public + */ +export function isExistError(error: Error): boolean { + return isErrnoException(error) && error.code === 'EEXIST'; +} + +/** + * Returns true if the error object indicates the file or folder does not exist (`ENOENT` or `ENOTDIR`) + * @public + */ +export function isNotExistError(error: Error): boolean { + return isFileDoesNotExistError(error) || isFolderDoesNotExistError(error); +} + +/** + * Returns true if the error object indicates the file does not exist (`ENOENT`). + * @public + */ +export function isFileDoesNotExistError(error: Error): boolean { + return isErrnoException(error) && error.code === 'ENOENT'; +} + +/** + * Returns true if the error object indicates the folder does not exist (`ENOTDIR`). + * @public + */ +export function isFolderDoesNotExistError(error: Error): boolean { + return isErrnoException(error) && error.code === 'ENOTDIR'; +} + +/** + * Returns true if the error object indicates the target is a directory (`EISDIR`). + * @public + */ +export function isDirectoryError(error: Error): boolean { + return isErrnoException(error) && error.code === 'EISDIR'; +} + +/** + * Returns true if the error object indicates the target is not a directory (`ENOTDIR`). + * @public + */ +export function isNotDirectoryError(error: Error): boolean { + return isErrnoException(error) && error.code === 'ENOTDIR'; +} + +/** + * Returns true if the error object indicates that the `unlink` system call failed + * due to a permissions issue (`EPERM`). + * @public + */ +export function isUnlinkNotPermittedError(error: Error): boolean { + return isErrnoException(error) && error.code === 'EPERM' && error.syscall === 'unlink'; +} + +/** + * Detects if the provided error object is a `NodeJS.ErrnoException` + * @public + */ +export function isErrnoException(error: Error): error is NodeJS.ErrnoException { + const typedError: NodeJS.ErrnoException = error; + // Don't check for `path` because the syscall may not have a path. + // For example, when invoked with a file descriptor. + return ( + typeof typedError.code === 'string' && + typeof typedError.errno === 'number' && + typeof typedError.syscall === 'string' + ); +} From bc8bffdd8515ca06f14057d360eda68e4e20b765 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 22:31:17 +0000 Subject: [PATCH 4/6] Address PR feedback: factor out newline helpers, use Symbol.matchAll, restore function.name in tests Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> --- .../node-core-library/src/test/Text.test.ts | 12 ++++++------ .../src/text/_newlineHelpers.ts | 16 ++++++++++++++++ .../node-core-library/src/text/convertTo.ts | 5 ++--- .../node-core-library/src/text/convertToCrLf.ts | 4 ++-- .../node-core-library/src/text/convertToLf.ts | 4 ++-- .../src/text/readLinesFromIterable.ts | 6 +++--- 6 files changed, 31 insertions(+), 16 deletions(-) create mode 100644 libraries/node-core-library/src/text/_newlineHelpers.ts diff --git a/libraries/node-core-library/src/test/Text.test.ts b/libraries/node-core-library/src/test/Text.test.ts index 096fd993de3..4a45af96db3 100644 --- a/libraries/node-core-library/src/test/Text.test.ts +++ b/libraries/node-core-library/src/test/Text.test.ts @@ -4,7 +4,7 @@ import { Text } from '../Text'; describe('Text', () => { - describe('padEnd', () => { + describe(Text.padEnd.name, () => { it("Throws an exception if the padding character isn't a single character", () => { expect(() => Text.padEnd('123', 1, '')).toThrow(); expect(() => Text.padEnd('123', 1, ' ')).toThrow(); @@ -28,7 +28,7 @@ describe('Text', () => { }); }); - describe('padStart', () => { + describe(Text.padStart.name, () => { it("Throws an exception if the padding character isn't a single character", () => { expect(() => Text.padStart('123', 1, '')).toThrow(); expect(() => Text.padStart('123', 1, ' ')).toThrow(); @@ -52,7 +52,7 @@ describe('Text', () => { }); }); - describe('truncateWithEllipsis', () => { + describe(Text.truncateWithEllipsis.name, () => { it('Throws an exception if the maximum length is less than zero', () => { expect(() => Text.truncateWithEllipsis('123', -1)).toThrow(); }); @@ -74,7 +74,7 @@ describe('Text', () => { }); }); - describe('convertToLf', () => { + describe(Text.convertToLf.name, () => { it('degenerate adjacent newlines', () => { expect(Text.convertToLf('')).toEqual(''); expect(Text.convertToLf('\n')).toEqual('\n'); @@ -99,7 +99,7 @@ describe('Text', () => { }); }); - describe('escapeRegExp', () => { + describe(Text.escapeRegExp.name, () => { it('escapes special characters', () => { expect(Text.escapeRegExp('')).toEqual(''); expect(Text.escapeRegExp('abc')).toEqual('abc'); @@ -120,7 +120,7 @@ describe('Text', () => { }); }); - describe('splitByNewLines', () => { + describe(Text.splitByNewLines.name, () => { it('splits a string by newlines', () => { expect(Text.splitByNewLines(undefined)).toEqual(undefined); expect(Text.splitByNewLines('')).toEqual(['']); diff --git a/libraries/node-core-library/src/text/_newlineHelpers.ts b/libraries/node-core-library/src/text/_newlineHelpers.ts new file mode 100644 index 00000000000..f12d12f36c1 --- /dev/null +++ b/libraries/node-core-library/src/text/_newlineHelpers.ts @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * Regular expression to match all types of newline characters + * @internal + */ +export const NEWLINE_REGEX: RegExp = /\r\n|\n\r|\r|\n/g; + +/** + * Helper function to replace all newlines in a string with a specified replacement + * @internal + */ +export function replaceNewlines(input: string, replacement: string): string { + return input.replace(NEWLINE_REGEX, replacement); +} diff --git a/libraries/node-core-library/src/text/convertTo.ts b/libraries/node-core-library/src/text/convertTo.ts index 124717f55eb..0a0d6c660e1 100644 --- a/libraries/node-core-library/src/text/convertTo.ts +++ b/libraries/node-core-library/src/text/convertTo.ts @@ -2,13 +2,12 @@ // See LICENSE in the project root for license information. import { type NewlineKind, getNewline } from './getNewline'; - -const NEWLINE_REGEX: RegExp = /\r\n|\n\r|\r|\n/g; +import { replaceNewlines } from './_newlineHelpers'; /** * Converts all newlines in the provided string to use the specified newline type. * @public */ export function convertTo(input: string, newlineKind: NewlineKind): string { - return input.replace(NEWLINE_REGEX, getNewline(newlineKind)); + return replaceNewlines(input, getNewline(newlineKind)); } diff --git a/libraries/node-core-library/src/text/convertToCrLf.ts b/libraries/node-core-library/src/text/convertToCrLf.ts index e2dcc32294c..381ad7de1e9 100644 --- a/libraries/node-core-library/src/text/convertToCrLf.ts +++ b/libraries/node-core-library/src/text/convertToCrLf.ts @@ -1,12 +1,12 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -const NEWLINE_REGEX: RegExp = /\r\n|\n\r|\r|\n/g; +import { replaceNewlines } from './_newlineHelpers'; /** * Converts all newlines in the provided string to use Windows-style CRLF end of line characters. * @public */ export function convertToCrLf(input: string): string { - return input.replace(NEWLINE_REGEX, '\r\n'); + return replaceNewlines(input, '\r\n'); } diff --git a/libraries/node-core-library/src/text/convertToLf.ts b/libraries/node-core-library/src/text/convertToLf.ts index 53e18214155..a65a904cdf2 100644 --- a/libraries/node-core-library/src/text/convertToLf.ts +++ b/libraries/node-core-library/src/text/convertToLf.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -const NEWLINE_REGEX: RegExp = /\r\n|\n\r|\r|\n/g; +import { replaceNewlines } from './_newlineHelpers'; /** * Converts all newlines in the provided string to use POSIX-style LF end of line characters. @@ -10,5 +10,5 @@ const NEWLINE_REGEX: RegExp = /\r\n|\n\r|\r|\n/g; * @public */ export function convertToLf(input: string): string { - return input.replace(NEWLINE_REGEX, '\n'); + return replaceNewlines(input, '\n'); } diff --git a/libraries/node-core-library/src/text/readLinesFromIterable.ts b/libraries/node-core-library/src/text/readLinesFromIterable.ts index ec9233ee48c..ac8342adaf4 100644 --- a/libraries/node-core-library/src/text/readLinesFromIterable.ts +++ b/libraries/node-core-library/src/text/readLinesFromIterable.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import { NEWLINE_REGEX } from './_newlineHelpers'; + /** * The allowed types of encodings, as supported by Node.js * @public @@ -31,8 +33,6 @@ interface IReadLinesFromIterableState { remaining: string; } -const NEWLINE_REGEX: RegExp = /\r\n|\n\r|\r|\n/g; - function* readLinesFromChunk( // eslint-disable-next-line @rushstack/no-new-null chunk: string | Buffer | null, @@ -45,7 +45,7 @@ function* readLinesFromChunk( } const remaining: string = state.remaining + (typeof chunk === 'string' ? chunk : chunk.toString(encoding)); let startIndex: number = 0; - const matches: IterableIterator = remaining.matchAll(NEWLINE_REGEX); + const matches: IterableIterator = NEWLINE_REGEX[Symbol.matchAll](remaining); for (const match of matches) { const endIndex: number = match.index!; if (startIndex !== endIndex || !ignoreEmptyLines) { From 7c970cfd6c61f34fea8d21bf68ea278abf0944bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 23:18:43 +0000 Subject: [PATCH 5/6] Remove fileSystem directory - scope PR to Text API only Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> --- .../src/fileSystem/errorChecks.ts | 74 ------------------- 1 file changed, 74 deletions(-) delete mode 100644 libraries/node-core-library/src/fileSystem/errorChecks.ts diff --git a/libraries/node-core-library/src/fileSystem/errorChecks.ts b/libraries/node-core-library/src/fileSystem/errorChecks.ts deleted file mode 100644 index e97e9626c88..00000000000 --- a/libraries/node-core-library/src/fileSystem/errorChecks.ts +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. -// See LICENSE in the project root for license information. - -/** - * Returns true if the error object indicates the file or folder already exists (`EEXIST`). - * @public - */ -export function isExistError(error: Error): boolean { - return isErrnoException(error) && error.code === 'EEXIST'; -} - -/** - * Returns true if the error object indicates the file or folder does not exist (`ENOENT` or `ENOTDIR`) - * @public - */ -export function isNotExistError(error: Error): boolean { - return isFileDoesNotExistError(error) || isFolderDoesNotExistError(error); -} - -/** - * Returns true if the error object indicates the file does not exist (`ENOENT`). - * @public - */ -export function isFileDoesNotExistError(error: Error): boolean { - return isErrnoException(error) && error.code === 'ENOENT'; -} - -/** - * Returns true if the error object indicates the folder does not exist (`ENOTDIR`). - * @public - */ -export function isFolderDoesNotExistError(error: Error): boolean { - return isErrnoException(error) && error.code === 'ENOTDIR'; -} - -/** - * Returns true if the error object indicates the target is a directory (`EISDIR`). - * @public - */ -export function isDirectoryError(error: Error): boolean { - return isErrnoException(error) && error.code === 'EISDIR'; -} - -/** - * Returns true if the error object indicates the target is not a directory (`ENOTDIR`). - * @public - */ -export function isNotDirectoryError(error: Error): boolean { - return isErrnoException(error) && error.code === 'ENOTDIR'; -} - -/** - * Returns true if the error object indicates that the `unlink` system call failed - * due to a permissions issue (`EPERM`). - * @public - */ -export function isUnlinkNotPermittedError(error: Error): boolean { - return isErrnoException(error) && error.code === 'EPERM' && error.syscall === 'unlink'; -} - -/** - * Detects if the provided error object is a `NodeJS.ErrnoException` - * @public - */ -export function isErrnoException(error: Error): error is NodeJS.ErrnoException { - const typedError: NodeJS.ErrnoException = error; - // Don't check for `path` because the syscall may not have a path. - // For example, when invoked with a file descriptor. - return ( - typeof typedError.code === 'string' && - typeof typedError.errno === 'number' && - typeof typedError.syscall === 'string' - ); -} From 236df5b8b948db22be9b7291b7eb0754b6736a2b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Nov 2025 01:28:44 +0000 Subject: [PATCH 6/6] Add module documentation comment with @module tag to text/index.ts Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> --- libraries/node-core-library/src/text/index.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/libraries/node-core-library/src/text/index.ts b/libraries/node-core-library/src/text/index.ts index 14d78a4b50d..593e8aa9016 100644 --- a/libraries/node-core-library/src/text/index.ts +++ b/libraries/node-core-library/src/text/index.ts @@ -1,6 +1,17 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +/** + * Operations for working with strings that contain text. + * + * @remarks + * The utilities provided by this class are intended to be simple, small, and very + * broadly applicable. + * + * @module + * @public + */ + export { replaceAll } from './replaceAll'; export { convertToCrLf } from './convertToCrLf'; export { convertToLf } from './convertToLf';