extension/src/goDebugConfiguration: fix debugging package urls

A package URL such as example.com/foo/bar is a valid program for dlv
debug. This updates goDebugConfiguration's handling of the program
attribute to prevent it from A) treating it as a path (prepending the
workspace folder path) and B) throwing an error due to it not being a
valid filesystem path.

Updates golang/vscode-go#1627.

Change-Id: I9ad15752271c84fa069106c3a96206b6edcc3797
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/661195
kokoro-CI: kokoro <noreply+kokoro@google.com>
Reviewed-by: Hongxiang Jiang <hxjiang@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/extension/src/goDebugConfiguration.ts b/extension/src/goDebugConfiguration.ts
index 723a9ad..94c7e12 100644
--- a/extension/src/goDebugConfiguration.ts
+++ b/extension/src/goDebugConfiguration.ts
@@ -488,24 +488,45 @@
 		debugConfiguration['env'] = Object.assign(goToolsEnvVars, fileEnvs, env);
 		debugConfiguration['envFile'] = undefined; // unset, since we already processed.
 
-		const entriesWithRelativePaths = ['cwd', 'output', 'program'].filter(
-			(attr) => debugConfiguration[attr] && !path.isAbsolute(debugConfiguration[attr])
-		);
 		if (debugAdapter === 'dlv-dap') {
-			// 1. Relative paths -> absolute paths
-			if (entriesWithRelativePaths.length > 0) {
-				const workspaceRoot = folder?.uri.fsPath;
-				if (workspaceRoot) {
-					entriesWithRelativePaths.forEach((attr) => {
-						debugConfiguration[attr] = path.join(workspaceRoot, debugConfiguration[attr]);
-					});
-				} else {
+			// If the user provides a relative path outside of a workspace
+			// folder, warn them, but only once.
+			let didWarn = false;
+			const makeRelative = (s: string) => {
+				if (folder) {
+					return path.join(folder.uri.fsPath, s);
+				}
+
+				if (!didWarn) {
+					didWarn = true;
 					this.showWarning(
 						'relativePathsWithoutWorkspaceFolder',
 						'Behavior when using relative paths without a workspace folder for `cwd`, `program`, or `output` is undefined.'
 					);
 				}
-			}
+
+				return s;
+			};
+
+			// 1. Relative paths -> absolute paths
+			['cwd', 'output', 'program'].forEach((attr) => {
+				const value = debugConfiguration[attr];
+				if (!value || path.isAbsolute(value)) return;
+
+				// Make the path relative (the program attribute needs
+				// additional checks).
+				if (attr !== 'program') {
+					debugConfiguration[attr] = makeRelative(value);
+					return;
+				}
+
+				// If the program could be a package URL, don't alter it unless
+				// we can confirm that it is also a file path.
+				if (!isPackageUrl(value) || isFsPath(value, folder?.uri.fsPath)) {
+					debugConfiguration[attr] = makeRelative(value);
+				}
+			});
+
 			// 2. For launch debug/test modes that builds the debug target,
 			//    delve needs to be launched from the right directory (inside the main module of the target).
 			//    Compute the launch dir heuristically, and translate the dirname in program to a path relative to buildDir.
@@ -518,7 +539,8 @@
 					// with a relative path. (https://github.com/golang/vscode-go/issues/1713)
 					// parseDebugProgramArgSync will throw an error if `program` is invalid.
 					const { program, dirname, programIsDirectory } = parseDebugProgramArgSync(
-						debugConfiguration['program']
+						debugConfiguration['program'],
+						folder?.uri.fsPath
 					);
 					if (
 						dirname &&
@@ -583,11 +605,15 @@
 
 // parseDebugProgramArgSync parses program arg of debug/auto/test launch requests.
 export function parseDebugProgramArgSync(
-	program: string
-): { program: string; dirname: string; programIsDirectory: boolean } {
+	program: string,
+	cwd?: string
+): { program: string; dirname?: string; programIsDirectory: boolean } {
 	if (!program) {
 		throw new Error('The program attribute is missing in the debug configuration in launch.json');
 	}
+	if (isPackageUrl(program) && !isFsPath(program, cwd)) {
+		return { program, programIsDirectory: true };
+	}
 	try {
 		const pstats = lstatSync(program);
 		if (pstats.isDirectory()) {
@@ -606,3 +632,47 @@
 		`The program attribute '${program}' must be a valid directory or .go file in debug/test/auto modes.`
 	);
 }
+
+/**
+ * Returns true if the given string is an absolute path or refers to a file or
+ * directory in the current working directory, or `wd` if specified.
+ * @param s The prospective file or directory path.
+ * @param wd The working directory to use instead of `process.cwd()`.
+ */
+function isFsPath(s: string, wd?: string) {
+	// If it's absolute, it's a path.
+	if (path.isAbsolute(s)) return;
+
+	// If the caller specifies a working directory, make the prospective path
+	// absolute.
+	if (wd) s = path.join(wd, s);
+
+	try {
+		// If lstat doesn't throw, the path refers to a file or directory.
+		lstatSync(s);
+		return true;
+	} catch (error) {
+		// ENOENT means nothing exists at the specified path.
+		if (error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT') {
+			return false;
+		}
+
+		// If the error is something unexpected, rethrow it.
+		throw error;
+	}
+}
+
+function isPackageUrl(s: string) {
+	// If the string does not contain `/` and ends with .go it is most likely
+	// intended to be a file path. If the file exists it would be caught by
+	// isFsPath, but otherwise "the file doesn't exist" is much less confusing
+	// than "the package doesn't exist" if the user is trying to execute a test
+	// file and got the path wrong.
+	if (s.match(/^[^/]*\.go$/)) {
+		return s;
+	}
+
+	// If the string starts with domain.tld/ and it doesn't reference a file,
+	// assume it's a package URL
+	return s.match(/^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9](\.[a-zA-Z]{2,})+\//);
+}
diff --git a/extension/test/integration/goDebug.test.ts b/extension/test/integration/goDebug.test.ts
index 8db072b..cd0ef62 100644
--- a/extension/test/integration/goDebug.test.ts
+++ b/extension/test/integration/goDebug.test.ts
@@ -2051,6 +2051,7 @@
 		// second test has a chance to run it.
 		if (!config['output'] && ['debug', 'auto', 'test'].includes(config['mode'])) {
 			const dir = parseDebugProgramArgSync(config['program']).dirname;
+			if (!dir) throw new Error('Debug configuration does not define an output directory');
 			config['output'] = path.join(dir, `__debug_bin_${testNumber}`);
 		}
 		testNumber++;
diff --git a/extension/test/integration/goDebugConfiguration.test.ts b/extension/test/integration/goDebugConfiguration.test.ts
index e2e21a1..877e72c 100644
--- a/extension/test/integration/goDebugConfiguration.test.ts
+++ b/extension/test/integration/goDebugConfiguration.test.ts
@@ -717,6 +717,29 @@
 		);
 	});
 
+	test('allow package path in dlv-dap mode', () => {
+		const config = debugConfig('dlv-dap');
+		config.program = 'example.com/foo/bar';
+
+		const workspaceFolder = {
+			uri: vscode.Uri.file(workspaceDir),
+			name: 'test',
+			index: 0
+		};
+		const { program, cwd, __buildDir } = debugConfigProvider.resolveDebugConfigurationWithSubstitutedVariables(
+			workspaceFolder,
+			config
+		)!;
+		assert.deepStrictEqual(
+			{ program, cwd, __buildDir },
+			{
+				program: 'example.com/foo/bar',
+				cwd: workspaceDir,
+				__buildDir: undefined
+			}
+		);
+	});
+
 	test('program and __buildDir are updated while resolving debug configuration in dlv-dap mode', () => {
 		createDirRecursively(path.join(workspaceDir, 'foo', 'bar', 'pkg'));