diff --git a/browser-stubs/path.js b/browser-stubs/path.js new file mode 100644 index 00000000..5e0332a2 --- /dev/null +++ b/browser-stubs/path.js @@ -0,0 +1,95 @@ +/* Browser stub for the 'path' module used by ESLint's Linter class */ +'use strict'; + +const sep = '/'; +const delimiter = ':'; + +function normalize(p) { + const parts = p.split('/').filter((part, i) => part !== '' || i === 0); + const result = []; + for (const part of parts) { + if (part === '..') { + result.pop(); + } else if (part !== '.') { + result.push(part); + } + } + return result.join('/') || '.'; +} + +function join(...args) { + return normalize(args.join('/')); +} + +function extname(p) { + const base = basename(p); + const dotIndex = base.lastIndexOf('.'); + return dotIndex <= 0 ? '' : base.slice(dotIndex); +} + +function basename(p, ext) { + const base = p.split('/').pop() || ''; + if (ext && base.endsWith(ext)) { + return base.slice(0, base.length - ext.length); + } + return base; +} + +function dirname(p) { + const parts = p.split('/'); + parts.pop(); + return parts.join('/') || '/'; +} + +function resolve(...args) { + let resolved = ''; + for (let i = args.length - 1; i >= 0; i--) { + const arg = args[i]; + if (arg) { + resolved = arg.startsWith('/') ? normalize(arg + '/' + resolved) : normalize(arg + '/' + resolved); + if (arg.startsWith('/')) { + break; + } + } + } + return resolved || '/'; +} + +function isAbsolute(p) { + return p.startsWith('/'); +} + +function relative(from, to) { + const fromParts = normalize(from).split('/'); + const toParts = normalize(to).split('/'); + while (fromParts.length > 0 && toParts.length > 0 && fromParts[0] === toParts[0]) { + fromParts.shift(); + toParts.shift(); + } + return [...fromParts.map(() => '..'), ...toParts].join('/') || '.'; +} + +module.exports = { + sep, + delimiter, + normalize, + join, + extname, + basename, + dirname, + resolve, + isAbsolute, + relative, + posix: { + sep, + delimiter, + normalize, + join, + extname, + basename, + dirname, + resolve, + isAbsolute, + relative, + }, +}; diff --git a/browser-stubs/util.js b/browser-stubs/util.js new file mode 100644 index 00000000..e7720ca8 --- /dev/null +++ b/browser-stubs/util.js @@ -0,0 +1,39 @@ +/* Browser stub for the 'util' module used by ESLint's Linter class */ +'use strict'; + +function deprecate(fn, _message) { + return fn; +} + +function inspect(value) { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function format(fmt, ...args) { + if (typeof fmt !== 'string') { + return [fmt, ...args].map(String).join(' '); + } + let i = 0; + return fmt.replace(/%[sdifjoO%]/g, (match) => { + if (match === '%%') { return '%'; } + if (i >= args.length) { return match; } + const arg = args[i++]; + switch (match) { + case '%s': return String(arg); + case '%d': case '%i': case '%f': return Number(arg).toString(); + case '%j': return JSON.stringify(arg); + case '%o': case '%O': return inspect(arg); + default: return match; + } + }); +} + +module.exports = { + deprecate, + inspect, + format, +}; diff --git a/client/src/browserExtension.ts b/client/src/browserExtension.ts new file mode 100644 index 00000000..4fefd486 --- /dev/null +++ b/client/src/browserExtension.ts @@ -0,0 +1,41 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * ------------------------------------------------------------------------------------------ */ + +import { ExtensionContext, Uri } from 'vscode'; +import { + LanguageClient, LanguageClientOptions, RevealOutputChannelOn +} from 'vscode-languageclient/browser'; + +let client: LanguageClient; + +export function activate(context: ExtensionContext): void { + const serverWorker = new Worker( + Uri.joinPath(context.extensionUri, 'server', 'out', 'browserServer.js').toString() + ); + + const clientOptions: LanguageClientOptions = { + documentSelector: [ + { scheme: 'file', language: 'javascript' }, + { scheme: 'file', language: 'javascriptreact' }, + { scheme: 'vscode-vfs', language: 'javascript' }, + { scheme: 'vscode-vfs', language: 'javascriptreact' }, + { scheme: 'untitled', language: 'javascript' }, + { scheme: 'untitled', language: 'javascriptreact' }, + ], + revealOutputChannelOn: RevealOutputChannelOn.Never, + diagnosticPullOptions: { + onChange: true, + onSave: true, + onFocus: true, + }, + }; + + client = new LanguageClient('ESLint', 'ESLint', serverWorker, clientOptions); + client.start(); +} + +export function deactivate(): Promise { + return client !== undefined ? client.stop() : Promise.resolve(); +} diff --git a/client/tsconfig.browser.json b/client/tsconfig.browser.json new file mode 100644 index 00000000..79fbdc93 --- /dev/null +++ b/client/tsconfig.browser.json @@ -0,0 +1,20 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "module": "Node16", + "target": "ES2022", + "outDir": "out", + "rootDir": "src", + "lib": [ "ES2023", "DOM", "DOM.Iterable" ], + "skipLibCheck": true, + "sourceMap": true, + "tsBuildInfoFile": "out/browser.tsbuildinfo", + "incremental": true + }, + "include": [ + "src/browserExtension.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/client/tsconfig.json b/client/tsconfig.json index 701b6480..cc6603df 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -15,6 +15,7 @@ "src" ], "exclude": [ - "node_modules" + "node_modules", + "src/browserExtension.ts" ] } \ No newline at end of file diff --git a/client/webpack.browser.config.js b/client/webpack.browser.config.js new file mode 100644 index 00000000..f427dff5 --- /dev/null +++ b/client/webpack.browser.config.js @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//@ts-check + +'use strict'; + +const path = require('path'); +const merge = require('merge-options'); + +/** @type {import('webpack').Configuration} */ +module.exports = merge({ + mode: 'none', + target: 'webworker', + resolve: { + mainFields: ['browser', 'module', 'main'], + extensions: ['.ts', '.js'], + conditionNames: ['browser', 'import', 'require'], + symlinks: false, + fallback: { + path: require.resolve('path-browserify'), + } + }, + module: { + rules: [{ + test: /\.ts$/, + exclude: /node_modules/, + use: [{ + loader: 'ts-loader', + options: { + compilerOptions: { + sourceMap: true, + } + } + }] + }] + }, + externals: { + 'vscode': 'commonjs vscode', + }, + output: { + filename: 'browserExtension.js', + path: path.join(__dirname, 'out'), + libraryTarget: 'commonjs', + }, + entry: { + browserExtension: './src/browserExtension.ts', + }, + context: path.join(__dirname), + devtool: 'source-map', +}); diff --git a/esbuild.js b/esbuild.js index 1074ec84..3a0f5e35 100644 --- a/esbuild.js +++ b/esbuild.js @@ -34,10 +34,49 @@ const serverOptions = { format: 'cjs', }; +const path = require('path'); + +/** @type {Record} */ +const browserNodePolyfills = { + 'node:path': path.resolve('./browser-stubs/path.js'), + 'node:util': path.resolve('./browser-stubs/util.js'), + 'path': path.resolve('./browser-stubs/path.js'), + 'util': path.resolve('./browser-stubs/util.js'), +}; + +/** @type BuildOptions */ +const browserClientOptions = { + bundle: true, + external: ['vscode'], + target: 'ES2022', + platform: 'browser', + sourcemap: false, + entryPoints: ['client/src/browserExtension.ts'], + outfile: 'client/out/browserExtension.js', + preserveSymlinks: true, + format: 'cjs', + alias: browserNodePolyfills, +}; + +/** @type BuildOptions */ +const browserServerOptions = { + bundle: true, + target: 'ES2022', + platform: 'browser', + sourcemap: false, + entryPoints: ['server/src/browserServer.ts'], + outfile: 'server/out/browserServer.js', + preserveSymlinks: true, + format: 'iife', + alias: browserNodePolyfills, +}; + function createContexts() { return Promise.all([ esbuild.context(clientOptions), esbuild.context(serverOptions), + esbuild.context(browserClientOptions), + esbuild.context(browserServerOptions), ]); } diff --git a/package.json b/package.json index 6655d45a..96c335ea 100644 --- a/package.json +++ b/package.json @@ -33,10 +33,11 @@ "enabledApiProposals": [ ], "main": "./client/out/extension", + "browser": "./client/out/browserExtension", "capabilities": { "virtualWorkspaces": { - "supported": false, - "description": "Using ESLint is not possible in virtual workspaces." + "supported": "limited", + "description": "In virtual workspaces, only JavaScript files are linted using ESLint's built-in rules. Custom plugins and config files from the workspace are not supported." }, "untrustedWorkspaces": { "supported": false, @@ -652,12 +653,12 @@ }, "scripts": { "vscode:prepublish": "npm run webpack", - "webpack": "npm run clean && webpack --mode production --config ./client/webpack.config.js && webpack --mode production --config ./server/webpack.config.js", - "webpack:dev": "npm run clean && webpack --mode none --config ./client/webpack.config.js && webpack --mode none --config ./server/webpack.config.js", + "webpack": "npm run clean && webpack --mode production --config ./client/webpack.config.js && webpack --mode production --config ./server/webpack.config.js && webpack --mode production --config ./client/webpack.browser.config.js && webpack --mode production --config ./server/webpack.browser.config.js", + "webpack:dev": "npm run clean && webpack --mode none --config ./client/webpack.config.js && webpack --mode none --config ./server/webpack.config.js && webpack --mode none --config ./client/webpack.browser.config.js && webpack --mode none --config ./server/webpack.browser.config.js", "esbuild": "npm run clean && node ./esbuild.js", "compile": "tsc -b", - "compile:client": "tsc -b ./client/tsconfig.json", - "compile:server": "tsc -b ./server/tsconfig.json", + "compile:client": "tsc -b ./client/tsconfig.json && tsc -p ./client/tsconfig.browser.json --noEmit", + "compile:server": "tsc -b ./server/tsconfig.json && tsc -p ./server/tsconfig.browser.json --noEmit", "watch": "tsc -b -w", "test": "cd client && npm test && cd ..", "lint": "node ./build/bin/all.js run lint", diff --git a/server/src/browserServer.ts b/server/src/browserServer.ts new file mode 100644 index 00000000..17d729f3 --- /dev/null +++ b/server/src/browserServer.ts @@ -0,0 +1,107 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * ------------------------------------------------------------------------------------------ */ + +import { + createConnection, BrowserMessageReader, BrowserMessageWriter, + TextDocuments, Diagnostic, DiagnosticSeverity, Range, + TextDocumentSyncKind, ProposedFeatures, type ClientCapabilities, + DocumentDiagnosticReportKind, type FullDocumentDiagnosticReport, + DidChangeConfigurationNotification +} from 'vscode-languageserver/browser'; +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { Linter } from 'eslint/universal'; +import js = require('@eslint/js'); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const messageReader = new BrowserMessageReader(self as any); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const messageWriter = new BrowserMessageWriter(self as any); + +const connection = createConnection(ProposedFeatures.all, messageReader, messageWriter); +const documents: TextDocuments = new TextDocuments(TextDocument); + +let clientCapabilities: ClientCapabilities; + +const linter = new Linter(); + +const jsLanguageIds = new Set(['javascript', 'javascriptreact']); + +const defaultConfig: Linter.Config[] = [ + js.configs.recommended as Linter.Config, + { + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + } + } +]; + +const emptyResult: FullDocumentDiagnosticReport = { + kind: DocumentDiagnosticReportKind.Full, + items: [] +}; + +function toDiagnostics(messages: Linter.LintMessage[]): Diagnostic[] { + return messages.map(msg => { + const startLine = Math.max(0, msg.line - 1); + const startChar = Math.max(0, msg.column - 1); + const endLine = Math.max(0, (msg.endLine ?? msg.line) - 1); + const endChar = Math.max(0, (msg.endColumn ?? msg.column) - 1); + const diagnostic: Diagnostic = { + range: Range.create(startLine, startChar, endLine, endChar), + severity: msg.severity === 2 ? DiagnosticSeverity.Error : DiagnosticSeverity.Warning, + message: msg.message, + source: 'eslint', + }; + if (msg.ruleId !== null) { + diagnostic.code = msg.ruleId; + diagnostic.codeDescription = { + href: `https://eslint.org/docs/rules/${msg.ruleId}` + }; + } + return diagnostic; + }); +} + +connection.onInitialize((params) => { + clientCapabilities = params.capabilities; + return { + capabilities: { + textDocumentSync: TextDocumentSyncKind.Incremental, + diagnosticProvider: { + identifier: 'eslint', + interFileDependencies: false, + workspaceDiagnostics: false + } + } + }; +}); + +connection.onInitialized(() => { + if (clientCapabilities.workspace?.didChangeConfiguration?.dynamicRegistration === true) { + void connection.client.register(DidChangeConfigurationNotification.type, undefined); + } +}); + +connection.languages.diagnostics.on(async (params) => { + const document = documents.get(params.textDocument.uri); + if (document === undefined || !jsLanguageIds.has(document.languageId)) { + return emptyResult; + } + + const code = document.getText(); + try { + const messages = linter.verify(code, defaultConfig, params.textDocument.uri); + return { + kind: DocumentDiagnosticReportKind.Full, + items: toDiagnostics(messages) + }; + } catch { + return emptyResult; + } +}); + +documents.listen(connection); +connection.listen(); diff --git a/server/tsconfig.browser.json b/server/tsconfig.browser.json new file mode 100644 index 00000000..9500692c --- /dev/null +++ b/server/tsconfig.browser.json @@ -0,0 +1,20 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "module": "Node16", + "moduleResolution": "Node16", + "target": "ES2022", + "sourceMap": true, + "outDir": "out", + "rootDir": "src", + "lib": [ "ES2023", "WebWorker" ], + "tsBuildInfoFile": "out/browser.tsbuildinfo", + "incremental": true + }, + "include": [ + "src/browserServer.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/server/tsconfig.json b/server/tsconfig.json index 46ca5be4..112453e4 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -16,6 +16,7 @@ "src" ], "exclude": [ - "node_modules" + "node_modules", + "src/browserServer.ts" ] } \ No newline at end of file diff --git a/server/webpack.browser.config.js b/server/webpack.browser.config.js new file mode 100644 index 00000000..9c2e0a6a --- /dev/null +++ b/server/webpack.browser.config.js @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//@ts-check + +'use strict'; + +const path = require('path'); + +/** @type {import('webpack').Configuration} */ +module.exports = { + mode: 'none', + target: 'webworker', + resolve: { + mainFields: ['browser', 'module', 'main'], + extensions: ['.ts', '.js'], + conditionNames: ['browser', 'import', 'require'], + symlinks: false, + fallback: { + path: require.resolve('path-browserify'), + } + }, + module: { + rules: [{ + test: /\.ts$/, + exclude: /node_modules/, + use: [{ + loader: 'ts-loader', + options: { + compilerOptions: { + sourceMap: true, + } + } + }] + }] + }, + output: { + filename: 'browserServer.js', + path: path.join(__dirname, 'out'), + libraryTarget: 'var', + library: 'serverExportVar', + }, + entry: { + browserServer: './src/browserServer.ts', + }, + context: path.join(__dirname), + devtool: 'source-map', +};