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
106 changes: 81 additions & 25 deletions src/services/cfnLint/CfnLintService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { extractErrorMessage } from '../../utils/Errors';
import { byteSize } from '../../utils/String';
import { DiagnosticCoordinator } from '../DiagnosticCoordinator';
import { WorkerNotInitializedError } from './CfnLintErrors';
import { LocalCfnLintExecutor } from './LocalCfnLintExecutor';
import { PyodideWorkerManager } from './PyodideWorkerManager';

export enum LintTrigger {
Expand Down Expand Up @@ -52,7 +53,8 @@ export class CfnLintService implements SettingsConfigurable, Closeable {
private settings: CfnLintSettings;
private settingsSubscription?: SettingsSubscription;
private initializationPromise?: Promise<void>;
private readonly workerManager: PyodideWorkerManager;
private readonly workerManager?: PyodideWorkerManager;
private localExecutor?: LocalCfnLintExecutor;
private readonly log = LoggerFactory.getLogger(CfnLintService);
private readonly mountedFolders = new Map<string, WorkspaceFolder>();

Expand Down Expand Up @@ -83,7 +85,20 @@ export class CfnLintService implements SettingsConfigurable, Closeable {
) {
this.settings = DefaultSettings.diagnostics.cfnLint;
this.delayer = delayer ?? new Delayer<void>(this.settings.delayMs);
this.workerManager = workerManager ?? new PyodideWorkerManager(this.settings.initialization, this.settings);
this.workerManager = workerManager;
this.updateExecutor();
}

private updateExecutor(): void {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If settings change while LSP is running, does the PyodideWorkerManager need to be shutdown?

if (this.settings.path) {
this.localExecutor = new LocalCfnLintExecutor(this.settings.path);
} else {
this.localExecutor = undefined;
if (!this.workerManager) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
(this as any).workerManager = new PyodideWorkerManager(this.settings.initialization, this.settings);
}
}
}

configure(settingsManager: ISettingsSubscriber): void {
Expand All @@ -99,11 +114,21 @@ export class CfnLintService implements SettingsConfigurable, Closeable {
this.settingsSubscription = settingsManager.subscribe('diagnostics', (newDiagnosticsSettings) => {
this.onSettingsChanged(newDiagnosticsSettings.cfnLint);
});

// Initialize executor based on current settings
this.updateExecutor();
}

private onSettingsChanged(newSettings: CfnLintSettings): void {
const pathChanged = this.settings.path !== newSettings.path;
this.settings = newSettings;
this.workerManager.updateSettings(newSettings);
if (this.workerManager) {
this.workerManager.updateSettings(newSettings);
}

if (pathChanged) {
this.updateExecutor();
}
// Note: Delayer delay is immutable, set at construction time
// The new delayMs will be used for future operations that check this.settings.delayMs
}
Expand All @@ -126,22 +151,31 @@ export class CfnLintService implements SettingsConfigurable, Closeable {
this.status = STATUS.Initializing;

try {
// Initialize the worker manager
await this.workerManager.initialize();

// Remount previously mounted folders after worker recovery
if (this.mountedFolders.size > 0) {
for (const [mountDir, folder] of this.mountedFolders) {
try {
const fsDir = URI.parse(folder.uri).fsPath;
await this.workerManager.mountFolder(fsDir, mountDir);
} catch (error) {
this.logError(`remounting folder ${mountDir}`, error);
if (this.localExecutor) {
// Local executor doesn't need initialization
this.status = STATUS.Initialized;
this.telemetry.count('initialized.local', 1);
} else if (this.workerManager) {
// Initialize the worker manager
await this.workerManager.initialize();

// Remount previously mounted folders after worker recovery
if (this.mountedFolders.size > 0) {
for (const [mountDir, folder] of this.mountedFolders) {
try {
const fsDir = URI.parse(folder.uri).fsPath;
await this.workerManager.mountFolder(fsDir, mountDir);
} catch (error) {
this.logError(`remounting folder ${mountDir}`, error);
}
}
}
}

this.status = STATUS.Initialized;
this.status = STATUS.Initialized;
this.telemetry.count('initialized.pyodide', 1);
} else {
throw new Error('No cfn-lint executor available');
}
this.telemetry.count('initialized', 1);
} catch (error) {
this.status = STATUS.Uninitialized;
Expand Down Expand Up @@ -171,9 +205,11 @@ export class CfnLintService implements SettingsConfigurable, Closeable {
}

try {
await this.workerManager.mountFolder(fsDir, mountDir);
this.mountedFolders.set(mountDir, folder);
this.telemetry.count('mount.success', 1);
if (this.workerManager) {
await this.workerManager.mountFolder(fsDir, mountDir);
this.mountedFolders.set(mountDir, folder);
this.telemetry.count('mount.success', 1);
}
} catch (error) {
this.logError('mounting folder', error);
this.telemetry.count('mount.fault', 1);
Expand Down Expand Up @@ -276,8 +312,16 @@ export class CfnLintService implements SettingsConfigurable, Closeable {
private async lintStandaloneFile(content: string, uri: string, fileType: CloudFormationFileType): Promise<void> {
const startTime = performance.now();
try {
// Use worker to lint template
const diagnosticPayloads = await this.workerManager.lintTemplate(content, uri, fileType);
this.log.debug(`Begin linting of ${fileType} ${uri} by string`);

let diagnosticPayloads;
if (this.localExecutor) {
diagnosticPayloads = await this.localExecutor.lintTemplate(content, uri, fileType);
} else if (this.workerManager) {
diagnosticPayloads = await this.workerManager.lintTemplate(content, uri, fileType);
} else {
throw new Error('No cfn-lint executor available');
}

if (!diagnosticPayloads || diagnosticPayloads.length === 0) {
// If no diagnostics were returned, publish empty diagnostics to clear any previous issues
Expand Down Expand Up @@ -347,8 +391,18 @@ export class CfnLintService implements SettingsConfigurable, Closeable {

const relativePath = uri.replace(folder.uri, '/'.concat(folder.name));

// Use worker to lint file
const diagnosticPayloads = await this.workerManager.lintFile(relativePath, uri, fileType);
let diagnosticPayloads;
if (this.localExecutor) {
const filePath = URI.parse(uri).fsPath;
// Make path relative to workspace root for cfn-lint
const workspaceRoot = URI.parse(folder.uri).fsPath;
const relativeFilePath = filePath.replace(workspaceRoot + '/', '');
diagnosticPayloads = await this.localExecutor.lintFile(relativeFilePath, uri, fileType, workspaceRoot);
} else if (this.workerManager) {
diagnosticPayloads = await this.workerManager.lintFile(relativePath, uri, fileType);
} else {
throw new Error('No cfn-lint executor available');
}

if (!diagnosticPayloads || diagnosticPayloads.length === 0) {
// Handle empty result case
Expand Down Expand Up @@ -708,8 +762,10 @@ export class CfnLintService implements SettingsConfigurable, Closeable {
this.delayer.cancelAll();

if (this.status !== STATUS.Uninitialized) {
// Shutdown worker manager
await this.workerManager.shutdown();
// Shutdown worker manager if using Pyodide
if (this.workerManager) {
await this.workerManager.shutdown();
}
this.status = STATUS.Uninitialized;
}
}
Expand Down
152 changes: 152 additions & 0 deletions src/services/cfnLint/LocalCfnLintExecutor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { spawn } from 'child_process';
import { writeFile, unlink } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { PublishDiagnosticsParams, DiagnosticSeverity } from 'vscode-languageserver';
import { CloudFormationFileType } from '../../document/Document';
import { LoggerFactory } from '../../telemetry/LoggerFactory';
import { extractErrorMessage } from '../../utils/Errors';

interface CfnLintDiagnostic {
Level: string;
Message: string;
Rule: {
Id: string;
Description: string;
Source: string;
};
Location: {
Start: {
LineNumber: number;
ColumnNumber: number;
};
End: {
LineNumber: number;
ColumnNumber: number;
};
Path: string[];
};
Filename: string;
}

export class LocalCfnLintExecutor {
private readonly log = LoggerFactory.getLogger(LocalCfnLintExecutor);

constructor(private readonly cfnLintPath: string) {}

async lintTemplate(
content: string,
uri: string,
fileType: CloudFormationFileType,
): Promise<PublishDiagnosticsParams[]> {
// Write content to temporary file
const tempFile = join(
tmpdir(),
`cfn-lint-${Date.now()}.${fileType === CloudFormationFileType.Template ? 'yaml' : 'json'}`,
);

try {
await writeFile(tempFile, content, 'utf8');
return await this.lintFile(tempFile, uri, fileType);
} finally {
try {
await unlink(tempFile);
} catch {
// Ignore cleanup errors
}
}
}

async lintFile(
filePath: string,
uri: string,
fileType: CloudFormationFileType,
workspaceRoot?: string,
): Promise<PublishDiagnosticsParams[]> {
const rawDiagnostics = await this.executeCfnLint(filePath, workspaceRoot);
return this.convertToLspFormat(rawDiagnostics, uri);
}

private async executeCfnLint(filePath: string, workspaceRoot?: string): Promise<CfnLintDiagnostic[]> {
return await new Promise((resolve, reject) => {
const args = ['--format', 'json', filePath];
const child = spawn(this.cfnLintPath, args, {
stdio: ['ignore', 'pipe', 'pipe'],
cwd: workspaceRoot,
});

let stdout = '';
let stderr = '';

child.stdout?.on('data', (data: Buffer) => {
stdout += data.toString();
});

child.stderr?.on('data', (data: Buffer) => {
stderr += data.toString();
});

child.on('close', (code) => {
try {
if (code === 0 || code === 2) {
// 0 = no issues, 2 = issues found
const diagnostics: CfnLintDiagnostic[] = stdout.trim()
? (JSON.parse(stdout) as CfnLintDiagnostic[])
: [];
resolve(diagnostics);
} else {
reject(new Error(`cfn-lint exited with code ${code}: ${stderr}`));
}
} catch (error) {
reject(new Error(`Failed to parse cfn-lint output: ${extractErrorMessage(error)}`));
}
});

child.on('error', (error) => {
reject(new Error(`Failed to execute cfn-lint: ${extractErrorMessage(error)}`));
});
});
}

private convertToLspFormat(diagnostics: CfnLintDiagnostic[], uri: string): PublishDiagnosticsParams[] {
if (!diagnostics || diagnostics.length === 0) {
return [];
}

const lspDiagnostics = diagnostics.map((item) => ({
severity: this.convertSeverity(item.Level),
range: {
start: {
line: Math.max(0, (item.Location?.Start?.LineNumber || 1) - 1),
character: Math.max(0, (item.Location?.Start?.ColumnNumber || 1) - 1),
},
end: {
line: Math.max(0, (item.Location?.End?.LineNumber || 1) - 1),
character: Math.max(0, (item.Location?.End?.ColumnNumber || 1) - 1),
},
},
message: item.Message || 'Unknown cfn-lint error',
source: 'cfn-lint',
code: item.Rule?.Id || 'unknown',
}));

return [{ uri, diagnostics: lspDiagnostics }];
}

private convertSeverity(level: string): DiagnosticSeverity {
switch (level) {
case 'Error': {
return DiagnosticSeverity.Error;
}
case 'Warning': {
return DiagnosticSeverity.Warning;
}
case 'Info': {
return DiagnosticSeverity.Information;
}
default: {
return DiagnosticSeverity.Information;
}
}
}
}
1 change: 1 addition & 0 deletions src/settings/Settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type CompletionSettings = Toggleable<{
export type CfnLintSettings = Toggleable<{
delayMs: number;
lintOnChange: boolean;
path?: string;
initialization: CfnLintInitializationSettings;
ignoreChecks: readonly string[];
includeChecks: readonly string[];
Expand Down
1 change: 1 addition & 0 deletions src/settings/SettingsParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ function createCfnLintSchema(defaults: Settings['diagnostics']['cfnLint']) {
enabled: z.boolean().default(defaults.enabled),
delayMs: z.number().default(defaults.delayMs),
lintOnChange: z.boolean().default(defaults.lintOnChange),
path: z.string().optional(),
initialization: createCfnLintInitializationSchema(defaults.initialization),
ignoreChecks: z.array(z.string()).readonly().default(defaults.ignoreChecks),
includeChecks: z.array(z.string()).readonly().default(defaults.includeChecks),
Expand Down
Loading
Loading