blob: 8854bc75fd632a5483406a4d1c4ba6cc80ef8d6a [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as vscode from 'vscode'
import * as fs from 'fs'
import * as os from 'os'
import * as child_process from 'child_process'
import path from 'path'
import { VSCodeLaunchConfigArgs } from './classes/vscode-launch'
import { InfosetOutput } from './daffodilDebugger'
import { XMLParser } from 'fast-xml-parser'
import * as unzip from 'unzip-stream'
import { pipeline } from 'stream/promises'
import { Transform } from 'stream'
let currentConfig: vscode.DebugConfiguration
export const terminalName = 'daffodil-debugger'
export const regexp = {
comma: new RegExp(',', 'g'),
slash: new RegExp('/', 'g'),
space: new RegExp(' ', 'g'),
workspace: new RegExp('${workspaceFolder}', 'g'),
}
// Function to retrieve to the current debug config
export function getCurrentConfig(): vscode.DebugConfiguration {
return currentConfig
}
// Function to set the current debug config
export function setCurrentConfig(
config: vscode.DebugConfiguration
): vscode.DebugConfiguration {
currentConfig = config
return config
}
// Function to run vscode command and catch the error to not cause other issues
export function runCommand(command: string, params?: any) {
const vscodeError = (err) => {
vscode.window.showInformationMessage(err)
}
if (!params)
vscode.commands.executeCommand(command).then(undefined, vscodeError)
else
vscode.commands.executeCommand(command, params).then(undefined, vscodeError)
}
// Function for checking if config specifies if either the
// infoset, infoset diff or data editor needs to be opened
export async function onDebugStartDisplay(viewsToCheck: string[]) {
let config = getCurrentConfig()
viewsToCheck.forEach(async (viewToCheck) => {
switch (viewToCheck) {
case 'data-editor':
if (config.openDataEditor) {
runCommand('extension.data.edit', config.data)
}
break
case 'infoset-view':
if (config.openInfosetView) {
runCommand('infoset.display')
}
break
case 'infoset-diff-view':
if (config.openInfosetDiffView) {
runCommand('infoset.diff')
}
break
}
})
}
function checkInfosetFileExtension(
infosetOutput: InfosetOutput,
infosetFormat: string
) {
// If the infoset file path doesn't end with the infoset output format, update to end with infoset output format.
if (!infosetOutput.path.endsWith(`.${infosetFormat}`)) {
vscode.window.showWarningMessage(
`The output path for the file extension doesn't end with the infoset output format type. The file extension will be updated to end with .${infosetFormat}`
)
let fileExtensionSearchResult = new RegExp('(\\.).*$')
.exec(infosetOutput!.path)
?.filter((fileExt) => fileExt !== '.')[0]
/**
* If search result is not undefined replace the file extension with the correct extension.
* Else append proper file extension to the end of the output path.
*/
infosetOutput.path =
fileExtensionSearchResult !== undefined
? infosetOutput.path.replace(
fileExtensionSearchResult,
`.${infosetFormat}`
)
: (infosetOutput.path = `${infosetOutput!.path}.${infosetFormat}`)
}
}
function checkSettingValue<T>(target: unknown, defaults: T): T {
if (
typeof defaults !== 'object' ||
defaults === null ||
Array.isArray(defaults)
) {
return target === undefined ? defaults : (target as T)
}
if (typeof target !== 'object' || target === null) {
return defaults
}
const result: Record<string, any> = {}
for (const key of Object.keys(defaults)) {
result[key] = checkSettingValue(
(target as any)[key],
(defaults as any)[key]
)
}
for (const key of Object.keys(target as any)) {
if (!(key in result)) {
result[key] = (target as any)[key]
}
}
return result as T
}
export function getConfig(jsonArgs: object): vscode.DebugConfiguration {
const launchConfigArgs: VSCodeLaunchConfigArgs = JSON.parse(
JSON.stringify(jsonArgs)
)
// NOTE: Don't make this a static value as extension configuration may change while the extension is loaded.
const defaultConf = vscode.workspace.getConfiguration()
const defaultValues = {
schema: defaultConf.get('schema', {
path: '${command:AskForSchemaName}',
rootName: null,
rootNamespace: null,
}),
data: defaultConf.get('data', '${command:AskForDataName}'),
debugServer: defaultConf.get('debugServer', 4711),
infosetFormat: 'xml',
infosetOutput: defaultConf.get('infosetOutput', {
type: 'file',
path: '${workspaceFolder}/target/infoset.xml',
}),
tdmlConfig: {
...{
action: 'generate',
name: 'Default Test Case',
path: '${command:AskForValidatedTDMLPath}',
},
...((defaultConf.get('tdmlConfig') as object) || {}),
},
stopOnEntry: defaultConf.get('stopOnEntry', true),
useExistingServer: defaultConf.get('useExistingServer', false),
trace: defaultConf.get('trace', true),
openDataEditor: defaultConf.get('openDataEditor', false),
openInfosetView: defaultConf.get('openInfosetView', false),
openInfosetDiffView: defaultConf.get('openInfosetDiffView', false),
daffodilDebugClasspath: defaultConf.get('daffodilDebugClasspath', []),
dataEditor: defaultConf.get('dataEditor', {
port: 9000,
logging: {
level: 'info',
file: '${workspaceFolder}/dataEditor-${omegaEditPort}.log',
},
}),
dfdlDebugger: defaultConf.get('dfdlDebugger', {
daffodilVersion: '3.11.0',
timeout: '10s',
logging: {
level: 'INFO',
file: '${workspaceFolder}/daffodil-debugger.log',
},
}),
}
for (const [key, defaults] of Object.entries(defaultValues)) {
launchConfigArgs[key] = checkSettingValue(launchConfigArgs[key], defaults)
}
if (launchConfigArgs.infosetOutput?.type == 'file') {
checkInfosetFileExtension(
launchConfigArgs.infosetOutput!,
launchConfigArgs.infosetFormat!
)
}
// VVVVVVVVVVVVVVVVVVVVVVVV COMPARTMENTALIZED FIX FOR THIRD BULLET POINT OF https://github.com/apache/daffodil-vscode/issues/1540 VVVVVVVVVVVVVVVVVVVVVVVV
// Handle setting test case name for TDML Execute action if it's not specified.
// This resolves the issue of always defualting to `Default Test Case` even thought a test case name isn't specified when we click on `Execute TDML` when we have opened TDML file
const args = jsonArgs as VSCodeLaunchConfigArgs // cast needed b/c of VS Code Typescript errors
if (
args.tdmlConfig?.action === 'execute' &&
args.tdmlConfig?.name === undefined
) {
launchConfigArgs.tdmlConfig.name = undefined
}
// ^^^^^^^^^^^^^^^^^^^^^^^^ COMPARTMENTALIZED FIX FOR THIRD BULLET POINT OF https://github.com/apache/daffodil-vscode/issues/1540 ^^^^^^^^^^^^^^^^^^^^^^^^
return JSON.parse(JSON.stringify(launchConfigArgs))
}
export async function displayTerminalExitStatus(terminal: vscode.Terminal) {
vscode.window.onDidCloseTerminal((t) => {
if (t.name === terminal.name && t.processId === terminal.processId) {
t.show()
vscode.window.showInformationMessage(
`Terminal exited with status code: ${t.exitStatus?.code}`
)
}
})
}
/*
* Check if OS is windows, if so return windows option else return the mac and linux option.
* This method is used to elimate a lot duplicated code we had check if the os was windows related.
*/
export function osCheck(winOption: any, macLinOption: any): any {
return os.platform().toLowerCase().startsWith('win')
? winOption
: macLinOption
}
export async function killProcess(id: number | undefined) {
if (id) {
child_process.exec(
osCheck(`taskkill /F /PID ${id}`, `kill -9 ${id} 2>&1 || echo 0`)
)
}
}
export const delay = (ms: number) => new Promise((res) => setTimeout(res, ms))
// Grab active terminal if available and it can run a new command, else create new one
export const getTerminal = (
hideTerminal: boolean,
env:
| {
[key: string]: string | null | undefined
}
| undefined,
createTerminal: boolean
) => {
if (!createTerminal) {
vscode.window.terminals.forEach((terminal) => {
if (terminal.name.includes(terminalName)) terminal.dispose()
})
}
// If no good active terminal available create new one
const terminal = vscode.window.createTerminal({
name: terminalName,
hideFromUser: hideTerminal,
// Need to specify 'cmd' for windows as by default it will use powershell which causes issues with the envionment varaibles
shellPath: osCheck('cmd', undefined),
})
// Looping to manual set all env variables. Setting "env: env" inside of createTerminal won't override variables already set
for (var key in env) {
if (key !== null && key !== undefined) {
let workspaceFolder = vscode.workspace.workspaceFolders
? vscode.workspace.workspaceFolders[0].uri.fsPath
: ''
/*
* NOTE: For windows to function properly the set format needs to be:
* set "VARIABLE_NAME=VARIABLE_VALUE"
* In bash doing:
* export "VARIABLE_NAME=VARIABLE_VALUE"
* didn't cause any issues so this was easiest work around.
*/
let exportVar = `${osCheck('set', 'export')} "${key}=${env[key]}"`
if (exportVar.includes('${workspaceFolder}')) {
exportVar = exportVar.replaceAll('${workspaceFolder}', workspaceFolder)
}
terminal.sendText(exportVar, true)
}
}
return terminal
}
export async function runScript(
scriptPath: string,
scriptName: string,
createTerminal: boolean,
shellArgs: string[] = [],
env:
| {
[key: string]: string | null | undefined
}
| undefined = undefined,
type: string = '',
hideTerminal: boolean = false,
port: number | undefined = undefined
): Promise<vscode.Terminal> {
// Get the full path to the script
const scriptFullPath = path.join(scriptPath, 'bin', scriptName)
// Make sure the path exists
if (!fs.existsSync(scriptFullPath)) {
vscode.window.showErrorMessage(
`Script path ${scriptFullPath} does not exist`
)
} else {
// Make sure the script is executable
if (!os.platform().toLowerCase().startsWith('win')) {
fs.chmodSync(scriptFullPath, 0o755)
}
}
const terminal = getTerminal(hideTerminal, env, createTerminal)
// Create debugger run command
const fullPathToScript = path
.join(scriptPath, 'bin', scriptName)
// fix pathing as emtpy space needs a \ before it to not cause errors
.replace(' ', '\\ ')
const debuggerRunCommand = `${fullPathToScript} ${shellArgs.join(' ')}`
// Send debugger run command to terminal, when exists terminal will stay open
terminal.sendText(debuggerRunCommand)
if (!hideTerminal) {
terminal.show()
await displayTerminalExitStatus(terminal)
}
if (type.includes('daffodil')) {
await delay(5000).then(() => {})
} else {
const wait_port = require('wait-port')
await wait_port({ host: '127.0.0.1', port: port, output: 'silent' })
}
return terminal
}
/**
* Search for an existing Omega Edit server and kill if desired.
* @param killOnFind Kill any OmegaEdit server found that is running. Defaults to True.
* @returns PID of the process found and/or killed. 0 if no server is found. -1 if a child_process error occurred.
*/
export async function findExistingOmegaEditServer(
killOnFind: boolean = true
): Promise<number> {
let ret: number
const pid: string = child_process
.execSync(
osCheck('', "ps -a | grep omega-edit | grep -v grep | awk '{print $1}'")
)
.toString('ascii')
pid === '' ? (ret = 0) : (ret = parseInt(pid))
if (ret > 0 && killOnFind) {
vscode.window.showWarningMessage(
`Existing Omega Edit server found | Killing PID: ${ret} and restarting server.`
)
await killProcess(ret)
}
return ret
}
export function tmpFile(sid: string): string {
return `${os.tmpdir()}/infoset-${sid}.${getCurrentConfig().infosetFormat}`
}
export function ensureFile(path: string): string {
if (!fs.existsSync(path)) {
fs.writeFileSync(path, '')
}
return path
}
/**
* Substitutes in VSCode and environmental variables into a given string.
*
* @param input - given string
* @param alternativeWorkspace - alternative workspace location if vscode can't find a good workspace
* @returns modified input, but with all ${} corresponding to VSCode or Env variables substituted with their
*/
export function substituteVSCodeEnvVariables(
input: string,
alternativeWorkspace?: string
): string {
const workspaceFolder =
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ||
alternativeWorkspace ||
''
const homeDir = os.homedir()
// Common VS Code variables
const variableMap: Record<string, string> = {
'${workspaceFolder}': workspaceFolder,
'${workspaceRoot}': workspaceFolder,
'${userHome}': homeDir,
'${file}': vscode.window.activeTextEditor?.document.uri.fsPath || '',
'${relativeFile}': path.relative(
workspaceFolder,
vscode.window.activeTextEditor?.document.uri.fsPath || ''
),
'${fileBasename}': path.basename(
vscode.window.activeTextEditor?.document.uri.fsPath || ''
),
'${fileDirname}': path.dirname(
vscode.window.activeTextEditor?.document.uri.fsPath || ''
),
'${fileExtname}': path.extname(
vscode.window.activeTextEditor?.document.uri.fsPath || ''
),
'${fileBasenameNoExtension}': path.basename(
vscode.window.activeTextEditor?.document.uri.fsPath || '',
path.extname(vscode.window.activeTextEditor?.document.uri.fsPath || '')
),
'${cwd}': process.cwd(),
}
// Add all environment variables dynamically
Object.entries(process.env).forEach(([key, value]) => {
if (value) {
variableMap[`$\{env:${key}\}`] = value
}
})
// Substitute all variables in the input string
return Object.entries(variableMap).reduce((result, [variable, value]) => {
return result.replaceAll(variable, value)
}, input)
}
/**
* Retrieves an array of test case items from a TDML (Test Data Markup Language) file.
*
* @param path - The file path to the TDML file.
* @returns An array of test case names
*/
export function getTDMLTestCaseItems(path: string): string[] {
if (!fs.existsSync(path)) {
return [] // TDML file not found
}
// Needed objects for parsing
const parser = new XMLParser({
ignoreAttributes: false,
removeNSPrefix: true,
})
const fileData = fs.readFileSync(path)
// representaiton of TDML XML file as JS object
const xml_obj = parser.parse(fileData)
// Read through TDML test cases and populate each TDML test case item if XML file is valid enough
// parserTestCaseObjs can be either an array of objects, a single object, or undefined if no parserTestCase element was found
// Convert that into an array and return a list containing the names of each parserTestCase element
const parserTestCaseObjs = xml_obj['testSuite']?.['parserTestCase']
const testCaseArr = Array.isArray(parserTestCaseObjs)
? parserTestCaseObjs
: parserTestCaseObjs
? [parserTestCaseObjs]
: []
return testCaseArr.map((item) => item['@_name'])
}
/**
* Displays a VSCode error message as modal. In some cases such as when running tests, using modal
* can cause an error because the dialog service is not available. If this happens when trying
* to display the message, this error is grabbed and then message is displayed with modal disabled.
*
* @param message - The message to display
* @param items — A set of items that will be rendered as actions in the message.
* @returns — A thenable that resolves to the selected item or undefined when being dismissed.
*/
export const displayModalError = async (
message: string,
...items: string[]
): Promise<Thenable<string | undefined>> =>
vscode.window
.showErrorMessage(message, { modal: true }, ...items)
.then(undefined, () =>
vscode.window.showErrorMessage(message, { modal: false })
)
/**
* Download and extract a files with a progress bar
*
* @param title A title to use for printing to the user what is being downloaded
* @param url The url to donwload the binary from
* @param targetDir The directory to target for extraction
*/
export async function downloadAndExtract(
title: string,
url: string,
targetDir: string
): Promise<void> {
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: `Downloading ${title}...`,
cancellable: false,
},
async (progress) => {
progress.report({ increment: 0, message: 'Starting download...' })
const res = await fetch(url)
if (!res.ok || !res.body) {
throw new Error(
`Failed to download ${url}: ${res.status} ${res.statusText}`
)
}
const totalBytes = Number(res.headers.get('content-length')) || 0
let downloaded = 0
let lastPercent = 0
// Transform stream to track download progress
const progressStream = new Transform({
transform(chunk: Buffer, _encoding, callback) {
downloaded += chunk.length
if (totalBytes > 0) {
const percent = (downloaded / totalBytes) * 100
const increment = percent - lastPercent
lastPercent = percent
progress.report({
increment,
message: `${percent.toFixed(1)}%`,
})
}
callback(null, chunk)
},
})
await fs.promises.mkdir(targetDir, { recursive: true })
await pipeline(
res.body as any,
progressStream,
unzip.Extract({ path: targetDir })
)
progress.report({
increment: 100 - lastPercent,
message: 'Extracting complete!',
})
}
)
}