Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for linting while typing #151

Merged
merged 13 commits into from
Dec 4, 2024
22 changes: 21 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
35 changes: 26 additions & 9 deletions src/ameba.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);

Expand All @@ -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();
})
Expand Down Expand Up @@ -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));
}
Expand Down
30 changes: 27 additions & 3 deletions src/configuration.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,48 @@
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 {
let command = 'ameba';
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<LintTrigger>("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
};
};
50 changes: 40 additions & 10 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
}
}
}
Expand All @@ -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'
}
Loading