diff --git a/package.json b/package.json index d84d1ce..5c278cc 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,23 @@ "command": "crystal.ameba.disable", "title": "Crystal Ameba: disable lints (workspace)" } - ] + ], + "configuration": { + "type": "object", + "title": "Crystal Ameba configuration", + "properties": { + "crystal-ameba.lint-trigger": { + "type": "string", + "description": "When the linter should be executed. Set to `none` to disable automatic linting.", + "default": "type", + "enum": [ + "none", + "save", + "type" + ] + } + } + } }, "scripts": { "vscode:prepublish": "yarn run compile", @@ -55,8 +71,12 @@ "@types/mocha": "^10.0.9", "@types/node": "^22.9.0", "@types/vscode": "^1.75.0", + "@types/semver": "^7.5.8", "tslint": "^6.1.3", "typescript": "^5.6.3", "vscode-test": "^1.6.1" + }, + "dependencies": { + "semver": "^7.6.3" } } diff --git a/src/ameba.ts b/src/ameba.ts index 1807628..bf69edc 100644 --- a/src/ameba.ts +++ b/src/ameba.ts @@ -16,7 +16,7 @@ import { import { AmebaOutput } from './amebaOutput'; import { AmebaConfig, getConfig } from './configuration'; import { Task, TaskQueue } from './taskQueue'; -import { outputChannel } from './extension'; +import { isCrystalDocument, isDocumentVirtual, noWorkspaceFolder, outputChannel } from './extension'; export class Ameba { private diag: DiagnosticCollection; @@ -29,13 +29,24 @@ export class Ameba { this.config = getConfig(); } - public execute(document: TextDocument): void { - if (document.languageId !== 'crystal' || document.isUntitled || document.uri.scheme !== 'file') { - return; + public execute(document: TextDocument, virtual: boolean = false): void { + if (!isCrystalDocument(document)) return; + if (isDocumentVirtual(document) && !virtual) return; + + const dir = (workspace.getWorkspaceFolder(document.uri) ?? noWorkspaceFolder(document.uri)).uri.fsPath; + + const args = [this.config.command, '--format', 'json']; + + if (!virtual) { + args.push(document.fileName) + } else { + // Disabling these as they're common when typing + args.push('--except', 'Lint/Formatting,Layout/TrailingBlankLines,Layout/TrailingWhitespace'); + + // Indicate that the source is passed through STDIN + args.push('-'); } - const args = [this.config.command, document.fileName, '--format', 'json']; - const dir = workspace.getWorkspaceFolder(document.uri)!.uri.fsPath; const configFile = path.join(dir, this.config.configFileName); if (existsSync(configFile)) args.push('--config', configFile); @@ -47,6 +58,12 @@ export class Ameba { outputChannel.appendLine(`$ ${args.join(' ')}`) const proc = spawn(args[0], args.slice(1), { cwd: dir }); + if (virtual) { + const documentText: string = document.getText(); + proc.stdin.write(documentText) + proc.stdin.end(); + } + token.onCancellationRequested(_ => { proc.kill(); }) @@ -148,10 +165,10 @@ export class Ameba { } let diagnosticUri: Uri; - if (path.isAbsolute(source.path)) { - diagnosticUri = Uri.parse(source.path) - } else if (document.isUntitled) { + if (virtual) { diagnosticUri = document.uri; + } else if (path.isAbsolute(source.path)) { + diagnosticUri = Uri.parse(source.path) } else { diagnosticUri = Uri.parse(path.join(dir, source.path)); } diff --git a/src/configuration.ts b/src/configuration.ts index f09f25e..4c707e4 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -1,11 +1,21 @@ import { workspace } from 'vscode'; import * as path from 'path'; import { existsSync } from 'fs'; +import * as semver from 'semver'; +import { execSync } from 'child_process'; + +import { outputChannel } from './extension'; export interface AmebaConfig { command: string; configFileName: string; - onSave: boolean; + trigger: LintTrigger; +} + +export enum LintTrigger { + None = "none", + Save = "save", + Type = "type" } export function getConfig(): AmebaConfig { @@ -13,12 +23,26 @@ export function getConfig(): AmebaConfig { const root = workspace.workspaceFolders || []; if (root.length) { const localAmebaPath = path.join(root[0].uri.fsPath, 'bin', 'ameba'); - if (existsSync(localAmebaPath)) command = localAmebaPath; + if (existsSync(localAmebaPath)) { + outputChannel.appendLine(`[Config] Using local ameba at ${localAmebaPath}`) + command = localAmebaPath; + } else { + outputChannel.appendLine(`[Config] Using system ameba`) + } + } + + const workspaceConfig = workspace.getConfiguration('crystal-ameba'); + const currentVersion = execSync(`"${command}" --version`).toString(); + + let trigger = workspaceConfig.get("lint-trigger", LintTrigger.Type); + + if (!semver.satisfies(currentVersion, ">=1.6.4") && trigger == LintTrigger.Type) { + trigger = LintTrigger.Save; } return { command, configFileName: '.ameba.yml', - onSave: true + trigger: trigger }; }; diff --git a/src/extension.ts b/src/extension.ts index 37080d7..b06a086 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,7 +7,7 @@ import { import * as path from 'path' import { Ameba } from './ameba'; -import { getConfig } from './configuration'; +import { getConfig, LintTrigger } from './configuration'; export let outputChannel: OutputChannel; @@ -18,6 +18,7 @@ export function activate(context: ExtensionContext) { const diag = languages.createDiagnosticCollection('crystal'); let ameba: Ameba | null = new Ameba(diag); + context.subscriptions.push(diag); context.subscriptions.push( @@ -83,15 +84,33 @@ export function activate(context: ExtensionContext) { executeAmebaOnWorkspace(ameba); + // This can happen when a file is open _or_ when a file's language id changes workspace.onDidOpenTextDocument(doc => { - ameba && ameba.execute(doc); + if (ameba && ameba.config.trigger !== LintTrigger.None && isCrystalDocument(doc)) { + if (isDocumentVirtual(doc)) { + if (ameba.config.trigger === LintTrigger.Type) { + outputChannel.appendLine(`[Open] Running ameba on ${getRelativePath(doc)}`); + ameba.execute(doc, true); + } + } else { + outputChannel.appendLine(`[Open] Running ameba on ${getRelativePath(doc)}`); + ameba.execute(doc); + } + } }); + workspace.onDidChangeTextDocument(e => { + if (ameba && ameba.config.trigger == LintTrigger.Type && isCrystalDocument(e.document)) { + outputChannel.appendLine(`[Change] Running ameba on ${getRelativePath(e.document)}`); + ameba.execute(e.document, isDocumentVirtual(e.document)); + } + }) + workspace.onDidSaveTextDocument(doc => { - if (ameba && ameba.config.onSave && isValidCrystalDocument(doc)) { + if (ameba && ameba.config.trigger === LintTrigger.Save && isCrystalDocument(doc)) { outputChannel.appendLine(`[Save] Running ameba on ${getRelativePath(doc)}`) ameba.execute(doc); - } else if (ameba && path.basename(doc.fileName) == ".ameba.yml") { + } else if (ameba && ameba.config.trigger !== LintTrigger.None && path.basename(doc.fileName) == ".ameba.yml") { outputChannel.appendLine(`[Config] Reloading diagnostics after config file change`) ameba.clear(); executeAmebaOnWorkspace(ameba); @@ -106,12 +125,19 @@ export function activate(context: ExtensionContext) { export function deactivate() { } function executeAmebaOnWorkspace(ameba: Ameba | null) { - if (!ameba) return; + if (!ameba || ameba.config.trigger === LintTrigger.None) return; for (const doc of workspace.textDocuments) { - if (isValidCrystalDocument(doc)) { - outputChannel.appendLine(`[Workspace] Running ameba on ${getRelativePath(doc)}`); - ameba.execute(doc); + if (isCrystalDocument(doc)) { + if (isDocumentVirtual(doc)) { + if (ameba.config.trigger === LintTrigger.Type) { + outputChannel.appendLine(`[Workspace] Running ameba on ${getRelativePath(doc)}`); + ameba.execute(doc, true); + } + } else { + outputChannel.appendLine(`[Workspace] Running ameba on ${getRelativePath(doc)}`); + ameba.execute(doc); + } } } } @@ -130,6 +156,10 @@ export function noWorkspaceFolder(uri: Uri): WorkspaceFolder { } } -function isValidCrystalDocument(doc: TextDocument): boolean { - return doc.languageId === 'crystal' && !doc.isUntitled && doc.uri.scheme === 'file' +export function isCrystalDocument(doc: TextDocument): boolean { + return doc.languageId === 'crystal' +} + +export function isDocumentVirtual(document: TextDocument): boolean { + return document.isDirty || document.isUntitled || document.uri.scheme !== 'file' }