Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 215 additions & 1 deletion apps/desktop/src/lib/trpc/routers/external/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import os from "node:os";
import path from "node:path";
import { getAppCommand, resolvePath } from "./helpers";
import { getAppCommand, resolvePath, stripPathWrappers } from "./helpers";

describe("getAppCommand", () => {
test("returns null for finder (handled specially)", () => {
Expand Down Expand Up @@ -211,4 +211,218 @@ describe("resolvePath", () => {
expect(result).toBe("/absolute/path/file.ts");
});
});

describe("wrapper character stripping", () => {
test("strips double quotes from path", () => {
const result = resolvePath('"/absolute/path/file.ts"');
expect(result).toBe("/absolute/path/file.ts");
});

test("strips single quotes from path", () => {
const result = resolvePath("'/absolute/path/file.ts'");
expect(result).toBe("/absolute/path/file.ts");
});

test("strips backticks from path", () => {
const result = resolvePath("`/absolute/path/file.ts`");
expect(result).toBe("/absolute/path/file.ts");
});

test("strips parentheses from path", () => {
const result = resolvePath("(/absolute/path/file.ts)");
expect(result).toBe("/absolute/path/file.ts");
});

test("strips square brackets from path", () => {
const result = resolvePath("[/absolute/path/file.ts]");
expect(result).toBe("/absolute/path/file.ts");
});

test("strips angle brackets from path", () => {
const result = resolvePath("</absolute/path/file.ts>");
expect(result).toBe("/absolute/path/file.ts");
});

test("strips nested wrappers", () => {
const result = resolvePath("\"'/absolute/path/file.ts'\"");
expect(result).toBe("/absolute/path/file.ts");
});

test("strips wrappers with leading/trailing whitespace", () => {
const result = resolvePath(' "/absolute/path/file.ts" ');
expect(result).toBe("/absolute/path/file.ts");
});

test("handles wrappers combined with ~ expansion", () => {
const result = resolvePath('"~/Documents/file.ts"');
expect(result).toBe(path.join(homedir, "Documents/file.ts"));
});

test("handles wrappers combined with relative paths", () => {
const result = resolvePath("(src/file.ts)", "/project");
expect(result).toBe("/project/src/file.ts");
});
});
});

