mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 12:54:07 -07:00
feat: preserve EOL in files (#16087)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com> Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import * as fsPromises from 'node:fs/promises';
|
import * as fsPromises from 'node:fs/promises';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
import * as os from 'node:os';
|
||||||
import * as crypto from 'node:crypto';
|
import * as crypto from 'node:crypto';
|
||||||
import * as Diff from 'diff';
|
import * as Diff from 'diff';
|
||||||
import {
|
import {
|
||||||
@@ -34,7 +35,7 @@ import {
|
|||||||
} from './modifiable-tool.js';
|
} from './modifiable-tool.js';
|
||||||
import { IdeClient } from '../ide/ide-client.js';
|
import { IdeClient } from '../ide/ide-client.js';
|
||||||
import { FixLLMEditWithInstruction } from '../utils/llm-edit-fixer.js';
|
import { FixLLMEditWithInstruction } from '../utils/llm-edit-fixer.js';
|
||||||
import { safeLiteralReplace } from '../utils/textUtils.js';
|
import { safeLiteralReplace, detectLineEnding } from '../utils/textUtils.js';
|
||||||
import { EditStrategyEvent } from '../telemetry/types.js';
|
import { EditStrategyEvent } from '../telemetry/types.js';
|
||||||
import { logEditStrategy } from '../telemetry/loggers.js';
|
import { logEditStrategy } from '../telemetry/loggers.js';
|
||||||
import { EditCorrectionEvent } from '../telemetry/types.js';
|
import { EditCorrectionEvent } from '../telemetry/types.js';
|
||||||
@@ -258,17 +259,6 @@ async function calculateRegexReplacement(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Detects the line ending style of a string.
|
|
||||||
* @param content The string content to analyze.
|
|
||||||
* @returns '\r\n' for Windows-style, '\n' for Unix-style.
|
|
||||||
*/
|
|
||||||
function detectLineEnding(content: string): '\r\n' | '\n' {
|
|
||||||
// If a Carriage Return is found, assume Windows-style endings.
|
|
||||||
// This is a simple but effective heuristic.
|
|
||||||
return content.includes('\r\n') ? '\r\n' : '\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function calculateReplacement(
|
export async function calculateReplacement(
|
||||||
config: Config,
|
config: Config,
|
||||||
context: ReplacementContext,
|
context: ReplacementContext,
|
||||||
@@ -812,9 +802,13 @@ class EditToolInvocation
|
|||||||
await this.ensureParentDirectoriesExistAsync(this.params.file_path);
|
await this.ensureParentDirectoriesExistAsync(this.params.file_path);
|
||||||
let finalContent = editData.newContent;
|
let finalContent = editData.newContent;
|
||||||
|
|
||||||
// Restore original line endings if they were CRLF
|
// Restore original line endings if they were CRLF, or use OS default for new files
|
||||||
if (!editData.isNewFile && editData.originalLineEnding === '\r\n') {
|
const useCRLF =
|
||||||
finalContent = finalContent.replace(/\n/g, '\r\n');
|
(!editData.isNewFile && editData.originalLineEnding === '\r\n') ||
|
||||||
|
(editData.isNewFile && os.EOL === '\r\n');
|
||||||
|
|
||||||
|
if (useCRLF) {
|
||||||
|
finalContent = finalContent.replace(/\r?\n/g, '\r\n');
|
||||||
}
|
}
|
||||||
await this.config
|
await this.config
|
||||||
.getFileSystemService()
|
.getFileSystemService()
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect,
|
||||||
|
beforeEach,
|
||||||
|
afterEach,
|
||||||
|
vi,
|
||||||
|
type Mocked,
|
||||||
|
} from 'vitest';
|
||||||
|
import { detectLineEnding } from '../utils/textUtils.js';
|
||||||
|
import { WriteFileTool } from './write-file.js';
|
||||||
|
import { EditTool } from './edit.js';
|
||||||
|
import type { Config } from '../config/config.js';
|
||||||
|
import { ApprovalMode } from '../policy/types.js';
|
||||||
|
import { ToolConfirmationOutcome } from './tools.js';
|
||||||
|
import type { ToolRegistry } from './tool-registry.js';
|
||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import { GeminiClient } from '../core/client.js';
|
||||||
|
import type { BaseLlmClient } from '../core/baseLlmClient.js';
|
||||||
|
import {
|
||||||
|
ensureCorrectEdit,
|
||||||
|
ensureCorrectFileContent,
|
||||||
|
} from '../utils/editCorrector.js';
|
||||||
|
import { StandardFileSystemService } from '../services/fileSystemService.js';
|
||||||
|
import { WorkspaceContext } from '../utils/workspaceContext.js';
|
||||||
|
import {
|
||||||
|
createMockMessageBus,
|
||||||
|
getMockMessageBusInstance,
|
||||||
|
} from '../test-utils/mock-message-bus.js';
|
||||||
|
|
||||||
|
const rootDir = path.resolve(os.tmpdir(), 'gemini-cli-line-ending-test-root');
|
||||||
|
|
||||||
|
// --- MOCKS ---
|
||||||
|
vi.mock('../core/client.js');
|
||||||
|
vi.mock('../utils/editCorrector.js');
|
||||||
|
vi.mock('../ide/ide-client.js', () => ({
|
||||||
|
IdeClient: {
|
||||||
|
getInstance: vi.fn().mockResolvedValue({
|
||||||
|
openDiff: vi.fn(),
|
||||||
|
isDiffingEnabled: vi.fn().mockReturnValue(false),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
let mockGeminiClientInstance: Mocked<GeminiClient>;
|
||||||
|
let mockBaseLlmClientInstance: Mocked<BaseLlmClient>;
|
||||||
|
const mockEnsureCorrectEdit = vi.fn<typeof ensureCorrectEdit>();
|
||||||
|
const mockEnsureCorrectFileContent = vi.fn<typeof ensureCorrectFileContent>();
|
||||||
|
|
||||||
|
// Mock Config
|
||||||
|
const fsService = new StandardFileSystemService();
|
||||||
|
const mockConfigInternal = {
|
||||||
|
getTargetDir: () => rootDir,
|
||||||
|
getApprovalMode: vi.fn(() => ApprovalMode.DEFAULT),
|
||||||
|
setApprovalMode: vi.fn(),
|
||||||
|
getGeminiClient: vi.fn(),
|
||||||
|
getBaseLlmClient: vi.fn(),
|
||||||
|
getFileSystemService: () => fsService,
|
||||||
|
getIdeMode: vi.fn(() => false),
|
||||||
|
getWorkspaceContext: () => new WorkspaceContext(rootDir),
|
||||||
|
getApiKey: () => 'test-key',
|
||||||
|
getModel: () => 'test-model',
|
||||||
|
getSandbox: () => false,
|
||||||
|
getDebugMode: () => false,
|
||||||
|
getQuestion: () => undefined,
|
||||||
|
getToolDiscoveryCommand: () => undefined,
|
||||||
|
getToolCallCommand: () => undefined,
|
||||||
|
getMcpServerCommand: () => undefined,
|
||||||
|
getMcpServers: () => undefined,
|
||||||
|
getUserAgent: () => 'test-agent',
|
||||||
|
getUserMemory: () => '',
|
||||||
|
setUserMemory: vi.fn(),
|
||||||
|
getGeminiMdFileCount: () => 0,
|
||||||
|
setGeminiMdFileCount: vi.fn(),
|
||||||
|
getDisableLLMCorrection: vi.fn(() => false),
|
||||||
|
validatePathAccess: vi.fn().mockReturnValue(null),
|
||||||
|
getToolRegistry: () =>
|
||||||
|
({
|
||||||
|
registerTool: vi.fn(),
|
||||||
|
discoverTools: vi.fn(),
|
||||||
|
}) as unknown as ToolRegistry,
|
||||||
|
isInteractive: () => false,
|
||||||
|
};
|
||||||
|
const mockConfig = mockConfigInternal as unknown as Config;
|
||||||
|
|
||||||
|
vi.mock('../telemetry/loggers.js', () => ({
|
||||||
|
logFileOperation: vi.fn(),
|
||||||
|
logEditStrategy: vi.fn(),
|
||||||
|
logEditCorrectionEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// --- END MOCKS ---
|
||||||
|
|
||||||
|
describe('Line Ending Preservation', () => {
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
tempDir = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), 'line-ending-test-external-'),
|
||||||
|
);
|
||||||
|
if (!fs.existsSync(rootDir)) {
|
||||||
|
fs.mkdirSync(rootDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
mockGeminiClientInstance = new (vi.mocked(GeminiClient))(
|
||||||
|
mockConfig,
|
||||||
|
) as Mocked<GeminiClient>;
|
||||||
|
vi.mocked(GeminiClient).mockImplementation(() => mockGeminiClientInstance);
|
||||||
|
|
||||||
|
mockBaseLlmClientInstance = {
|
||||||
|
generateJson: vi.fn(),
|
||||||
|
} as unknown as Mocked<BaseLlmClient>;
|
||||||
|
|
||||||
|
vi.mocked(ensureCorrectEdit).mockImplementation(mockEnsureCorrectEdit);
|
||||||
|
vi.mocked(ensureCorrectFileContent).mockImplementation(
|
||||||
|
mockEnsureCorrectFileContent,
|
||||||
|
);
|
||||||
|
|
||||||
|
mockConfigInternal.getGeminiClient.mockReturnValue(
|
||||||
|
mockGeminiClientInstance,
|
||||||
|
);
|
||||||
|
mockConfigInternal.getBaseLlmClient.mockReturnValue(
|
||||||
|
mockBaseLlmClientInstance,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (fs.existsSync(tempDir)) {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
if (fs.existsSync(rootDir)) {
|
||||||
|
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectLineEnding', () => {
|
||||||
|
it('should detect CRLF', () => {
|
||||||
|
expect(detectLineEnding('line1\r\nline2')).toBe('\r\n');
|
||||||
|
expect(detectLineEnding('line1\r\n')).toBe('\r\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect LF', () => {
|
||||||
|
expect(detectLineEnding('line1\nline2')).toBe('\n');
|
||||||
|
expect(detectLineEnding('line1\n')).toBe('\n');
|
||||||
|
expect(detectLineEnding('line1')).toBe('\n'); // Default to LF if no newline
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WriteFileTool', () => {
|
||||||
|
let tool: WriteFileTool;
|
||||||
|
const abortSignal = new AbortController().signal;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const bus = createMockMessageBus();
|
||||||
|
getMockMessageBusInstance(bus).defaultToolDecision = 'ask_user';
|
||||||
|
tool = new WriteFileTool(mockConfig, bus);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve CRLF when overwriting an existing file', async () => {
|
||||||
|
const filePath = path.join(rootDir, 'crlf_file.txt');
|
||||||
|
const originalContent = 'line1\r\nline2\r\n';
|
||||||
|
fs.writeFileSync(filePath, originalContent); // Write with CRLF (or however Node writes binary buffer)
|
||||||
|
// Ensure strictly CRLF
|
||||||
|
fs.writeFileSync(filePath, Buffer.from('line1\r\nline2\r\n'));
|
||||||
|
|
||||||
|
// Proposed content from LLM (usually LF)
|
||||||
|
const proposedContent = 'line1\nline2\nline3\n';
|
||||||
|
|
||||||
|
// Mock corrections to return proposed content as-is (but usually normalized)
|
||||||
|
mockEnsureCorrectEdit.mockResolvedValue({
|
||||||
|
params: {
|
||||||
|
file_path: filePath,
|
||||||
|
old_string: originalContent,
|
||||||
|
new_string: proposedContent,
|
||||||
|
},
|
||||||
|
occurrences: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = { file_path: filePath, content: proposedContent };
|
||||||
|
const invocation = tool.build(params);
|
||||||
|
|
||||||
|
// Force approval
|
||||||
|
const confirmDetails = await invocation.shouldConfirmExecute(abortSignal);
|
||||||
|
if (
|
||||||
|
confirmDetails &&
|
||||||
|
typeof confirmDetails === 'object' &&
|
||||||
|
'onConfirm' in confirmDetails
|
||||||
|
) {
|
||||||
|
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
|
||||||
|
}
|
||||||
|
|
||||||
|
await invocation.execute(abortSignal);
|
||||||
|
|
||||||
|
const writtenContent = fs.readFileSync(filePath, 'utf8');
|
||||||
|
// Expect all newlines to be CRLF
|
||||||
|
expect(writtenContent).toBe('line1\r\nline2\r\nline3\r\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use OS EOL for new files', async () => {
|
||||||
|
const filePath = path.join(rootDir, 'new_os_eol_file.txt');
|
||||||
|
const proposedContent = 'line1\nline2\n';
|
||||||
|
|
||||||
|
mockEnsureCorrectFileContent.mockResolvedValue(proposedContent);
|
||||||
|
|
||||||
|
const params = { file_path: filePath, content: proposedContent };
|
||||||
|
const invocation = tool.build(params);
|
||||||
|
|
||||||
|
const confirmDetails = await invocation.shouldConfirmExecute(abortSignal);
|
||||||
|
if (
|
||||||
|
confirmDetails &&
|
||||||
|
typeof confirmDetails === 'object' &&
|
||||||
|
'onConfirm' in confirmDetails
|
||||||
|
) {
|
||||||
|
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
|
||||||
|
}
|
||||||
|
|
||||||
|
await invocation.execute(abortSignal);
|
||||||
|
|
||||||
|
const writtenContent = fs.readFileSync(filePath, 'utf8');
|
||||||
|
|
||||||
|
if (os.EOL === '\r\n') {
|
||||||
|
expect(writtenContent).toBe('line1\r\nline2\r\n');
|
||||||
|
} else {
|
||||||
|
expect(writtenContent).toBe('line1\nline2\n');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('EditTool', () => {
|
||||||
|
let tool: EditTool;
|
||||||
|
const abortSignal = new AbortController().signal;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const bus = createMockMessageBus();
|
||||||
|
getMockMessageBusInstance(bus).defaultToolDecision = 'ask_user';
|
||||||
|
tool = new EditTool(mockConfig, bus);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve CRLF when editing a file', async () => {
|
||||||
|
const filePath = path.join(rootDir, 'edit_crlf.txt');
|
||||||
|
const originalContent = 'line1\r\nline2\r\nline3\r\n';
|
||||||
|
fs.writeFileSync(filePath, Buffer.from(originalContent));
|
||||||
|
|
||||||
|
const oldString = 'line2';
|
||||||
|
const newString = 'modified';
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
file_path: filePath,
|
||||||
|
old_string: oldString,
|
||||||
|
new_string: newString,
|
||||||
|
instruction: 'Change line2 to modified',
|
||||||
|
};
|
||||||
|
const invocation = tool.build(params);
|
||||||
|
|
||||||
|
// Force approval
|
||||||
|
const confirmDetails = await invocation.shouldConfirmExecute(abortSignal);
|
||||||
|
if (
|
||||||
|
confirmDetails &&
|
||||||
|
typeof confirmDetails === 'object' &&
|
||||||
|
'onConfirm' in confirmDetails
|
||||||
|
) {
|
||||||
|
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
|
||||||
|
}
|
||||||
|
|
||||||
|
await invocation.execute(abortSignal);
|
||||||
|
|
||||||
|
const writtenContent = fs.readFileSync(filePath, 'utf8');
|
||||||
|
|
||||||
|
expect(writtenContent).toBe('line1\r\nmodified\r\nline3\r\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -901,6 +901,7 @@ describe('WriteFileTool', () => {
|
|||||||
errorMessage: 'Generic write error',
|
errorMessage: 'Generic write error',
|
||||||
expectedMessagePrefix: 'Error writing to file',
|
expectedMessagePrefix: 'Error writing to file',
|
||||||
mockFsExistsSync: false,
|
mockFsExistsSync: false,
|
||||||
|
restoreAllMocks: false,
|
||||||
},
|
},
|
||||||
])(
|
])(
|
||||||
'should return $errorType error when write fails with $errorCode',
|
'should return $errorType error when write fails with $errorCode',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import fsPromises from 'node:fs/promises';
|
import fsPromises from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
import * as Diff from 'diff';
|
import * as Diff from 'diff';
|
||||||
import { WRITE_FILE_TOOL_NAME } from './tool-names.js';
|
import { WRITE_FILE_TOOL_NAME } from './tool-names.js';
|
||||||
import type { Config } from '../config/config.js';
|
import type { Config } from '../config/config.js';
|
||||||
@@ -33,6 +34,7 @@ import {
|
|||||||
ensureCorrectEdit,
|
ensureCorrectEdit,
|
||||||
ensureCorrectFileContent,
|
ensureCorrectFileContent,
|
||||||
} from '../utils/editCorrector.js';
|
} from '../utils/editCorrector.js';
|
||||||
|
import { detectLineEnding } from '../utils/textUtils.js';
|
||||||
import { DEFAULT_DIFF_OPTIONS, getDiffStat } from './diffOptions.js';
|
import { DEFAULT_DIFF_OPTIONS, getDiffStat } from './diffOptions.js';
|
||||||
import type {
|
import type {
|
||||||
ModifiableDeclarativeTool,
|
ModifiableDeclarativeTool,
|
||||||
@@ -301,9 +303,19 @@ class WriteFileToolInvocation extends BaseToolInvocation<
|
|||||||
await fsPromises.mkdir(dirName, { recursive: true });
|
await fsPromises.mkdir(dirName, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let finalContent = fileContent;
|
||||||
|
const useCRLF =
|
||||||
|
!isNewFile && originalContent
|
||||||
|
? detectLineEnding(originalContent) === '\r\n'
|
||||||
|
: os.EOL === '\r\n';
|
||||||
|
|
||||||
|
if (useCRLF) {
|
||||||
|
finalContent = finalContent.replace(/\r?\n/g, '\r\n');
|
||||||
|
}
|
||||||
|
|
||||||
await this.config
|
await this.config
|
||||||
.getFileSystemService()
|
.getFileSystemService()
|
||||||
.writeTextFile(this.resolvedPath, fileContent);
|
.writeTextFile(this.resolvedPath, finalContent);
|
||||||
|
|
||||||
// Generate diff for display result
|
// Generate diff for display result
|
||||||
const fileName = path.basename(this.resolvedPath);
|
const fileName = path.basename(this.resolvedPath);
|
||||||
|
|||||||
@@ -639,6 +639,33 @@ describe('editCorrector', () => {
|
|||||||
expect(result.params).toEqual(originalParams);
|
expect(result.params).toEqual(originalParams);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Scenario Group 7: Trimming with Newline Preservation', () => {
|
||||||
|
it('Test 7.1: should preserve trailing newlines in new_string when trimming is applied', async () => {
|
||||||
|
const currentContent = ' find me'; // Matches old_string initially
|
||||||
|
const originalParams = {
|
||||||
|
file_path: '/test/file.txt',
|
||||||
|
old_string: ' find me', // Matches, but has whitespace to trim
|
||||||
|
new_string: ' replaced\n\n', // Needs trimming but preserve newlines
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await ensureCorrectEdit(
|
||||||
|
'/test/file.txt',
|
||||||
|
currentContent,
|
||||||
|
originalParams,
|
||||||
|
mockGeminiClientInstance,
|
||||||
|
mockBaseLlmClientInstance,
|
||||||
|
abortSignal,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// old_string should be trimmed to 'find me' because 'find me' also exists uniquely in ' find me'
|
||||||
|
expect(result.params.old_string).toBe('find me');
|
||||||
|
// new_string should be trimmed of spaces but keep ALL newlines
|
||||||
|
expect(result.params.new_string).toBe('replaced\n\n');
|
||||||
|
expect(result.occurrences).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ensureCorrectFileContent', () => {
|
describe('ensureCorrectFileContent', () => {
|
||||||
|
|||||||
@@ -689,13 +689,20 @@ Return ONLY the corrected string in the specified JSON format with the key 'corr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trimPreservingTrailingNewline(str: string): string {
|
||||||
|
const trimmedEnd = str.trimEnd();
|
||||||
|
const trailingWhitespace = str.slice(trimmedEnd.length);
|
||||||
|
const trailingNewlines = trailingWhitespace.replace(/[^\r\n]/g, '');
|
||||||
|
return str.trim() + trailingNewlines;
|
||||||
|
}
|
||||||
|
|
||||||
function trimPairIfPossible(
|
function trimPairIfPossible(
|
||||||
target: string,
|
target: string,
|
||||||
trimIfTargetTrims: string,
|
trimIfTargetTrims: string,
|
||||||
currentContent: string,
|
currentContent: string,
|
||||||
expectedReplacements: number,
|
expectedReplacements: number,
|
||||||
) {
|
) {
|
||||||
const trimmedTargetString = target.trim();
|
const trimmedTargetString = trimPreservingTrailingNewline(target);
|
||||||
if (target.length !== trimmedTargetString.length) {
|
if (target.length !== trimmedTargetString.length) {
|
||||||
const trimmedTargetOccurrences = countOccurrences(
|
const trimmedTargetOccurrences = countOccurrences(
|
||||||
currentContent,
|
currentContent,
|
||||||
@@ -703,7 +710,8 @@ function trimPairIfPossible(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (trimmedTargetOccurrences === expectedReplacements) {
|
if (trimmedTargetOccurrences === expectedReplacements) {
|
||||||
const trimmedReactiveString = trimIfTargetTrims.trim();
|
const trimmedReactiveString =
|
||||||
|
trimPreservingTrailingNewline(trimIfTargetTrims);
|
||||||
return {
|
return {
|
||||||
targetString: trimmedTargetString,
|
targetString: trimmedTargetString,
|
||||||
pair: trimmedReactiveString,
|
pair: trimmedReactiveString,
|
||||||
|
|||||||
@@ -54,6 +54,17 @@ export function isBinary(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects the line ending style of a string.
|
||||||
|
* @param content The string content to analyze.
|
||||||
|
* @returns '\r\n' for Windows-style, '\n' for Unix-style.
|
||||||
|
*/
|
||||||
|
export function detectLineEnding(content: string): '\r\n' | '\n' {
|
||||||
|
// If a Carriage Return is found, assume Windows-style endings.
|
||||||
|
// This is a simple but effective heuristic.
|
||||||
|
return content.includes('\r\n') ? '\r\n' : '\n';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Truncates a string to a maximum length, appending a suffix if truncated.
|
* Truncates a string to a maximum length, appending a suffix if truncated.
|
||||||
* @param str The string to truncate.
|
* @param str The string to truncate.
|
||||||
|
|||||||
Reference in New Issue
Block a user