Skip to content

Commit 807166c

Browse files
committed
fix: handle zizmor not found case gracefullier
Is that even a word?
1 parent 96bb4af commit 807166c

File tree

4 files changed

+193
-40
lines changed

4 files changed

+193
-40
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
!.vscode/
2+
.vscode/settings.json
23

34
out/
45
dist/

package-lock.json

Lines changed: 31 additions & 23 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/extension.ts

Lines changed: 82 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,76 @@
1-
import * as vscode from 'vscode';
1+
import { exec } from 'child_process';
2+
import * as fs from 'fs/promises';
23
import * as os from 'os';
34
import * as path from 'path';
4-
import { exec } from 'child_process';
55
import { promisify } from 'util';
6+
import * as vscode from 'vscode';
67
import {
8+
Executable,
79
LanguageClient,
810
LanguageClientOptions,
911
ServerOptions,
10-
TransportKind,
11-
Executable
12+
TransportKind
1213
} from 'vscode-languageclient/node';
14+
import { which } from './which';
1315

1416
let client: LanguageClient;
1517

1618
const execAsync = promisify(exec);
1719
const MIN_ZIZMOR_VERSION = '1.11.0';
1820

21+
async function getZizmor(config: vscode.WorkspaceConfiguration): Promise<string | undefined> {
22+
const rawConfiguredPath = config.get<string>('executablePath');
23+
24+
if (rawConfiguredPath) {
25+
const configuredPath = await which(expandTilde(rawConfiguredPath));
26+
27+
if (configuredPath) {
28+
console.log(`Using configured zizmor: ${configuredPath}`);
29+
return configuredPath;
30+
} else {
31+
console.warn(`Configured zizmor not found: ${rawConfiguredPath}`)
32+
}
33+
}
34+
35+
const pathPath = await which('zizmor')
36+
if (pathPath) {
37+
console.log(`Using zizmor from PATH: ${pathPath}`)
38+
return pathPath;
39+
}
40+
41+
// It's particularly important to check common locations on macOS because of https://github.com/microsoft/vscode/issues/30847#issuecomment-420399383.
42+
const commonPathsLocal = [
43+
path.join(os.homedir(), ".cargo", "bin"),
44+
path.join(os.homedir(), ".nix-profile", "bin"),
45+
path.join(os.homedir(), ".local", "bin"),
46+
path.join(os.homedir(), "bin"),
47+
]
48+
const commonPathsUnixGlobal = [
49+
"/usr/bin/",
50+
"/home/linuxbrew/.linuxbrew/bin/",
51+
"/usr/local/bin/",
52+
"/opt/homebrew/bin/",
53+
"/opt/local/bin/",
54+
];
55+
56+
// Windows supports forward slashes, but these paths won't work without drive letters.
57+
// I figure old DOSes we don't support might not support forward slashes, so this is a good enough check.
58+
const commonPaths = path.sep == "/" ? [...commonPathsLocal, ...commonPathsUnixGlobal] : commonPathsLocal
59+
60+
const commonPath = await which('zizmor', {
61+
path: commonPaths.join('\0'),
62+
delimiter: '\0'
63+
})
64+
65+
if (commonPath !== undefined) {
66+
console.log(`Using common zizmor path: ${commonPath}`)
67+
return commonPath;
68+
}
69+
70+
return undefined;
71+
}
72+
73+
1974
/**
2075
* Expands tilde (~) in file paths to the user's home directory
2176
*/
@@ -78,7 +133,7 @@ async function checkZizmorVersion(executablePath: string): Promise<{ isValid: bo
78133
}
79134
}
80135