describe("stripPathWrappers", () => {
describe("single wrapper types", () => {
test("strips double quotes", () => {
expect(stripPathWrappers('"/path/to/file"')).toBe("/path/to/file");
});

test("strips single quotes", () => {
expect(stripPathWrappers("'/path/to/file'")).toBe("/path/to/file");
});

test("strips backticks", () => {
expect(stripPathWrappers("`/path/to/file`")).toBe("/path/to/file");
});

test("strips parentheses", () => {
expect(stripPathWrappers("(/path/to/file)")).toBe("/path/to/file");
});

test("strips square brackets", () => {
expect(stripPathWrappers("[/path/to/file]")).toBe("/path/to/file");
});

test("strips angle brackets", () => {
expect(stripPathWrappers("</path/to/file>")).toBe("/path/to/file");
});
});

describe("nested wrappers", () => {
test("strips multiple layers of same wrapper", () => {
expect(stripPathWrappers('"""/path/to/file"""')).toBe("/path/to/file");
});

test("strips mixed nested wrappers", () => {
expect(stripPathWrappers("\"'/path/to/file'\"")).toBe("/path/to/file");
});

test("strips deeply nested mixed wrappers", () => {
expect(stripPathWrappers("\"('[/path/to/file]')\"")).toBe(
"/path/to/file",
);
});
});

describe("edge cases", () => {
test("returns empty string for empty input", () => {
expect(stripPathWrappers("")).toBe("");
});

test("returns trimmed string for whitespace only", () => {
expect(stripPathWrappers(" ")).toBe("");
});

test("trims surrounding whitespace", () => {
expect(stripPathWrappers(' "/path/to/file" ')).toBe("/path/to/file");
});

test("does not strip mismatched wrappers", () => {
expect(stripPathWrappers('"/path/to/file)')).toBe('"/path/to/file)');
});

test("does not strip opening wrapper only", () => {
expect(stripPathWrappers('"/path/to/file')).toBe('"/path/to/file');
});

test("does not strip closing wrapper only", () => {
expect(stripPathWrappers('/path/to/file"')).toBe('/path/to/file"');
});

test("preserves path with internal wrappers", () => {
expect(stripPathWrappers("/path/to/(file)")).toBe("/path/to/(file)");
});

test("preserves path with no wrappers", () => {
expect(stripPathWrappers("/path/to/file")).toBe("/path/to/file");
});

test("handles single character inside wrappers", () => {
expect(stripPathWrappers('"a"')).toBe("a");
});

test("handles wrappers with only whitespace inside", () => {
expect(stripPathWrappers('" "')).toBe(" ");
});
});

describe("trailing punctuation", () => {
test("strips trailing period", () => {
expect(stripPathWrappers("./path/file.ts.")).toBe("./path/file.ts");
});

test("strips trailing comma", () => {
expect(stripPathWrappers("./path/file.ts,")).toBe("./path/file.ts");
});

test("strips trailing colon", () => {
expect(stripPathWrappers("./path/file.ts:")).toBe("./path/file.ts");
});

test("strips trailing semicolon", () => {
expect(stripPathWrappers("./path/file.ts;")).toBe("./path/file.ts");
});

test("strips trailing question mark", () => {
expect(stripPathWrappers("./path/file.ts?")).toBe("./path/file.ts");
});

test("strips trailing exclamation", () => {
expect(stripPathWrappers("./path/file.ts!")).toBe("./path/file.ts");
});

test("strips multiple trailing punctuation", () => {
expect(stripPathWrappers("./path/file.ts..")).toBe("./path/file.ts");
});

test("strips mixed trailing punctuation", () => {
expect(stripPathWrappers("./path/file.ts.,")).toBe("./path/file.ts");
});

test("preserves file extension", () => {
expect(stripPathWrappers("./path/file.ts")).toBe("./path/file.ts");
});

test("preserves .json extension", () => {
expect(stripPathWrappers("./path/file.json")).toBe("./path/file.json");
});

test("preserves multi-dot extensions like .test.ts", () => {
expect(stripPathWrappers("./path/file.test.ts")).toBe(
"./path/file.test.ts",
);
});

test("preserves line number suffix :42", () => {
expect(stripPathWrappers("./path/file.ts:42")).toBe("./path/file.ts:42");
});

test("preserves line:col suffix :42:10", () => {
expect(stripPathWrappers("./path/file.ts:42:10")).toBe(
"./path/file.ts:42:10",
);
});
});

describe("wrappers with trailing punctuation", () => {
test("quoted path with trailing period", () => {
expect(stripPathWrappers('"./path/file.ts".')).toBe("./path/file.ts");
});

test("quoted path with trailing comma", () => {
expect(stripPathWrappers('"./path/file.ts",')).toBe("./path/file.ts");
});

test("parenthesized path with trailing period", () => {
expect(stripPathWrappers("(./path/file.ts).")).toBe("./path/file.ts");
});

test("complex nested with trailing punctuation", () => {
expect(stripPathWrappers('"(./path/file.ts)".')).toBe("./path/file.ts");
});
});
});
117 changes: 112 additions & 5 deletions apps/desktop/src/lib/trpc/routers/external/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,115 @@ export function getAppCommand(
return { command: "open", args: ["-a", appName, targetPath] };
}

/**
* Wrapper characters that can surround paths.
* These are pairs of [open, close] characters.
*/
const PATH_WRAPPERS: [string, string][] = [
['"', '"'], // double quotes
["'", "'"], // single quotes
["`", "`"], // backticks
["(", ")"], // parentheses
["[", "]"], // square brackets
["<", ">"], // angle brackets
];

/**
* Trailing punctuation that can appear after paths in sentences.
* These are stripped unless they're part of a valid suffix (extension, line:col).
*/
const TRAILING_PUNCTUATION = /[.,;:!?]+$/;

