packages/core/src/shared/utilities/textDocumentUtilities.ts (197 lines of code) (raw):
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
import * as _path from 'path'
import * as vscode from 'vscode'
import { disposeOnEditorClose, getTabSizeSetting } from './editorUtilities'
import { tempDirPath } from '../filesystemUtilities'
import { getLogger } from '../logger/logger'
import fs from '../fs/fs'
import { ToolkitError } from '../errors'
import { indent } from './textUtilities'
import { ViewDiffMessage } from '../../amazonq/commons/controllers/contentController'
import { DiffContentProvider } from '../../amazonq/commons/controllers/diffContentProvider'
/**
* Finds occurences of text in a document. Currently only used for highlighting cloudwatchlogs data.
* @param document Document to search.
* @param keyword Keyword to search for.
* @returns the ranges of each and every occurence of the keyword.
*/
export function findOccurencesOf(document: vscode.TextDocument, keyword: string): vscode.Range[] {
const ranges: vscode.Range[] = []
let lineNum = 0
keyword = keyword.toLowerCase()
while (lineNum < document.lineCount) {
const currentLine = document.lineAt(lineNum)
const currentLineText = currentLine.text.toLowerCase()
let indexOccurrence = currentLineText.indexOf(keyword, 0)
while (indexOccurrence >= 0) {
ranges.push(
new vscode.Range(
new vscode.Position(lineNum, indexOccurrence),
new vscode.Position(lineNum, indexOccurrence + keyword.length)
)
)
indexOccurrence = currentLineText.indexOf(keyword, indexOccurrence + 1)
}
lineNum += 1
}
return ranges
}
/**
* If the specified document is currently open, and marked as dirty, it is saved.
*/
export async function saveDocumentIfDirty(documentPath: string): Promise<void> {
const path = _path.normalize(vscode.Uri.file(documentPath).fsPath)
const document = vscode.workspace.textDocuments.find((doc) => {
if (!doc.isDirty) {
return false
}
if (_path.normalize(doc.uri.fsPath) !== path) {
return false
}
return true
})
if (document) {
await document.save()
}
}
/**
* Determine the tab width used by the editor.
*
* @param editor The editor for which to determine the tab width.
*/
export function getTabSize(editor?: vscode.TextEditor): number {
const tabSize = !editor ? undefined : editor.options.tabSize
switch (typeof tabSize) {
case 'number':
return tabSize
case 'string':
return Number.parseInt(tabSize, 10)
default:
return getTabSizeSetting()
}
}
/**
* Creates a selection range from the given document and selection.
* If a user selects a partial code, this function generates the range from start line to end line.
*
* @param {vscode.TextDocument} doc - The VSCode document where the selection is applied.
* @param {vscode.Selection} selection - The selection range in the document.
* @returns {vscode.Range} - The VSCode range object representing the start and end of the selection.
*/
export function getSelectionFromRange(doc: vscode.TextDocument, selection: vscode.Selection) {
return new vscode.Range(
new vscode.Position(selection.start.line, 0),
new vscode.Position(selection.end.line, doc.lineAt(selection.end.line).range.end.character)
)
}
/**
* Applies the given code to the specified range in the document.
* Saves the document after the edit is successfully applied.
*
* @param {vscode.TextDocument} doc - The VSCode document to which the changes are applied.
* @param {vscode.Range} range - The range in the document where the code is replaced.
* @param {string} code - The code to be applied to the document.
* @returns {Promise<void>} - Resolves when the changes are successfully applied and the document is saved.
*/
export async function applyChanges(doc: vscode.TextDocument, range: vscode.Range, code: string) {
const edit = new vscode.WorkspaceEdit()
edit.replace(doc.uri, range, code)
const successfulEdit = await vscode.workspace.applyEdit(edit)
if (successfulEdit) {
getLogger().debug('Diff: Edits successfully applied to: %s', doc.uri.fsPath)
await doc.save()
} else {
getLogger().error('Diff: Unable to apply changes to: %s', doc.uri.fsPath)
}
}
/**
* Creates a temporary file for diff comparison by cloning the original file
* and applying the proposed changes within the selected range.
*
* @param {vscode.Uri} originalFileUri - The URI of the original file.
* @param {ViewDiffMessage} message - The message object containing the proposed code changes.
* @param {vscode.Selection} selection - The selection range in the document where the changes are applied.
* @returns {Promise<vscode.Uri>} - A promise that resolves to the URI of the temporary file.
*/
export async function createTempFileForDiff(
originalFileUri: vscode.Uri,
message: ViewDiffMessage,
selection: vscode.Selection,
scheme: string
): Promise<vscode.Uri> {
const errorCode = 'createTempFile'
const id = Date.now()
const languageId = (await vscode.workspace.openTextDocument(originalFileUri)).languageId
const tempFile = _path.parse(originalFileUri.path)
const tempFilePath = _path.join(tempDirPath, `${tempFile.name}_proposed-${id}${tempFile.ext}`)
await fs.mkdir(tempDirPath)
const tempFileUri = vscode.Uri.parse(`${scheme}:${tempFilePath}`)
getLogger().debug('Diff: Creating temp file: %s', tempFileUri.fsPath)
try {
// Write original content to temp file
await fs.writeFile(tempFilePath, await fs.readFileText(originalFileUri.fsPath))
} catch (error) {
if (!(error instanceof Error)) {
throw error
}
throw ToolkitError.chain(error, 'Failed to write to temp file', { code: errorCode })
}
// Apply the proposed changes to the temp file
const doc = await vscode.workspace.openTextDocument(tempFileUri.path)
const languageIdStatus = await vscode.languages.setTextDocumentLanguage(doc, languageId)
if (languageIdStatus) {
getLogger().debug('Diff: languageId for %s is set to: %s', tempFileUri.fsPath, languageId)
} else {
getLogger().error('Diff: Unable to set languageId for %s to: %s', tempFileUri.fsPath, languageId)
}
const code = getIndentedCode(message, doc, selection)
const range = getSelectionFromRange(doc, selection)
await applyChanges(doc, range, code)
return tempFileUri
}
/**
* Creates temporary URIs for diff comparison and registers their content with a DiffContentProvider.
* This approach avoids writing to the file system by keeping content in memory.
*
* @param filePath The path of the original file (used for naming)
* @param fileText Optional content of the original file (if not provided, will be read from filePath)
* @param message The message object containing the proposed code changes
* @param selection The selection range where changes should be applied
* @param scheme The URI scheme to use
* @param diffProvider The content provider to register URIs with
* @returns A promise that resolves to a tuple of [originalUri, modifiedUri]
*/
export async function createTempUrisForDiff(
filePath: string,
fileText: string | undefined,
message: ViewDiffMessage,
selection: vscode.Selection,
scheme: string,
diffProvider: DiffContentProvider
): Promise<[vscode.Uri, vscode.Uri]> {
const originalFile = _path.parse(filePath)
const id = Date.now()
// Create URIs with the custom scheme
const originalFileUri = vscode.Uri.parse(`${scheme}:/${originalFile.name}_original-${id}${originalFile.ext}`)
const modifiedFileUri = vscode.Uri.parse(`${scheme}:/${originalFile.name}_proposed-${id}${originalFile.ext}`)
// Get the original content
const contentToUse = fileText ?? (await fs.readFileText(filePath))
// Register the original content
diffProvider.registerContent(originalFileUri, contentToUse)
const indentedCode = getIndentedCodeFromOriginalContent(message, contentToUse, selection)
const lines = contentToUse.split('\n')
// Create the modified content
const beforeLines = lines.slice(0, selection.start.line)
const afterLines = lines.slice(selection.end.line + 1)
const modifiedContent = [...beforeLines, indentedCode, ...afterLines].join('\n')
// Register the modified content
diffProvider.registerContent(modifiedFileUri, modifiedContent)
return [originalFileUri, modifiedFileUri]
}
/**
* Indents the given code based on the current document's indentation at the selection start.
*
* @param message The message object containing the code.
* @param doc The VSCode document where the code is applied.
* @param selection The selection range in the document.
* @returns The processed code to be applied to the document.
*/
export function getIndentedCode(message: ViewDiffMessage, doc: vscode.TextDocument, selection: vscode.Selection) {
const indentRange = new vscode.Range(new vscode.Position(selection.start.line, 0), selection.active)
let indentation = doc.getText(indentRange)
if (indentation.trim().length !== 0) {
indentation = ' '.repeat(indentation.length - indentation.trimStart().length)
}
return indent(message.code, indentation.length)
}
/**
* Indents the given code based on the indentation of the original content at the selection start.
*
* @param message The message object containing the code.
* @param originalContent The original content of the document.
* @param selection The selection range in the document.
* @returns The processed code to be applied to the document.
*/
export function getIndentedCodeFromOriginalContent(
message: ViewDiffMessage,
originalContent: string,
selection: vscode.Selection
) {
const lines = originalContent.split('\n')
const selectionStartLine = lines[selection.start.line] || ''
const indentMatch = selectionStartLine.match(/^(\s*)/)
const indentation = indentMatch ? indentMatch[1] : ''
return indent(message.code, indentation.length)
}
export async function showFile(uri: vscode.Uri) {
const doc = await vscode.workspace.openTextDocument(uri)
await vscode.window.showTextDocument(doc, { preview: false })
await vscode.languages.setTextDocumentLanguage(doc, 'log')
}
/**
* Expands the given selection to full line(s) in the document.
* If the selection is partial, it will be extended to include the entire line(s).
* @param document The current text document
* @param selection The current selection
* @returns A new Range that covers full line(s) of the selection
*/
export function expandSelectionToFullLines(document: vscode.TextDocument, selection: vscode.Selection): vscode.Range {
const startLine = document.lineAt(selection.start.line)
const endLine = document.lineAt(selection.end.line)
return new vscode.Range(startLine.range.start, endLine.range.end)
}
/**
* Ensures the document ends with a newline character.
* If the selection is at the end of the last line and the document doesn't end with a newline,
* this function inserts one.
* @param editor The VS Code text editor to modify
*/
export async function addEofNewline(editor: vscode.TextEditor) {
if (
editor.selection.end.line === editor.document.lineCount - 1 &&
editor.selection.end.character === editor.document.lineAt(editor.selection.end.line).text.length &&
!editor.document.getText().endsWith('\n')
) {
await editor.edit((editBuilder) => {
editBuilder.insert(editor.selection.end, '\n')
})
}
}
class ReadonlyTextDocumentProvider implements vscode.TextDocumentContentProvider {
private content = ''
setContent(content: string) {
this.content = content
}
provideTextDocumentContent(uri: vscode.Uri): string {
return this.content
}
}
/**
* Shows a read only virtual txt file on a side column
* It's read-only so that the "save" option doesn't appear when user closes the pop up window
* Usage: ReadonlyDocument.show(content, filename)
* @param content The content to be displayed in the virtual document
* @param filename The title on top of the pop up window
*/
class ReadonlyDocument {
private readonly scheme = 'AWStoolkit-readonly'
private readonly provider = new ReadonlyTextDocumentProvider()
public async show(content: string, filename: string) {
const disposableProvider = vscode.workspace.registerTextDocumentContentProvider(this.scheme, this.provider)
this.provider.setContent(content)
const uri = vscode.Uri.parse(`${this.scheme}:/${filename}.txt`)
// txt document on side column, in focus and preview
const options: vscode.TextDocumentShowOptions = {
viewColumn: vscode.ViewColumn.Beside,
preserveFocus: false,
preview: true,
}
// Open the document with the updated content
const document = await vscode.workspace.openTextDocument(uri)
await vscode.window.showTextDocument(document, options)
disposeOnEditorClose(uri, disposableProvider)
}
}
export const readonlyDocument = new ReadonlyDocument()