diff --git a/.vscode/launch.json b/.vscode/launch.json index 64e7a93..4289d12 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -19,7 +19,7 @@ "!**/node_modules/**" ], "autoAttachChildProcesses": true, - "preLaunchTask": "npm: build" + "preLaunchTask": "npm: dev" } ] } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 870a43d..f31f208 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -3,7 +3,7 @@ "tasks": [ { "type": "npm", - "script": "esbuild", + "script": "dev", "group": "build", "presentation": { "panel": "dedicated", diff --git a/README.md b/README.md index 3a59ef9..7c5dd9c 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ Table of Contents: - [Introduction](#introduction) - [Features](#features) - [Syntax highlighting](#syntax-highlighting) - - [Adblock rule linter](#adblock-rule-linter) + - [AGLint integration (linter)](#aglint-integration-linter) + - [Configuration](#configuration) - [GitHub Linguist support](#github-linguist-support) - [Ideas \& Questions](#ideas--questions) - [Reporting Issues](#reporting-issues) @@ -73,7 +74,7 @@ syntaxes. Nowadays it is unimaginable to work with code without highlighting, which helps you to distinguish different parts of the code and makes it easier to read. -### Adblock rule linter +### AGLint integration (linter) We integrated [AGLint][aglint] into this extension, that makes it able to check your rules for various issues, such as @@ -81,6 +82,19 @@ invalid syntax, invalid domains, invalid / incompatible CSS selectors, unknown / incompatible scriptlets, bad practices, etc. For more information about AGLint, please refer to its [repository][aglint]. +AGLint integration is done in the following way: +1. Extension will search local AGLint installation (if it is installed) and use + it for linting. First, it will search for local installation in the current + workspace, and if it is not found, it will search for a global installation. + This is an ideal behavior, because if you have a local installation, it + guarantees that you will use the same version of AGLint, and the results will + be the same. +2. If the extension doesn't find any installation, it will use the bundled + version of AGLint, which is included in the extension itself. Usually, it is + the latest version of AGLint. The advantage of this approach is that you + don't need to install AGLint manually, and you can start using the extension + immediately after installation. + > :warning: Please note that the linter is under active development, so it may > not work properly for some rules. If you find any issues, please report them > [here][aglintissues]. We look forward to your feedback, your help is very @@ -89,6 +103,16 @@ about AGLint, please refer to its [repository][aglint]. [aglint]: https://github.com/AdguardTeam/AGLint [aglintissues]: https://github.com/AdguardTeam/AGLint/issues +### Configuration + +This extension provides the following configuration options: + +| Option | Description | Default value | Possible values | +| ------ | ----------- | ------------- | --------------- | +| `adblock.enableAglint` | Enable or disable AGLint integration. If disabled, only syntax highlighting and other language features will be available. | `true` | `true`, `false` | +| `adblock.useExternalAglintPackages` | If enabled, extension will search for AGLint installations in the system. If disabled, extension will use its own AGLint installation, which is included in the extension (integrated AGLint bundle). If you have AGLint installed in your system / project, it is recommended to enable this option in order to provide consistent results. | `true` | `true`, `false` | +| `adblock.packageManager` | Package manager to use for searching global AGLint installations. Set it to your preferred package manager. | `npm` | `npm`, `yarn`, `pnpm` | + ### GitHub Linguist support GitHub supports adblock syntax officially via the [Linguist][linguist] library. diff --git a/client/src/extension.ts b/client/src/extension.ts index 777ca78..cc2b55f 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -91,6 +91,7 @@ export function activate(context: ExtensionContext) { // Notify the server if the configuration has changed (such as .aglintrc, .aglintignore, etc.) // We define these files as glob patterns here fileEvents: [ + workspace.createFileSystemWatcher('**/*.{txt,adblock,ublock,adguard}', false, true, false), workspace.createFileSystemWatcher(`**/{${CONFIG_FILE_NAMES.join(',')}}`), workspace.createFileSystemWatcher(`**/{${IGNORE_FILE_NAME}}`), ], @@ -124,14 +125,27 @@ export function activate(context: ExtensionContext) { if (params?.error) { // We have an error, so change the status bar background to red statusBarItem.backgroundColor = new ThemeColor('statusBarItem.warningBackground'); + + // Show warning icon + statusBarItem.text = '$(warning) AGLint'; } else { // Everything is fine, so change the status bar background to the default color // In this case, params is null statusBarItem.backgroundColor = undefined; + + if (params?.aglintEnabled === false) { + // Show a blocked icon if AGLint is disabled + statusBarItem.text = '$(debug-pause) AGLint'; + } else { + // Reset the status bar text + statusBarItem.text = 'AGLint'; + } } }); - client.outputChannel.appendLine('AGLint extension client activated'); + // Notify the user if the extension is activated. Show the version of the VSCode plugin, + // maybe it will be useful for debugging / support + client.info(`AGLint extension client activated. Extension version: ${context.extension.packageJSON.version}`); // Start the client. This will also launch the server. client.start(); @@ -148,5 +162,7 @@ export function deactivate(): Thenable | undefined { return undefined; } + client.info('Deactivating AGLint extension client...'); + return client.stop(); } diff --git a/package.json b/package.json index 052e07f..1bb262d 100644 --- a/package.json +++ b/package.json @@ -55,34 +55,52 @@ "source.js": "javascript" } } - ] + ], + "configuration": { + "type": "object", + "title": "Adblock", + "properties": { + "adblock.enableAglint": { + "scope": "resource", + "type": "boolean", + "default": true, + "description": "Enable or disable AGLint integration. If disabled, only syntax highlighting and other language features will be available." + }, + "adblock.useExternalAglintPackages": { + "scope": "resource", + "type": "boolean", + "default": true, + "description": "If enabled, extension will search for AGLint installations in the system. If disabled, extension will use its own AGLint installation, which is included in the extension (integrated AGLint bundle). If you have AGLint installed in your system / project, it is recommended to enable this option in order to provide consistent results." + }, + "adblock.packageManager": { + "scope": "resource", + "type": "string", + "enum": [ + "npm", + "yarn", + "pnpm" + ], + "enumDescriptions": [ + "Node Package Manager", + "Yarn Package Manager", + "pnpm Package Manager" + ], + "default": "npm", + "description": "Package manager to use for searching global AGLint installations. Set it to your preferred package manager." + } + } + } }, "scripts": { - "client:esbuild-prod": "yarn client:esbuild-base --minify", - "client:esbuild-base": "esbuild ./client/src/extension.ts --bundle --outfile=client/out/extension.js --external:vscode --format=cjs --platform=node", - "client:esbuild": "yarn client:esbuild-base --sourcemap", - "client:esbuild-watch": "yarn client:esbuild-base --sourcemap --watch", - "client:test-compile": "tsc -p ./client", - "client:lint": "eslint ./client/src --ext .ts", - "client:clean": "rimraf client/out", - "server:esbuild-prod": "yarn server:esbuild-base --minify", - "server:esbuild-base": "esbuild ./server/src/server.ts --bundle --outfile=server/out/server.js --external:vscode --format=cjs --platform=node", - "server:esbuild": "yarn server:esbuild-base --sourcemap", - "server:esbuild-watch": "yarn server:esbuild-base --sourcemap --watch", - "server:test-compile": "tsc -p ./server", - "server:lint": "eslint ./server/src --ext .ts", - "server:clean": "rimraf server/out", - "esbuild-prod": "yarn client:esbuild-prod && yarn server:esbuild-prod", - "esbuild": "yarn client:esbuild && yarn server:esbuild", - "build": "yarn grammar:build && yarn esbuild", - "esbuild-watch": "yarn concurrently \"yarn server:esbuild-watch\" \"yarn client:esbuild-watch\"", - "test-compile": "yarn client:test-compile && yarn server:test-compile", - "clean": "yarn client:clean && yarn server:clean", + "dev": "yarn concurrently -n grammar,client,server,aglint \"ts-node tools/grammar-builder.ts\" \"esbuild ./client/src/extension.ts --bundle --outfile=client/out/extension.js --external:vscode --format=cjs --platform=node --sourcemap\" \"esbuild ./server/src/server.ts --bundle --outfile=server/out/server.js --external:vscode --external:./integrated-aglint --format=cjs --platform=node --sourcemap\" \"esbuild ./server/src/aglint.ts --bundle --outfile=server/out/aglint.js --format=cjs --platform=node --sourcemap\"", + "dev-watch": "concurrently -n watch-client,watch-server,watch-aglint \"esbuild ./client/src/extension.ts --bundle --outfile=client/out/extension.js --external:vscode --format=cjs --platform=node --sourcemap --watch\" \"esbuild ./server/src/server.ts --bundle --outfile=server/out/server.js --external:vscode --external:./integrated-aglint --format=cjs --platform=node --sourcemap --watch\" \"esbuild ./server/src/aglint.ts --bundle --outfile=server/out/aglint.js --format=cjs --platform=node --sourcemap --watch\"", + "prod": "yarn clean && ts-node tools/grammar-builder.ts && esbuild ./client/src/extension.ts --bundle --outfile=client/out/extension.js --external:vscode --format=cjs --platform=node --minify && yarn esbuild ./server/src/server.ts --bundle --outfile=server/out/server.js --external:vscode --external:\"./integrated-aglint\" --format=cjs --platform=node --minify && yarn esbuild ./server/src/aglint.ts --bundle --outfile=server/out/aglint.js --format=cjs --platform=node --minify", + "clean": "rimraf ./client/out && rimraf ./server/out && rimraf ./syntaxes/out", + "test-compile": "tsc -p ./client --noEmit && tsc -p ./server --noEmit", "lint": "eslint . --ext .ts", - "postinstall": "cd client && yarn && cd ../server && yarn && cd ..", - "generate-vsix": "yarn grammar:build && yarn esbuild-prod && vsce package --yarn --out vscode-adblock.vsix", "test": "jest", - "grammar:build": "ts-node tools/grammar-builder.ts", + "generate-vsix": "vsce package --yarn --out vscode-adblock.vsix", + "postinstall": "cd client && yarn && cd ../server && yarn && cd ..", "prepare": "husky install" }, "devDependencies": { diff --git a/server/package.json b/server/package.json index 61ef888..145cebe 100644 --- a/server/package.json +++ b/server/package.json @@ -12,6 +12,10 @@ "vscode-languageserver-textdocument": "^1.0.8" }, "devDependencies": { - "@adguard/aglint": "1.0.11" + "@adguard/aglint": "1.0.11", + "@types/clone-deep": "^4.0.1", + "@types/semver": "^7.3.13", + "clone-deep": "^4.0.1", + "semver": "^7.5.0" } } diff --git a/server/src/aglint.ts b/server/src/aglint.ts new file mode 100644 index 0000000..d8125f7 --- /dev/null +++ b/server/src/aglint.ts @@ -0,0 +1,13 @@ +/** + * @file Integrated version of AGLint + * + * In this file, we simply re-export the AGLint API from the @adguard/aglint + * package, which is a dev dependency of this server package, so we simply call + * it "integrated" / "bundled" version. + * + * We always make a separate bundle for this file, so the server bundle can + * import it as a fallback if it doesn't found any other AGLint installation + * with module finder. + */ + +export * from '@adguard/aglint'; diff --git a/server/src/common/constants.ts b/server/src/common/constants.ts new file mode 100644 index 0000000..83d0dc1 --- /dev/null +++ b/server/src/common/constants.ts @@ -0,0 +1,16 @@ +/** + * @file General constant values used across the server + */ + +/** + * Name of the AGLint package, probably will never change + */ +export const AGLINT_PACKAGE_NAME = '@adguard/aglint'; + +/** + * URL to the AGLint GitHub repository + */ +export const AGLINT_REPO_URL = 'https://github.com/AdguardTeam/AGLint'; + +export const LF = '\n'; +export const EMPTY = ''; diff --git a/server/src/server.ts b/server/src/server.ts index 1d578f9..89052fc 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -1,5 +1,5 @@ /** - * AGLint Language Server for VSCode + * @file AGLint Language Server for VSCode (Node.js) */ import { @@ -12,15 +12,31 @@ import { DidChangeConfigurationNotification, InitializeResult, } from 'vscode-languageserver/node'; - import { TextDocument } from 'vscode-languageserver-textdocument'; - -// eslint-disable-next-line import/no-extraneous-dependencies -import { - LinterConfig, scan, walk, Linter, buildConfigForDirectory, -} from '@adguard/aglint'; import { ParsedPath, join as joinPath } from 'path'; -import { fileURLToPath } from 'url'; +import { fileURLToPath, pathToFileURL } from 'url'; +// TODO: Implement minimum version check +// import { satisfies } from 'semver'; +// Import type definitions from the AGLint package +import type * as AGLint from '@adguard/aglint'; +import cloneDeep from 'clone-deep'; +import { resolveAglintModulePath } from './utils/aglint-resolver'; +import { AGLINT_PACKAGE_NAME, AGLINT_REPO_URL, LF } from './common/constants'; +import { defaultSettings, ExtensionSettings } from './settings'; +import { NPM, PackageManager, getInstallationCommand } from './utils/package-managers'; + +// Store AGLint module here +let AGLintModule: typeof AGLint; + +// TODO: Implement minimum version check +// const MIN_AGLINT_VERSION = '1.0.12'; + +/** + * Path to the bundled AGLint module, relative to the server bundle. + * Development done in TypeScript, but here we should think as if + * the bundles would already be built. + */ +const BUNDLED_AGLINT_PATH = './aglint.js'; // Create a connection for the server, using Node's IPC as a transport. // Also include all preview / proposed LSP features. @@ -29,11 +45,6 @@ const connection = createConnection(ProposedFeatures.all); // Create a simple text document manager. const documents: TextDocuments = new TextDocuments(TextDocument); -/** - * URL to the AGLint repository - */ -const AGLINT_URL = 'https://github.com/AdguardTeam/AGLint'; - let hasConfigurationCapability = false; let hasWorkspaceFolderCapability = false; @@ -42,13 +53,18 @@ let hasWorkspaceFolderCapability = false; */ let workspaceRoot: string | undefined; -type CachedPaths = { [key: string]: LinterConfig }; +type CachedPaths = { [key: string]: AGLint.LinterConfig }; /** * Cache of the scanned workspace */ let cachedPaths: CachedPaths | undefined; +/** + * Actual settings for the extension (always synced) + */ +let settings: ExtensionSettings = defaultSettings; + /** * Scan the workspace and cache the result. */ @@ -60,17 +76,22 @@ async function cachePaths(): Promise { } // Get the config for the cwd, should exist - const rootConfig = await buildConfigForDirectory(workspaceRoot); + const rootConfig = await AGLintModule.buildConfigForDirectory(workspaceRoot); - const scanResult = await scan(workspaceRoot); + const scanResult = await AGLintModule.scan(workspaceRoot); // Create a map of paths to configs const newCache: CachedPaths = {}; - await walk( + if (!settings.enableAglint) { + // If AGLint is disabled, we should ignore the scan + return false; + } + + await AGLintModule.walk( scanResult, { - file: async (path: ParsedPath, config: LinterConfig) => { + file: async (path: ParsedPath, config: AGLint.LinterConfig) => { const filePath = joinPath(path.dir, path.base); // Add the file path to the new cache map with the resolved config @@ -102,14 +123,13 @@ async function cachePaths(): Promise { connection.console.error([ 'AGLint couldn\'t find the config file. To set up a configuration file for this project, please run:', '', - ' If you use NPM:\tnpx aglint init', - ' If you use Yarn:\tyarn aglint init', + ` ${getInstallationCommand(settings.packageManager, AGLINT_PACKAGE_NAME)} init`, '', 'IMPORTANT: The init command creates a root config file, so be sure to run it in the root directory of your project!', '', 'AGLint will try to find the config file in the current directory (cwd), but if the config file is not found', 'there, it will try to find it in the parent directory, and so on until it reaches your OS root directory.', - ].join('\n')); + ].join(LF)); /* eslint-enable max-len */ } else { connection.console.error(error.toString()); @@ -125,6 +145,74 @@ async function cachePaths(): Promise { } } +/** + * Load the installed AGLint module. If the module is not found, it will + * fallback to the bundled version. + * + * @param dir Workspace root path + * @param searchExternal Search for external AGLint installations (default: true) + * @param packageManagers Package managers to use when searching for external AGLint installations (default: NPM). + * It is only relevant if `searchExternal` is set to `true`. Technically, multiple package managers can be used, + * but in practice, we only use one. + */ +async function loadAglintModule( + dir: string, + searchExternal = true, + packageManagers: PackageManager[] = [NPM], +): Promise { + // Initially, we assume that the AGLint module is not installed + let externalAglintPath: string | undefined; + + if (searchExternal) { + connection.console.info(`Searching for external AGLint installations from: ${dir}`); + + externalAglintPath = await resolveAglintModulePath( + dir, + (message: string, verbose?: string | undefined) => { + connection.tracer.log(message, verbose); + }, + packageManagers, + ); + + if (!externalAglintPath) { + connection.console.info([ + /* eslint-disable max-len */ + 'It seems that the AGLint package is not installed either locally or globally. Falling back to the bundled version.', + `You can install AGLint by running: ${getInstallationCommand(settings.packageManager, AGLINT_PACKAGE_NAME)}`, + /* eslint-enable max-len */ + ].join(LF)); + } else { + connection.console.info(`Using AGlint from: ${externalAglintPath}`); + } + } else { + connection.console.info( + 'Searching for external AGLint installations disabled, falling back to the bundled version.', + ); + } + + // Convert external path to a file URL, otherwise the module will fail to load + if (externalAglintPath) { + externalAglintPath = pathToFileURL(externalAglintPath).toString(); + } + + // Import corresponding AGLint module + AGLintModule = await import(externalAglintPath || BUNDLED_AGLINT_PATH); + + // TODO: Another way to import the module, since we use CJS bundles + // eslint-disable-next-line import/no-dynamic-require, global-require + // AGLintModule = require(externalAglintPath || bundledAglintPath); + + // TODO: Implement minimum version check + // TODO: Version should be exported from AGLint to do this simply + // if (!satisfies(AGLint.version, `>=${MIN_AGLINT_VERSION}`)) { + // throw new Error([ + // `The installed AGLint module is too old: ${version}`, + // `The minimum required version is: ${MIN_AGLINT_VERSION}`, + // `Please update the AGLint module: ${workspaceRoot}`, + // ].join(LF)); + // } +} + connection.onInitialize(async (params: InitializeParams) => { const { capabilities } = params; @@ -147,11 +235,9 @@ connection.onInitialize(async (params: InitializeParams) => { }; } - // TODO: Better way to get the workspace root + // TODO: Checks for better way to get the workspace root workspaceRoot = params.workspaceFolders ? fileURLToPath(params.workspaceFolders[0].uri) : undefined; - connection.console.info(`AGLint Language Server initialized in workspace: ${workspaceRoot}`); - return result; }); @@ -166,6 +252,14 @@ async function lintFile(textDocument: TextDocument): Promise { try { const documentPath = fileURLToPath(textDocument.uri); + // If AGLint is disabled, report no diagnostics + if (!settings.enableAglint) { + // Reset the diagnostics for the document + connection.sendDiagnostics({ uri: textDocument.uri, diagnostics: [] }); + + return; + } + // If the file is not present in the cached path map, it means that it is // not lintable or it is marked as ignored in some .aglintignore file. // In this case, we should not lint it. @@ -183,7 +277,7 @@ async function lintFile(textDocument: TextDocument): Promise { const text = textDocument.getText(); // Create the linter instance and lint the document text - const linter = new Linter(true, config); + const linter = new AGLintModule.Linter(true, config); const { problems } = linter.lint(text); // Convert problems to VSCode diagnostics @@ -218,7 +312,7 @@ async function lintFile(textDocument: TextDocument): Promise { if (problem.rule) { diagnostic.code = problem.rule; diagnostic.codeDescription = { - href: `${AGLINT_URL}#${problem.rule}`, + href: `${AGLINT_REPO_URL}#${problem.rule}`, }; } @@ -247,18 +341,100 @@ async function lintFile(textDocument: TextDocument): Promise { } } -// Called when any of monitored file paths change -connection.onDidChangeWatchedFiles(async () => { - // Reset current file diagnostics +/** + * Remove all diagnostics from all open text documents. + */ +function removeAllDiagnostics() { documents.all().forEach((document) => { connection.sendDiagnostics({ uri: document.uri, diagnostics: [] }); }); +} + +/** + * Rebuild the cached paths and revalidate any open text documents. + */ +async function refreshLinter() { + if (!settings.enableAglint || !workspaceRoot) { + removeAllDiagnostics(); + return; + } - // Re-scan the workspace + // Revalidate the cached paths await cachePaths(); // Revalidate any open text documents documents.all().forEach(lintFile); +} + +/** + * Pull the settings from VSCode and update the settings variable. It also + * re-builts the cached paths and revalidates any open text documents. + * + * "In this model the clients simply sends an empty change event to signal that the settings have + * changed and must be reread" + * + * @param initial If true, it means that this is the first time we pull the settings + * @see https://github.com/microsoft/vscode-languageserver-node/issues/380#issuecomment-414691493 + */ +async function pullSettings(initial = false) { + // Store old settings + const oldSettings = cloneDeep(settings); + + if (hasConfigurationCapability) { + // Get settings from VSCode + const receivedSettings = (await connection.workspace.getConfiguration('adblock')) as ExtensionSettings; + + // Update the settings. No need to validate them, VSCode does this for us based on the schema + // specified in the package.json + // If we didn't receive any settings, use the default ones + settings = receivedSettings || defaultSettings; + } else { + settings = defaultSettings; + } + + // If initial is true, it means that this is the first time we pull the settings + // In this case, we should load the AGLint module + // If module related settings changed, we also need to reload the AGLint module + if ( + initial + || oldSettings.useExternalAglintPackages !== settings.useExternalAglintPackages + || oldSettings.packageManager !== settings.packageManager + ) { + // Workspace root should be defined at this point + if (!workspaceRoot) { + connection.console.error('Workspace root is not defined'); + removeAllDiagnostics(); + return; + } + + await loadAglintModule(workspaceRoot, settings.useExternalAglintPackages, [settings.packageManager]); + } + + // If AGLint is disabled, remove status bar problems + connection.sendNotification('aglint/status', { aglintEnabled: settings.enableAglint }); + + if (!settings.enableAglint) { + removeAllDiagnostics(); + connection.console.info('AGLint is disabled'); + return; + } + + await refreshLinter(); +} + +connection.onDidChangeConfiguration(async () => { + connection.console.info('Configuration changed'); + + // Pull the settings from VSCode + await pullSettings(); +}); + +// Called when any of monitored file paths change +connection.onDidChangeWatchedFiles(async () => { + // Reset current file diagnostics + removeAllDiagnostics(); + + await refreshLinter(); }); // The content of a text document has changed. This event is emitted @@ -279,11 +455,14 @@ connection.onInitialized(async () => { }); } - // Scan the workspace and cache the result - await cachePaths(); + if (!workspaceRoot) { + connection.console.error('Couldn\'t determine the workspace root of the VSCode instance'); + } else { + // Pull the settings from VSCode (in initial mode) + await pullSettings(true); - // Lint all open documents when the server starts - documents.all().forEach(lintFile); + connection.console.info(`AGLint Language Server initialized in workspace: ${workspaceRoot}`); + } }); // Make the text document manager listen on the connection @@ -293,4 +472,4 @@ documents.listen(connection); // Listen on the connection connection.listen(); -connection.console.info(`AGLint server running in Node ${process.version}`); +connection.console.info(`AGLint Node.js Language Server running in node ${process.version}`); diff --git a/server/src/settings.ts b/server/src/settings.ts new file mode 100644 index 0000000..e70d84a --- /dev/null +++ b/server/src/settings.ts @@ -0,0 +1,31 @@ +/** + * @file Extension settings + * + * Guide to add new settings to the extension: + * + * 1. Add a new entry to: + * - extension metadata (package.json: contributes.configuration.properties) + * - extension settings interface (settings.ts: ExtensionSettings) + * - default settings object (settings.ts: defaultSettings) + * 2. Implement the logic in the server.ts file + */ + +import { NPM, PackageManager } from './utils/package-managers'; + +/** + * Represents the extension settings + */ +export interface ExtensionSettings { + enableAglint: boolean; + useExternalAglintPackages: boolean; + packageManager: PackageManager; +} + +/** + * Default extension settings + */ +export const defaultSettings: ExtensionSettings = { + enableAglint: true, + useExternalAglintPackages: true, + packageManager: NPM, +}; diff --git a/server/src/utils/aglint-resolver.ts b/server/src/utils/aglint-resolver.ts new file mode 100644 index 0000000..86f6509 --- /dev/null +++ b/server/src/utils/aglint-resolver.ts @@ -0,0 +1,89 @@ +/** + * @file Utility functions to find AGLint installations + */ + +import { Files } from 'vscode-languageserver/node'; +import { + NPM, + PNPM, + PackageManager, + TraceFunction, + YARN, + findGlobalPathForPackageManager, +} from './package-managers'; +import { AGLINT_PACKAGE_NAME } from '../common/constants'; + +/** + * Priority of package managers. We will try to find global AGLint installation in + * this order (first element has the highest priority). + */ +export const PACKAGE_MANAGER_PRIORITY: PackageManager[] = [NPM, YARN, PNPM]; + +/** + * Resolve the path to the AGLint module. First, we try to find it in the current + * working directory, then we try to find it in the global path of the package + * managers in the given priority order. + * + * If we didn't find the AGLint module, we return undefined, which means that + * the extension will use the integrated version of AGLint. + * + * @param cwd Current working directory + * @param tracer Trace function + * @param packageManagers Package managers to search for global AGLint installations. + * - The priority of the package managers is defined by the order of the array. + * - If you specify an empty array, global path search will be skipped. + * - If you specify only one package manager, we will skip the search for + * the others. For example, if you specify only NPM in the array, we will + * try to find AGLint only in the global NPM path, and we will skip the search + * for Yarn and PNPM. + * @returns Path to the AGLint module or `undefined` if not found + */ +export async function resolveAglintModulePath( + cwd: string, + tracer: TraceFunction, + packageManagers: PackageManager[] = PACKAGE_MANAGER_PRIORITY, +): Promise { + // First, try to find AGLint in the current working directory + try { + const aglintPath = await Files.resolve(AGLINT_PACKAGE_NAME, cwd, cwd, tracer); + + // If we found it, return the path and abort the search + if (aglintPath) { + return aglintPath; + } + } catch (error: unknown) { + // "Files.resolve" throws an error if the package is not found, but we need + // to continue our search + } + + // If we didn't find local installation, try to find it in the global path. + // We will try to find it in the global path of the package managers in the + // given order + for (const packageManager of packageManagers) { + // Find the global path for the actual package manager + const globalPath = await findGlobalPathForPackageManager(packageManager, tracer); + + // If we didn't find the global path, continue with the next package manager, + // because the current one seems to be not installed + if (!globalPath) { + continue; + } + + // Otherwise, try to find AGLint in the found global path + try { + const aglintPath = await Files.resolve(AGLINT_PACKAGE_NAME, globalPath, cwd, tracer); + + if (aglintPath) { + return aglintPath; + } + } catch (error: unknown) { + // Error tolerance: if the function throws an error, we ignore it, + // and continue with the next package manager. In the worst case, + // we will return undefined, which means that AGLint is not installed, + // but the extension will still work, because we have a fallback + // to the integrated version of AGLint. + } + } + + return undefined; +} diff --git a/server/src/utils/package-managers.ts b/server/src/utils/package-managers.ts new file mode 100644 index 0000000..0bd7f67 --- /dev/null +++ b/server/src/utils/package-managers.ts @@ -0,0 +1,134 @@ +/** + * @file Utility functions for package managers + */ + +import { execSync } from 'child_process'; +import { isAbsolute } from 'path'; +import { Files } from 'vscode-languageserver/node'; +import { EMPTY } from '../common/constants'; + +/** + * Node Package Manager + * + * @see https://www.npmjs.com/ + */ +export const NPM = 'npm'; + +/** + * Yarn Package Manager + * + * @see https://yarnpkg.com/ + */ +export const YARN = 'yarn'; + +/** + * PNPM Package Manager + * + * @see https://pnpm.io/ + */ +export const PNPM = 'pnpm'; + +/** + * Type of supported package managers + */ +export type PackageManager = typeof NPM | typeof YARN | typeof PNPM; + +/** + * Type of trace function. It's the same as the trace function + * as used in the VSCode server, but we define it here to give + * a more convenient way to use it via the type alias. + */ +export type TraceFunction = (message: string, verbose?: string) => void; + +/** + * Finds the global path for PNPM. This isn't implemented in the + * VSCode server, so we need to do it here. + * + * @param tracer Trace function (optional) + * @returns Path to the global PNPM root or undefined if not found + */ +export function findPnpmRoot(tracer?: TraceFunction): string | undefined { + try { + // Execute `pnpm root -g` command to find the global root. + // If the command fails (e.g. PNPM is not installed or + // not in the PATH), the execSync will throw an error + const result = execSync('pnpm root -g').toString().trim(); + + // If the command succeeded, we should check if the result + // is an absolute path. If it's not, we should ignore it + if (isAbsolute(result)) { + if (tracer) { + tracer(`Found global PNPM root: ${result}`); + } + + return result; + } + + if (tracer) { + tracer(`Global PNPM root is not an absolute path: ${result}`); + } + } catch (error: unknown) { + // If the execution failed, we simply ignore the error, + // and consider the PNPM root as not found + if (tracer) { + tracer(`Error while finding global PNPM root: ${error}`); + } + } + + return undefined; +} + +/** + * Find the path to the global root of the given package manager + * + * @param packageManager Name of the package manager (npm, yarn, pnpm) + * @param tracer Trace function (optional) + * @returns Path to the root directory of the package manager or undefined if not found + * or cannot be resolved + */ +export async function findGlobalPathForPackageManager( + packageManager: PackageManager, + tracer?: TraceFunction, +): Promise { + try { + switch (packageManager) { + case NPM: + // eslint-disable-next-line max-len + // TODO: It seems that this function marked as deprecated in the VSCode server, so we should find a better way to do this. + // Anyway, this solution is works for now, so it doesn't seem to be a big problem + return Files.resolveGlobalNodePath(tracer); + case YARN: + return Files.resolveGlobalYarnPath(tracer); + case PNPM: + return findPnpmRoot(tracer); + default: + return undefined; + } + } catch (error: unknown) { + // Error tolerance: if the function throws an error, we simply ignore it, + // and consider the global path as not found + return undefined; + } +} + +/** + * Returns the installation command for the given package manager and package name + * + * @param packageManager Name of the package manager (npm, yarn, pnpm) + * @param packageName Name of the package to install + * @param global Whether to install the package globally or not (default: false) + * @returns Corresponding installation command for the given package manager + */ +export function getInstallationCommand(packageManager: PackageManager, packageName: string, global = false): string { + switch (packageManager) { + case NPM: + return `npm install ${global ? '-g ' : EMPTY}${packageName}`; + case YARN: + return `yarn add ${global ? 'global ' : EMPTY}${packageName}`; + case PNPM: + return `pnpm install ${global ? '-g ' : EMPTY}${packageName}`; + default: + // Theoretically, this should never happen + return 'Cannot determine the package manager'; + } +} diff --git a/server/yarn.lock b/server/yarn.lock index 3efdf38..b7ac4cb 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -43,6 +43,16 @@ core-js-pure "^3.25.1" regenerator-runtime "^0.13.11" +"@types/clone-deep@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@types/clone-deep/-/clone-deep-4.0.1.tgz#7c488443ab9f571cd343d774551b78e9264ea990" + integrity sha512-bdkCSkyVHsgl3Goe1y16T9k6JuQx7SiDREkq728QjKmTZkGJZuS8R3gGcnGzVuGBP0mssKrzM/GlMOQxtip9cg== + +"@types/semver@^7.3.13": + version "7.3.13" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.13.tgz#da4bfd73f49bd541d28920ab0e2bf0ee80f71c91" + integrity sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw== + ansi-escapes@^4.2.1: version "4.3.2" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" @@ -278,7 +288,7 @@ regenerator-runtime@^0.13.11: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9" integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== -semver@^7.3.8: +semver@^7.3.8, semver@^7.5.0: version "7.5.0" resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.0.tgz#ed8c5dc8efb6c629c88b23d41dc9bf40c1d96cd0" integrity sha512-+XC0AD/R7Q2mPSRuy2Id0+CGTZ98+8f+KvwirxOKIEyid+XSx6HbC63p+O4IndTHuX5Z+JxQ0TghCkO5Cg/2HA==