81-
export function activate(context: vscode.ExtensionContext) {
136+
export async function activate(context: vscode.ExtensionContext) {
82137
// Get configuration
83138
const config = vscode.workspace.getConfiguration('zizmor');
84139
const enabled = config.get<boolean>('enable', true);
@@ -87,12 +142,19 @@ export function activate(context: vscode.ExtensionContext) {
87142
return;
88143
}
89144

90-
// Get the path to the zizmor executable
91-
const rawExecutablePath = config.get<string>('executablePath', 'zizmor');
92-
const executablePath = expandTilde(rawExecutablePath);
145+
try {
146+
// Get the path to the zizmor executable
147+
const executablePath = await getZizmor(config)
148+
149+
if (!executablePath) {
150+
console.error('zizmor was not found');
151+
vscode.window.showErrorMessage('zizmor was not found');
152+
return;
153+
}
154+
155+
// Check zizmor version before starting the language server
156+
const versionCheck = await checkZizmorVersion(executablePath)
93157

94-
// Check zizmor version before starting the language server
95-
checkZizmorVersion(executablePath).then(versionCheck => {
96158
if (!versionCheck.isValid) {
97159
const errorMessage = versionCheck.version
98160
? `zizmor version ${versionCheck.version} is too old. This extension requires zizmor ${MIN_ZIZMOR_VERSION} or newer. Please update zizmor and try again.`
@@ -105,31 +167,34 @@ export function activate(context: vscode.ExtensionContext) {
105167

106168
console.log(`zizmor version ${versionCheck.version} meets minimum requirement (${MIN_ZIZMOR_VERSION})`);
107169
startLanguageServer(context, executablePath);
108-
}).catch(error => {
109-
const errorMessage = `Failed to start zizmor language server: ${error.message}`;
170+
} catch (error) {
171+
const errorMessage = `Failed to start zizmor language server: ${(error as Error).message}`;
110172
console.error('zizmor activation failed:', error);
111173
vscode.window.showErrorMessage(errorMessage);
112-
});
174+
};
113175
}
114176

115177
function startLanguageServer(context: vscode.ExtensionContext, executablePath: string) {
116178

117179
// Define the server options
118-
const serverExecutable: Executable = {
180+
const serverOptions: Executable & ServerOptions = {
119181
command: executablePath,
120182
args: ['--lsp'],
121-
transport: TransportKind.stdio
183+
transport: TransportKind.stdio,
122184
};
123185

124-
const serverOptions: ServerOptions = serverExecutable;
186+
const config = vscode.workspace.getConfiguration('zizmor.trace');
187+
const shouldTrace = config.get<"off" | "messages" | "verbose">('server', "off");
188+
189+
const traceChannel = shouldTrace !== "off" ? vscode.window.createOutputChannel('zizmor LSP trace') : undefined
125190

126191
const clientOptions: LanguageClientOptions = {
127192
documentSelector: [
128193
{ scheme: 'file', language: 'yaml', pattern: '**/.github/workflows/*.{yml,yaml}' },
129194
{ scheme: 'file', language: 'yaml', pattern: '**/action.{yml,yaml}' },
130195
{ scheme: 'file', language: 'yaml', pattern: '**/.github/dependabot.{yml,yaml}' },
131196
],
132-
traceOutputChannel: vscode.window.createOutputChannel('zizmor LSP trace')
197+
traceOutputChannel: traceChannel
133198
};
134199

135200
client = new LanguageClient(

src/which.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { join, delimiter, sep, posix } from 'node:path'
2+
import * as fs from 'fs/promises'
3+
4+
/** Options bag */
5+
export interface Options {
6+
/** Use instead of the PATH environment variable. */
7+
path?: string | undefined;
8+
/** Use instead of the PATHEXT environment variable. */
9+
pathExt?: string | undefined;
10+
/** Use instead of the platform's native path separator. */
11+
delimiter?: string | undefined;
12+
}
13+
14+
const isWindows = process.platform === 'win32'
15+
16+
/**
17+
* Used to check for slashed in commands passed in.
18+
* Always checks for the POSIX separator on all platforms,
19+
* and checks for the current separator when not on a POSIX platform.
20+
*/
21+
const rSlash = new RegExp(`[${posix.sep}${sep === posix.sep ? '' : sep}]`.replace(/(\\)/g, '\\$1'))
22+
const rRel = new RegExp(`^\\.${rSlash.source}`)
23+
24+
const getPathInfo = (cmd: string, {
25+
path: optPath = process.env.PATH,
26+
pathExt: optPathExt = process.env.PATHEXT,
27+
delimiter: optDelimiter = delimiter,
28+
}: Partial<Options>) => {
29+
// If it has a slash, then we don't bother searching the pathenv.
30+
// just check the file itself, and that's it.
31+
const pathEnv = cmd.match(rSlash) ? [''] : [
32+
// Windows always checks the cwd first.
33+
...(isWindows ? [process.cwd()] : []),
34+
...(optPath ?? '').split(optDelimiter),
35+
]
36+
37+
if (isWindows) {
38+
const pathExtExe = optPathExt ??
39+
['.EXE', '.CMD', '.BAT', '.COM'].join(optDelimiter)
40+
const pathExt = pathExtExe.split(optDelimiter).flatMap((item) => [item, item.toLowerCase()])
41+
if (cmd.includes('.') && pathExt[0] !== '') {
42+
pathExt.unshift('')
43+
}
44+
return { pathEnv, pathExt }
45+
}
46+
47+
return { pathEnv, pathExt: [''] }
48+
}
49+
50+
const getPathPart = (raw: string, cmd: string) => {
51+
const pathPart = /^".*"$/.test(raw) ? raw.slice(1, -1) : raw
52+
const prefix = !pathPart && rRel.test(cmd) ? cmd.slice(0, 2) : ''
53+
return prefix + join(pathPart, cmd)
54+
}
55+
56+
export const which = async (
57+
cmd: string,
58+
options: Options = {},
59+
): Promise<string | undefined> => {
60+
const { pathEnv, pathExt } = getPathInfo(cmd, options)
61+
62+
for (const envPart of pathEnv) {
63+
const p = getPathPart(envPart, cmd)
64+
65+
for (const ext of pathExt) {
66+
const candidate = p + ext;
67+
68+
try {
69+
// Check that the file exists and is (on POSIX) executable.
70+
// X_OK acts like F_OK on Windows.
71+
await fs.access(candidate, fs.constants.X_OK)
72+
return candidate;
73+
} catch {
74+
}
75+
}
76+
}
77+
78+
return undefined
79+
}

0 commit comments

Comments
 (0)