diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cb8ad9..9f06002 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,12 +17,11 @@ jobs: - macos-latest - windows-latest node: - - 20 + - 22 code-version: - stable - 1.66.0 include: - - { os: ubuntu-latest, node: 22, code-version: stable } - { os: ubuntu-latest, node: 24, code-version: stable } runs-on: ${{ matrix.os }} name: Test with Node ${{ matrix.node }} and VSCode ${{ matrix.code-version }} on ${{ matrix.os }} diff --git a/CHANGELOG.md b/CHANGELOG.md index a44ec8d..5b831fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## Next +* Use module build tasks by default, and run programs without GDB when not debugging [PR #44](https://github.com/ocamlpro/superbol-vscode-debug/pull/44) * Enable customization of the COBOL runtime [PR #42](https://github.com/ocamlpro/superbol-vscode-debug/pull/42) * Show global data items in Variables panel [PR #40](https://github.com/ocamlpro/superbol-vscode-debug/pull/40) * Fixed a bug that made the extension hang when debugged programs displayed signed numbers [PR #39](https://github.com/ocamlpro/superbol-vscode-debug/pull/39) diff --git a/package-lock.json b/package-lock.json index ba0e4e3..8d065db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "@typescript-eslint/parser": "^6.7.2", "@vscode/debugadapter-testsupport": "^1.51.0", "@vscode/test-cli": "^0.0.12", - "@vscode/test-electron": "^2.5.2", + "@vscode/test-electron": "^3.1.0", "eslint": "^8.50.0", "eslint_d": "^12.2.1", "mocha": "^10.8.2", @@ -799,9 +799,9 @@ "license": "Apache-2.0" }, "node_modules/@vscode/test-electron": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.5.2.tgz", - "integrity": "sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-3.1.0.tgz", + "integrity": "sha512-CRqv5u+YYoseuNVJ6Tyo4k0sF0mx4qnKMihRB0PjsUF8Dc0WKtCXo6CNL6nWWm5esfFQsQA/pejMj4ZbpJVLTw==", "dev": true, "license": "MIT", "dependencies": { @@ -812,7 +812,7 @@ "semver": "^7.6.2" }, "engines": { - "node": ">=16" + "node": ">=22" } }, "node_modules/acorn": { diff --git a/package.json b/package.json index 716baa3..1390827 100644 --- a/package.json +++ b/package.json @@ -298,7 +298,7 @@ "@typescript-eslint/parser": "^6.7.2", "@vscode/debugadapter-testsupport": "^1.51.0", "@vscode/test-cli": "^0.0.12", - "@vscode/test-electron": "^2.5.2", + "@vscode/test-electron": "^3.1.0", "eslint": "^8.50.0", "eslint_d": "^12.2.1", "mocha": "^10.8.2", diff --git a/src/extension.ts b/src/extension.ts index 8d7b7b7..1a7c331 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -13,14 +13,23 @@ const COBOL_RESERVED_WORDS = ["perform", "move", "to", "set", "add", "subtract", const superbolConsts = { taskType: "superbol", taskSource: "SuperBOL", - debugBuildTaskName: "build (debug)", + buildTaskName: "build module", + debugBuildTaskName: "build module (debug)", } -const defaultDebugBuildTask = `${superbolConsts.taskSource}: ${superbolConsts.debugBuildTaskName}`; -async function checkForSuperBOLBuildTask () { +/** Name of the default build task: debug build, unless we run without debugging. */ +function defaultBuildTaskName (noDebug: boolean | undefined) { + return noDebug ? superbolConsts.buildTaskName : superbolConsts.debugBuildTaskName; +} + +function taskLabel (taskName: string) { + return `${superbolConsts.taskSource}: ${taskName}`; +} + +async function checkForSuperBOLBuildTask (taskName: string) { return vscode.tasks.fetchTasks({ type: superbolConsts.taskType }).then((tasks) => tasks.find(task => task.source == superbolConsts.taskSource && - task.name == superbolConsts.debugBuildTaskName) != undefined + task.name == taskName) != undefined ); } @@ -44,7 +53,8 @@ export function deactivate() { class GdbConfigurationProvider implements vscode.DebugConfigurationProvider { public resolveDebugConfiguration(workspaceFolder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration, _token?: vscode.CancellationToken): vscode.ProviderResult { config.gdbargs = ["-q", "--interpreter=mi2"]; - return checkForSuperBOLBuildTask().then(haveSuperBOLBuildTask => { + const buildTaskName = defaultBuildTaskName(config.noDebug); + return checkForSuperBOLBuildTask(buildTaskName).then(haveSuperBOLBuildTask => { if (config.name === undefined && haveSuperBOLBuildTask) { config.name = "SuperBOL: default debug"; } @@ -58,7 +68,11 @@ class GdbConfigurationProvider implements vscode.DebugConfigurationProvider { workspaceFolder != undefined && config.request != "attach" && config.preLaunchTask === undefined) { - config.preLaunchTask = defaultDebugBuildTask; + config.preLaunchTask = taskLabel(buildTaskName); + // Default build tasks produce modules, so we run them with cobcrun. + if (config.useCobcrun === undefined) { + config.useCobcrun = true; + } } else if (config.preLaunchTask === "") { delete config.preLaunchTask; } @@ -72,7 +86,7 @@ class GdbConfigurationProvider implements vscode.DebugConfigurationProvider { config.group = []; } const libcobpath = settings.libcobPath; - if (libcobpath != undefined) { + if (libcobpath) { if (config.env === undefined) { config.env = { ["LD_LIBRARY_PATH"]: libcobpath }; } else { @@ -92,12 +106,14 @@ class GdbConfigurationProvider implements vscode.DebugConfigurationProvider { public provideDebugConfigurations(_folder: vscode.WorkspaceFolder, _token?: vscode.CancellationToken) : vscode.ProviderResult { - return checkForSuperBOLBuildTask().then(haveSuperBOLBuildTask => { + const buildTaskName = superbolConsts.debugBuildTaskName; + return checkForSuperBOLBuildTask(buildTaskName).then(haveSuperBOLBuildTask => { const launchConfigDefault: vscode.DebugConfiguration = { name: "SuperBOL: debug (launch)", type: "superbol-gdb", request: "launch", - preLaunchTask: haveSuperBOLBuildTask ? defaultDebugBuildTask : undefined, + preLaunchTask: haveSuperBOLBuildTask ? taskLabel(buildTaskName) : undefined, + useCobcrun: haveSuperBOLBuildTask ? true : undefined, target: "${file}", arguments: "" }; diff --git a/src/gdb.ts b/src/gdb.ts index 90bbbdd..15cc000 100644 --- a/src/gdb.ts +++ b/src/gdb.ts @@ -16,7 +16,8 @@ import {DebugProtocol} from '@vscode/debugprotocol'; import {Breakpoint, DebuggerVariable, localizeSymbols} from './debugger'; import {MINode} from './parser.mi2'; import * as path from "path"; -import {MI2} from './mi2'; +import * as fs from "fs"; +import {MI2, cobExecutablePath, cobcrunArgs, removePathExtension} from './mi2'; import {CoverageStatus} from './coverage'; import {accessors as settings} from './settings'; import * as log from './log'; @@ -64,6 +65,24 @@ export interface AttachRequestArguments extends DebugProtocol.AttachRequestArgum sourceDirs: string[]; } +/** Module file extensions, as produced by `cobc -m`. */ +const MODULE_EXTENSIONS = [".so", ".dylib", ".dll"]; + +/** Time (in ms) to wait for the client to start the program in a terminal. */ +const RUN_IN_TERMINAL_TIMEOUT_MS = 10000; + +/** Identifier of the error sent when the program cannot be run in a terminal + * (follows the launch errors 100 and 103 below). */ +const RUN_IN_TERMINAL_ERROR_ID = 104; + +/** Guess if the target was built as a module: there is a module file but no + * executable. */ +function isModuleTarget(target: string, cwd: string): boolean { + const name = path.join(cwd, removePathExtension(target)); + return !fs.existsSync(cobExecutablePath(target, cwd)) && + MODULE_EXTENSIONS.some(ext => fs.existsSync(name + ext)); +} + function initLogLevel(verbose: boolean) { if (verbose) { log.setLevel(log.Level.Debug); @@ -86,19 +105,61 @@ export class GDBDebugSession extends DebugSession { coverageStatus: CoverageStatus; private showCoverage: boolean = true; private readonly showDetails = settings.displayVariableAttributes; + private supportsRunInTerminal: boolean = false; - protected initializeRequest(response: DebugProtocol.InitializeResponse, _args: DebugProtocol.InitializeRequestArguments): void { + protected initializeRequest(response: DebugProtocol.InitializeResponse, args: DebugProtocol.InitializeRequestArguments): void { + this.supportsRunInTerminal = !!args.supportsRunInTerminalRequest; response.body.supportsSetVariable = true; this.sendResponse(response); } + /** Run the program in a terminal, without GDB. */ + private runWithoutDebugger(response: DebugProtocol.LaunchResponse, args: LaunchRequestArguments, + cwd: string, cobcrunPath: string): void { + const command = args.useCobcrun + ? [cobcrunPath].concat(cobcrunArgs(args.target, cwd)) + : [cobExecutablePath(args.target, cwd)]; + const programArgs = (args.arguments ?? "").split(/\s+/).filter(a => a !== ""); + const env: { [key: string]: string | null } = {}; + for (const [key, value] of Object.entries(args.env ?? {})) { + env[key] = value ?? null; + } + this.runInTerminalRequest({ + kind: "integrated", + title: removePathExtension(args.target), + cwd: cwd, + args: command.concat(programArgs), + env: env, + }, RUN_IN_TERMINAL_TIMEOUT_MS, (runResponse) => { + if (runResponse.success) { + this.sendResponse(response); + // The program keeps running in the terminal; the session ends here. + this.sendEvent(new TerminatedEvent()); + } else { + this.sendErrorResponse(response, RUN_IN_TERMINAL_ERROR_ID, `Failed to run program: ${runResponse.message}`); + } + }); + } + protected launchRequest(response: DebugProtocol.LaunchResponse, args: LaunchRequestArguments): void { initLogLevel(args.verbose); + const cobcrunPath = args.cobcrunPath ?? settings.cobcrunPath; + // Run in the target executables' directory, unless specified; becomes '.' if target is a module name. + let cwd = args.cwd ?? path.dirname (args.target); + + if (args.useCobcrun === undefined) { + args.useCobcrun = isModuleTarget(args.target, cwd); + } + + if (args.noDebug && this.supportsRunInTerminal) { + this.runWithoutDebugger(response, args, cwd, cobcrunPath); + return; + } + this.showCoverage = args.coverage; this.started = false; this.attached = false; - const cobcrunPath = args.cobcrunPath ?? settings.cobcrunPath; this.miDebugger = new MI2(settings.gdbPath, args.gdbargs, args.env, args.noDebug, args.gdbtty, cobcrunPath, args.useCobcrun, args.sourceDirs); this.miDebugger.on("launcherror", (err: Error) => this.launchError(err)); @@ -118,8 +179,6 @@ export class GDBDebugSession extends DebugSession { this.needContinue = false; this.crashed = false; this.debugReady = false; - // Run in the target executables' directory, unless specified; becomes '.' if target is a module name. - let cwd = args.cwd ?? path.dirname (args.target); this.miDebugger.load(cwd, args.target, args.arguments, args.group, args.gdbtty).then( /*onfulfilled:*/ () => { setTimeout(() => { diff --git a/src/mi2.ts b/src/mi2.ts index 4f94a7f..aafa7be 100644 --- a/src/mi2.ts +++ b/src/mi2.ts @@ -24,10 +24,57 @@ export function escape(str: string) { return str.replace(/\\/g, "\\\\").replace(/"/g, "\\\""); } -function removePathExtension(f: string) : string { +export function removePathExtension(f: string) : string { return path.basename(f, path.extname(f)); } +const programIdRegex = + /PROGRAM-ID\s*\.?\s*(?:"([^"]+)"|'([^']+)'|([\w-]+))(?:\s+AS\s+(?:"([^"]+)"|'([^']+)'))?/i; + +/** Name of the entry point to give to `cobcrun` for the given target: the + * name of the first program in the source file, or the name of the file if it + * cannot be found. It may differ from the name of the module file (e.g, + * `PROGRAM-ID. HELLO` in `hello.cob`), so `cobcrunArgs` also passes the file + * with `-M`. The name is returned as written in the source: `cobcrun` + * encodes it itself (see `cob_encode_program_id` in libcob) when resolving + * the entry point, as for any dynamic `CALL`. */ +function moduleEntryName(target: string, cwd: string): string { + const file = path.resolve(cwd, target); + try { + for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) { + // Skip comment lines (fixed and free formats). + if (/^.{6}[*/]/.test(line) || /^\s*\*>/.test(line)) { + continue; + } + const m = programIdRegex.exec(line); + if (m) { + return m[4] ?? m[5] ?? m[1] ?? m[2] ?? m[3]; + } + } + } catch (e) { + log.debug(`could not read ${file}: ${e}`); + } + return removePathExtension(target); +} + +/** Path to the executable built from the given target. */ +export function cobExecutablePath(target: string, cwd: string): string { + target = removePathExtension(target); + if (!path.isAbsolute(target)) { + target = path.join(cwd, target); + } + if (process.platform === "win32") { + target = target + '.exe'; + } + return target; +} + +/** Arguments to give to `cobcrun` to run the module built from the given + * target. */ +export function cobcrunArgs(target: string, cwd: string): string[] { + return ["-M", removePathExtension(target), moduleEntryName(target, cwd)]; +} + export function couldBeOutput(line: string) { return !nonOutput.exec(line); } @@ -139,14 +186,7 @@ export class MI2 extends EventEmitter implements IDebugger { } private targetCobExecutable(target: string, cwd: string) { - target = removePathExtension(target); - if (!path.isAbsolute(target)) { - target = path.join(cwd, target); - } - if (process.platform === "win32") { - target = target + '.exe'; - } - return escape(target); + return escape(cobExecutablePath(target, cwd)); } private launchCommands(target: string, targetargs: string, cwd: string) { @@ -154,7 +194,7 @@ export class MI2 extends EventEmitter implements IDebugger { ? this.cobcrunPath : this.targetCobExecutable(target, cwd); targetargs = this.useCobcrun - ? `${removePathExtension(target)} ${targetargs}` + ? `${cobcrunArgs(target, cwd).join(" ")} ${targetargs}` : targetargs; return this.commonCommands(cwd).concat([ this.sendCommand("gdb-set args " + targetargs), diff --git a/yarn.lock b/yarn.lock index 7452e69..6dda535 100644 --- a/yarn.lock +++ b/yarn.lock @@ -316,10 +316,10 @@ supports-color "^10.2.2" yargs "^17.7.2" -"@vscode/test-electron@^2.5.2": - version "2.5.2" - resolved "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.5.2.tgz" - integrity sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg== +"@vscode/test-electron@^3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-3.1.0.tgz" + integrity sha512-CRqv5u+YYoseuNVJ6Tyo4k0sF0mx4qnKMihRB0PjsUF8Dc0WKtCXo6CNL6nWWm5esfFQsQA/pejMj4ZbpJVLTw== dependencies: http-proxy-agent "^7.0.2" https-proxy-agent "^7.0.5"