Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
!.vscode/
.vscode/settings.json

out/
dist/
Expand Down
20 changes: 15 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@
"vsce:publish": "vsce publish"
},
"devDependencies": {
"@types/node": "16.x",
"@types/node": "^22.0.0",
"@types/vscode": "^1.105.0",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
Expand Down
92 changes: 75 additions & 17 deletions src/extension.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,69 @@
import * as vscode from 'vscode';
import { exec } from 'child_process';
import * as os from 'os';
import * as path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
import * as vscode from 'vscode';
import {
Executable,
LanguageClient,
LanguageClientOptions,
ServerOptions,
TransportKind,
Executable
TransportKind
} from 'vscode-languageclient/node';
import { which } from './which';

let client: LanguageClient;

const execAsync = promisify(exec);
const MIN_ZIZMOR_VERSION = '1.11.0';

async function getZizmor(config: vscode.WorkspaceConfiguration): Promise<string | undefined> {
const rawConfiguredPath = config.get<string>('executablePath');

if (rawConfiguredPath) {
const configuredPath = await which(expandTilde(rawConfiguredPath));

if (configuredPath) {
console.log(`Using configured zizmor: ${configuredPath}`);
return configuredPath;
} else {
console.warn(`Configured zizmor not found: ${rawConfiguredPath}`);
}
}

const pathPath = await which('zizmor');
if (pathPath) {
console.log(`Using zizmor from PATH: ${pathPath}`);
return pathPath;
}

// It's particularly important to check common locations on macOS because of https://github.com/microsoft/vscode/issues/30847#issuecomment-420399383.
const commonPathsGlobalMacos = [
path.join(os.homedir(), ".cargo", "bin"),
path.join(os.homedir(), ".nix-profile", "bin"),
path.join(os.homedir(), ".local", "bin"),
path.join(os.homedir(), "bin"),
"/usr/bin/",
"/home/linuxbrew/.linuxbrew/bin/",
"/usr/local/bin/",
"/opt/homebrew/bin/",
"/opt/local/bin/",
];

const commonPath = await which('zizmor', os.platform() === "darwin" ? {
path: commonPathsGlobalMacos.join('\0'),
delimiter: '\0'
} : undefined);

if (commonPath !== undefined) {
console.log(`Using common zizmor path: ${commonPath}`);
return commonPath;
}

return undefined;
}


/**
* Expands tilde (~) in file paths to the user's home directory
*/
Expand Down Expand Up @@ -78,7 +126,7 @@ async function checkZizmorVersion(executablePath: string): Promise<{ isValid: bo
}
}

