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

feat: worker based linting #386

Draft
wants to merge 5 commits into
base: component-context-info-origin
Choose a base branch
from
Draft
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@
"ts-loader": "^9.2.3",
"ts-node": "^8.10.2",
"typescript": "^4.6.3",
"webpack": "^5.44.0",
"webpack": "^5.72.0",
"webpack-bundle-analyzer": "^4.5.0",
"webpack-cli": "^4.7.2"
},
Expand All @@ -109,6 +109,7 @@
"clean": "rimraf lib/",
"build:bundle:node": "webpack --mode production",
"build:bundle:worker": "webpack --mode production && node ./fix-worker-bundle.js",
"build:bundle:linter-thread": "webpack --mode production",
"compile": "tsc --skipLibCheck -p .",
"lint": "eslint \"./{src,test}/**/*.ts\"",
"prepublish": "yarn clean && yarn compile",
Expand Down
44 changes: 11 additions & 33 deletions src/builtin-addons/core/code-actions/template-lint-fixes.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,8 @@
import { CodeActionFunctionParams } from '../../../utils/addon-api';
import { Command, CodeAction, WorkspaceEdit, CodeActionKind, TextEdit, Diagnostic } from 'vscode-languageserver/node';
import { URI } from 'vscode-uri';
import { toLSRange } from '../../../estree-utils';
import BaseCodeActionProvider, { INodeSelectionInfo } from './base';
import { logError } from '../../../utils/logger';

function setCwd(cwd: string) {
try {
process.chdir(cwd);
} catch (err) {
logError(err);
}
}
import { TextDocument } from 'vscode-languageserver-textdocument';