/**
* Strip trailing punctuation from a path, but preserve valid suffixes.
* - Preserves file extensions like .ts, .json
* - Preserves line:col suffixes like :42 or :42:10
* - Strips sentence punctuation like trailing period, comma, etc.
*/
function stripTrailingPunctuation(path: string): string {
const match = path.match(TRAILING_PUNCTUATION);
if (!match) return path;

const punct = match[0];
const beforePunct = path.slice(0, -punct.length);

// Don't strip if it looks like a file extension (e.g., "file.ts")
// Extension: period followed by 1-10 alphanumeric chars at the end
if (punct === "." || punct.startsWith(".")) {
// Check if what's before looks like it ends with a valid extension
const extMatch = beforePunct.match(/\.[a-zA-Z0-9]{1,10}$/);
if (extMatch) {
// This trailing period is after an extension, strip just the trailing punct
return beforePunct;
}
// Check if the punct itself could be part of an extension
// e.g., path ends with ".ts." - strip just the final "."
if (/^\.[a-zA-Z0-9]{1,10}\.$/.test(punct)) {
return path.slice(0, -1);
}
}

// Don't strip colons that are followed by digits (line numbers)
// But do strip trailing colons with no digits
if (punct === ":") {
return beforePunct;
}
if (punct.startsWith(":") && /^:\d/.test(punct)) {
// This is a line number suffix, keep it
return path;
}

return beforePunct;
}

/**
* Strip matching wrapper characters and trailing punctuation from a path.
* Handles nested wrappers and multiple layers of wrapping.
* Examples:
* "(path/to/file)" -> "path/to/file"
* '"path/to/file"' -> "path/to/file"
* "'(path/to/file)'" -> "path/to/file"
* "./path/file.ts." -> "./path/file.ts"
* '"./path/file.ts",' -> "./path/file.ts"
* "path/to/file" -> "path/to/file" (unchanged)
*/
export function stripPathWrappers(filePath: string): string {
let result = filePath.trim();

// Keep stripping wrappers and trailing punctuation until no more changes
let changed = true;
while (changed && result.length > 0) {
changed = false;

// First, try to strip trailing punctuation
const withoutPunct = stripTrailingPunctuation(result);
if (withoutPunct !== result) {
result = withoutPunct;
changed = true;
continue;
}

// Then, try to strip wrappers
for (const [open, close] of PATH_WRAPPERS) {
if (result.startsWith(open) && result.endsWith(close)) {
result = result.slice(1, -1);
changed = true;
break;
}
}
}

return result;
}

/**
* Resolve a path by expanding ~ and converting relative paths to absolute.
* Also handles file:// URLs by converting them to regular file paths.
* Strips wrapping characters like quotes, parentheses, brackets, etc.
*/
export function resolvePath(filePath: string, cwd?: string): string {
let resolved = filePath;
// First strip any wrapping characters (quotes, parentheses, etc.)
let resolved = stripPathWrappers(filePath);

if (resolved.startsWith("file://")) {
try {
Expand Down Expand Up @@ -80,10 +183,15 @@ export function resolvePath(filePath: string, cwd?: string): string {
export function spawnAsync(command: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: "ignore",
stdio: ["ignore", "ignore", "pipe"],
detached: false,
});

let stderr = "";
child.stderr?.on("data", (data) => {
stderr += data.toString();
});

child.on("error", (error) => {
reject(
new Error(
Expand All @@ -96,10 +204,9 @@ export function spawnAsync(command: string, args: string[]): Promise<void> {
if (code === 0) {
resolve();
} else {
const stderrMessage = stderr.trim();
reject(
new Error(
`'${command}' exited with code ${code}. The application may not be installed.`,
),
new Error(stderrMessage || `'${command}' exited with code ${code}`),
);
}
});
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/lib/trpc/routers/external/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
EXTERNAL_APPS,
type ExternalApp,
getAppCommand,
resolvePath,
resolvePathWithFallback,
Comment thread
Kitenite marked this conversation as resolved.
Outdated
spawnAsync,
} from "./helpers";

Expand Down
Loading