export function activate(context: vscode.ExtensionContext) {
export async function activate(context: vscode.ExtensionContext) {
// Get configuration
const config = vscode.workspace.getConfiguration('zizmor');
const enabled = config.get<boolean>('enable', true);
Expand All @@ -87,12 +135,19 @@ export function activate(context: vscode.ExtensionContext) {
return;
}

// Get the path to the zizmor executable
const rawExecutablePath = config.get<string>('executablePath', 'zizmor');
const executablePath = expandTilde(rawExecutablePath);
try {
// Get the path to the zizmor executable
const executablePath = await getZizmor(config);

if (!executablePath) {
console.error('zizmor was not found');
vscode.window.showErrorMessage('zizmor was not found');
return;
}

// Check zizmor version before starting the language server
const versionCheck = await checkZizmorVersion(executablePath);

// Check zizmor version before starting the language server
checkZizmorVersion(executablePath).then(versionCheck => {
if (!versionCheck.isValid) {
const errorMessage = versionCheck.version
? `zizmor version ${versionCheck.version} is too old. This extension requires zizmor ${MIN_ZIZMOR_VERSION} or newer. Please update zizmor and try again.`
Expand All @@ -105,23 +160,26 @@ export function activate(context: vscode.ExtensionContext) {

console.log(`zizmor version ${versionCheck.version} meets minimum requirement (${MIN_ZIZMOR_VERSION})`);
startLanguageServer(context, executablePath);
}).catch(error => {
const errorMessage = `Failed to start zizmor language server: ${error.message}`;
} catch (error) {
const errorMessage = `Failed to start zizmor language server: ${(error as Error).message}`;
console.error('zizmor activation failed:', error);
vscode.window.showErrorMessage(errorMessage);
});
};
}

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

// Define the server options
const serverExecutable: Executable = {
const serverOptions: Executable & ServerOptions = {
command: executablePath,
args: ['--lsp'],
transport: TransportKind.stdio
transport: TransportKind.stdio,
};

const serverOptions: ServerOptions = serverExecutable;
const config = vscode.workspace.getConfiguration('zizmor.trace');
const shouldTrace = config.get<"off" | "messages" | "verbose">('server', "off");

const traceChannel = shouldTrace !== "off" ? vscode.window.createOutputChannel('zizmor LSP trace') : undefined;

const clientOptions: LanguageClientOptions = {
documentSelector: [
Expand All @@ -131,7 +189,7 @@ function startLanguageServer(context: vscode.ExtensionContext, executablePath: s
{ scheme: 'file', language: 'github-actions-workflow', pattern: '**/action.{yml,yaml}' },
{ scheme: 'file', language: 'yaml', pattern: '**/.github/dependabot.{yml,yaml}' },
],
traceOutputChannel: vscode.window.createOutputChannel('zizmor LSP trace')
traceOutputChannel: traceChannel
};

client = new LanguageClient(
Expand Down
83 changes: 83 additions & 0 deletions src/which.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { join, delimiter, sep, posix } from 'node:path';
import * as fs from 'node:fs/promises';

/** Options bag */
export interface Options {
/** Use instead of the PATH environment variable. */
path?: string | undefined;
/** Use instead of the PATHEXT environment variable. */
pathExt?: string | undefined;
/** Use instead of the platform's native path separator. */
delimiter?: string | undefined;
}

const isWindows = process.platform === 'win32';

/**
* Used to check for slashed in commands passed in.
* Always checks for the POSIX separator on all platforms,
* and checks for the current separator when not on a POSIX platform.
*/
const rSlash = new RegExp(`[${posix.sep}${sep === posix.sep ? '' : sep}]`.replace(/(\\)/g, '\\$1'));
const rRel = new RegExp(`^\\.${rSlash.source}`);

const getPathInfo = (cmd: string, {
path: optPath = process.env.PATH,
pathExt: optPathExt = process.env.PATHEXT,
delimiter: optDelimiter = delimiter,
}: Partial<Options>) => {
// If it has a slash, then we don't bother searching the pathenv.
// just check the file itself, and that's it.
const pathEnv = cmd.match(rSlash) ? [''] : [
// Windows always checks the cwd first.
...(isWindows ? [process.cwd()] : []),
...(optPath ?? '').split(optDelimiter),
];

if (isWindows) {
const pathExtExe = optPathExt ??
['.EXE', '.CMD', '.BAT', '.COM'].join(optDelimiter);
const pathExt = pathExtExe.split(optDelimiter).flatMap((item) => [item, item.toLowerCase()]);
if (cmd.includes('.') && pathExt[0] !== '') {
pathExt.unshift('');
}
return { pathEnv, pathExt };
}

return { pathEnv, pathExt: [''] };
};

const getPathPart = (raw: string, cmd: string) => {
const pathPart = /^".*"$/.test(raw) ? raw.slice(1, -1) : raw;
const prefix = !pathPart && rRel.test(cmd) ? cmd.slice(0, 2) : '';
return prefix + join(pathPart, cmd);
};

/**
* Emulate POSIX `command -v` cross-playform.
*
* @param cmd - command to search the PATH for.
* @param options - options to customize the lookup.
* @returns The path to the desired executable, or `undefined` if not found.
*/
export const which = async (
cmd: string,
options: Options = {},
): Promise<string | undefined> => {
const { pathEnv, pathExt } = getPathInfo(cmd, options);

for (const envPart of pathEnv) {
const p = getPathPart(envPart, cmd);

for (const ext of pathExt) {
const candidate = p + ext;

try {
await fs.access(candidate, fs.constants.F_OK);
return candidate;
} catch { }
}
}

return undefined;
};