export default class TemplateLintFixesCodeAction extends BaseCodeActionProvider {
async fixTemplateLintIssues(issues: Diagnostic[], params: CodeActionFunctionParams, meta: INodeSelectionInfo): Promise<Array<CodeAction | null>> {
Expand All @@ -25,40 +16,27 @@ export default class TemplateLintFixesCodeAction extends BaseCodeActionProvider
return [null];
}

const cwd = process.cwd();

try {
setCwd(this.project.root);
const linter = new linterKlass();

const fixes = issues.map(async (issue): Promise<null | CodeAction> => {
const { output, isFixed } = await Promise.resolve(
linter.verifyAndFix({
source: meta.selection || '',
moduleId: URI.parse(params.textDocument.uri).fsPath,
filePath: URI.parse(params.textDocument.uri).fsPath,
})
);
const result = await this.server.templateLinter.fix(TextDocument.create(params.textDocument.uri, 'handlebars', 1, meta.selection || ''));

if (result && result.isFixed) {
const edit: WorkspaceEdit = {
changes: {
[params.textDocument.uri]: [TextEdit.replace(toLSRange(meta.location), result.output)],
},
};

if (!isFixed) {
return CodeAction.create(`fix: ${issue.code}`, edit, CodeActionKind.QuickFix);
} else {
return null;
}

const edit: WorkspaceEdit = {
changes: {
[params.textDocument.uri]: [TextEdit.replace(toLSRange(meta.location), output)],
},
};

return CodeAction.create(`fix: ${issue.code}`, edit, CodeActionKind.QuickFix);
});
const resolvedFixes = await Promise.all(fixes);

return resolvedFixes;
} catch (e) {
return [];
} finally {
setCwd(cwd);
}
}
public async onCodeAction(_: string, params: CodeActionFunctionParams): Promise<(Command | CodeAction)[] | undefined | null> {
Expand Down
249 changes: 249 additions & 0 deletions src/linter-thread.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import { parentPort } from 'worker_threads';
import { toDiagnostic, toHbsSource } from './utils/diagnostic';
import { getTemplateNodes } from '@lifeart/ember-extract-inline-templates';
import { parseScriptFile } from 'ember-meta-explorer';
import { pathToFileURL } from 'url';
import { getExtension } from './utils/file-extension';
import { TextDocument } from 'vscode-languageserver-textdocument';
import { Diagnostic } from 'vscode-languageserver/node';
import { URI } from 'vscode-uri';

import { getFileRanges, RangeWalker } from './utils/glimmer-script';
export interface TemplateLinterError {
fatal?: boolean;
moduleId: string;
rule?: string;
filePath: string;
severity: number;
message: string;
isFixable?: boolean;
line?: number;
column?: number;
source?: string;
}
type LinterVerifyArgs = { source: string; moduleId: string; filePath: string };

class Linter {
constructor() {
return this;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
verify(_params: LinterVerifyArgs): TemplateLinterError[] {
return [];
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
verifyAndFix(_params: LinterVerifyArgs): { isFixed: boolean; output: string } {
return {
output: '',
isFixed: true,
};
}
}

type LintAction = 'verify' | 'verifyAndFix';

export type LinterMessage = { id: string; content: string; uri: string; action: LintAction; projectRoot: string; linterPath: string };

const linters: Map<string, typeof Linter> = new Map();

export const extensionsToLint: string[] = ['.hbs', '.js', '.ts', '.gts', '.gjs'];

async function getLinterClass(msg: LinterMessage) {
try {
// commonjs behavior

// @ts-expect-error @todo - fix webpack imports
const requireFunc = typeof __webpack_require__ === 'function' ? __non_webpack_require__ : require;

// eslint-disable-next-line @typescript-eslint/no-var-requires
const linter: typeof Linter = requireFunc(msg.linterPath);

return linter;
} catch {
// ember-template-lint v4 support (as esm module)
// using eval here to stop webpack from bundling it
const linter: typeof Linter = (await eval(`import("${pathToFileURL(msg.linterPath)}")`)).default;

return linter;
}
}

function sourcesForDocument(textDocument: TextDocument) {
const ext = getExtension(textDocument);

if (ext !== null && !extensionsToLint.includes(ext)) {
return [];
}

const documentContent = textDocument.getText();

if (ext === '.hbs') {
if (documentContent.trim().length === 0) {
return [];
} else {
return [documentContent];
}
} else if (ext === '.gjs' || ext === '.gts') {
const ranges = getFileRanges(documentContent);

const rangeWalker = new RangeWalker(ranges);
const templates = rangeWalker.templates();

return templates.map((t) => {
return toHbsSource({
startLine: t.loc.start.line,
startColumn: t.loc.start.character,
endColumn: t.loc.end.character,
endLine: t.loc.end.line,
template: t.content,
});
});
} else {
const nodes = getTemplateNodes(documentContent, {
parse(source: string) {
return parseScriptFile(source);
},
});
const sources = nodes.filter((el) => {
return el.template.trim().length > 0;
});

return sources.map((el) => {
return toHbsSource(el);
});
}
}

async function linkLinterToProject(msg: LinterMessage) {
if (!linters.has(msg.projectRoot)) {
linters.set(msg.projectRoot, await getLinterClass(msg));
}
}

async function getLinterInstance(msg: LinterMessage) {
const LinterKlass = linters.get(msg.projectRoot);

return LinterKlass ? new LinterKlass() : undefined;
}

async function fixDocument(message: LinterMessage): Promise<[null | Error, { isFixed: boolean; output?: string }]> {
try {
await linkLinterToProject(message);
} catch {
return [new Error('Unable to find linter for project'), { isFixed: false }];
}

let linter: Linter | undefined;

try {
linter = await getLinterInstance(message);
} catch {
return [new Error('Unable to create linter instance'), { isFixed: false }];
}

if (!linter) {
return [new Error('Unable resolve linter instance'), { isFixed: false }];
}

try {
const { isFixed, output } = await (linter as Linter).verifyAndFix({
source: message.content,
moduleId: URI.parse(message.uri).fsPath,
filePath: URI.parse(message.uri).fsPath,
});

return [null, { isFixed, output: isFixed ? output : '' }];
} catch (e) {
return [e, { isFixed: false }];
}
}

async function lintDocument(message: LinterMessage): Promise<[null | Error, Diagnostic[]]> {
try {
await linkLinterToProject(message);
} catch {
return [new Error('Unable to find linter for project'), []];
}

let linter: Linter | undefined;

try {
linter = await getLinterInstance(message);
} catch {
return [new Error('Unable to create linter instance'), []];
}

if (!linter) {
return [new Error('Unable resolve linter instance'), []];
}

let sources: string[] = [];

try {
sources = sourcesForDocument(TextDocument.create(message.uri, 'handlebars', 0, message.content));
} catch (e) {
return [new Error('Unable to extract document sources'), []];
}

let diagnostics: Diagnostic[] = [];

try {
const results = await Promise.all(
sources.map(async (source) => {
const errors = await (linter as Linter).verify({
source,
moduleId: URI.parse(message.uri).fsPath,
filePath: URI.parse(message.uri).fsPath,
});

return errors.map((error: TemplateLinterError) => toDiagnostic(source, error));
})
);

results.forEach((result) => {
diagnostics = [...diagnostics, ...result];
});
} catch (e) {
return [e, []];
}

return [null, diagnostics];
}

parentPort?.on('message', async (message: LinterMessage) => {
if (message.action === 'verify') {
try {
const [err, diagnostics] = await lintDocument(message);

parentPort?.postMessage({
id: message.id,
error: err,
diagnostics,
});
} catch (e) {
parentPort?.postMessage({
id: message.id,
error: e,
diagnostics: [],
});
}
} else if (message.action === 'verifyAndFix') {
try {
const [err, { isFixed, output }] = await fixDocument(message);

parentPort?.postMessage({
id: message.id,
error: err,
isFixed,
output,
});
} catch (e) {
parentPort?.postMessage({
id: message.id,
error: e,
isFixed: false,
output: '',
});
}
}
});
Loading