diff --git a/lib/auto-languageclient.ts b/lib/auto-languageclient.ts index 66c15343..3d2e4139 100644 --- a/lib/auto-languageclient.ts +++ b/lib/auto-languageclient.ts @@ -26,7 +26,13 @@ import * as Utils from "./utils" import { Socket } from "net" import { LanguageClientConnection } from "./languageclient" import { ConsoleLogger, FilteredLogger, Logger } from "./logger" -import { LanguageServerProcess, ServerManager, ActiveServer } from "./server-manager.js" +import { + LanguageServerProcess, + ServerManager, + ActiveServer, + normalizePath, + considerAdditionalPath, +} from "./server-manager.js" import { Disposable, CompositeDisposable, Point, Range, TextEditor } from "atom" import * as ac from "atom/autocomplete-plus" import { basename } from "path" @@ -308,7 +314,8 @@ export default class AutoLanguageClient { (e) => this.shouldStartForEditor(e), (filepath) => this.filterChangeWatchedFiles(filepath), this.reportBusyWhile, - this.getServerName() + this.getServerName(), + this.determineProjectPath ) this._serverManager.startListening() process.on("exit", () => this.exitCleanup.bind(this)) @@ -390,6 +397,7 @@ export default class AutoLanguageClient { connection, capabilities: initializeResponse.capabilities, disposable: new CompositeDisposable(), + additionalPaths: new Set(), } this.postInitialization(newServer) connection.initialized() @@ -477,6 +485,27 @@ export default class AutoLanguageClient { this.logger.debug(`exit: code ${code} signal ${signal}`) } + /** (Optional) Finds the project path. If there is a custom logic for finding projects override this method. */ + protected determineProjectPath(textEditor: TextEditor): string | null { + const filePath = textEditor.getPath() + // TODO can filePath be null + if (filePath === null || filePath === undefined) { + return null + } + const projectPath = this._serverManager.getNormalizedProjectPaths().find((d) => filePath.startsWith(d)) + if (projectPath !== undefined) { + return projectPath + } + + const serverWithClaim = this._serverManager + .getActiveServers() + .find((server) => server.additionalPaths?.has(path.dirname(filePath))) + if (serverWithClaim !== undefined) { + return normalizePath(serverWithClaim.projectPath) + } + return null + } + /** * The function called whenever the spawned server returns `data` in `stderr` Extend (call super.onSpawnStdErrData) or * override this if you need custom stderr data handling @@ -668,7 +697,23 @@ export default class AutoLanguageClient { } this.definitions = this.definitions || new DefinitionAdapter() - return this.definitions.getDefinition(server.connection, server.capabilities, this.getLanguageName(), editor, point) + const query = await this.definitions.getDefinition( + server.connection, + server.capabilities, + this.getLanguageName(), + editor, + point + ) + + if (query !== null && server.additionalPaths !== undefined) { + // populate additionalPaths based on definitions + // Indicates that the language server can support LSP functionality for out of project files indicated by `textDocument/definition` responses. + for (const def of query.definitions) { + considerAdditionalPath(server as ActiveServer & { additionalPaths: Set }, path.dirname(def.path)) + } + } + + return query } // Outline View via LS documentSymbol--------------------------------- diff --git a/lib/server-manager.ts b/lib/server-manager.ts index e078a3e3..289f92d2 100644 --- a/lib/server-manager.ts +++ b/lib/server-manager.ts @@ -22,6 +22,8 @@ export interface ActiveServer { process: LanguageServerProcess connection: ls.LanguageClientConnection capabilities: ls.ServerCapabilities + /** Out of project directories that this server can also support. */ + additionalPaths?: Set } interface RestartCounter { @@ -47,7 +49,8 @@ export class ServerManager { private _startForEditor: (editor: TextEditor) => boolean, private _changeWatchedFileFilter: (filePath: string) => boolean, private _reportBusyWhile: ReportBusyWhile, - private _languageServerName: string + private _languageServerName: string, + private _determineProjectPath: (textEditor: TextEditor) => string | null ) { this.updateNormalizedProjectPaths() } @@ -121,7 +124,7 @@ export class ServerManager { textEditor: TextEditor, { shouldStart }: { shouldStart?: boolean } = { shouldStart: false } ): Promise { - const finalProjectPath = this.determineProjectPath(textEditor) + const finalProjectPath = this._determineProjectPath(textEditor) if (finalProjectPath == null) { // Files not yet saved have no path return null @@ -245,14 +248,6 @@ export class ServerManager { }) } - public determineProjectPath(textEditor: TextEditor): string | null { - const filePath = textEditor.getPath() - if (filePath == null) { - return null - } - return this._normalizedProjectPaths.find((d) => filePath.startsWith(d)) || null - } - public updateNormalizedProjectPaths(): void { this._normalizedProjectPaths = atom.project.getPaths().map(normalizePath) } @@ -350,3 +345,13 @@ export function normalizedProjectPathToWorkspaceFolder(normalizedProjectPath: st export function normalizePath(projectPath: string): string { return !projectPath.endsWith(path.sep) ? path.join(projectPath, path.sep) : projectPath } + +/** Considers a path for inclusion in `additionalPaths`. */ +export function considerAdditionalPath( + server: ActiveServer & { additionalPaths: Set }, + additionalPath: string +): void { + if (!additionalPath.startsWith(server.projectPath)) { + server.additionalPaths.add(additionalPath) + } +} diff --git a/test/auto-languageclient.test.ts b/test/auto-languageclient.test.ts index f1cadebc..183b7759 100644 --- a/test/auto-languageclient.test.ts +++ b/test/auto-languageclient.test.ts @@ -1,7 +1,14 @@ +import { TextEditor } from "atom" import AutoLanguageClient from "../lib/auto-languageclient" -import { projectPathToWorkspaceFolder, ServerManager } from "../lib/server-manager" +import { + projectPathToWorkspaceFolder, + ServerManager, + ActiveServer, + considerAdditionalPath, + normalizePath, +} from "../lib/server-manager" import { FakeAutoLanguageClient } from "./helpers" -import { dirname } from "path" +import { dirname, join } from "path" function mockEditor(uri: string, scopeName: string): any { return { @@ -12,20 +19,84 @@ function mockEditor(uri: string, scopeName: string): any { } } +function setupClient() { + atom.workspace.getTextEditors().forEach((editor) => editor.destroy()) + atom.project.getPaths().forEach((project) => atom.project.removePath(project)) + const client = new FakeAutoLanguageClient() + client.activate() + return client +} + +function setupServerManager(client = setupClient()) { + /* eslint-disable-next-line dot-notation */ + const serverManager = client["_serverManager"] + return serverManager +} + describe("AutoLanguageClient", () => { + describe("determineProjectPath", () => { + it("returns the project path for an internal or an external file in the project", async () => { + if (process.platform === "darwin") { + // there is nothing OS specific about the code. It just hits the limits that MacOS can handle in this test + pending("skipped on MacOS") + return + } + const client = setupClient() + const serverManager = setupServerManager(client) + + // "returns null when a single file is open" + + let textEditor = (await atom.workspace.open(__filename)) as TextEditor + /* eslint-disable-next-line dot-notation */ + expect(client["determineProjectPath"](textEditor)).toBeNull() + textEditor.destroy() + + // "returns the project path when a file of that project is open" + const projectPath = __dirname + + // gives the open workspace folder + atom.project.addPath(projectPath) + await serverManager.startServer(projectPath) + + textEditor = (await atom.workspace.open(__filename)) as TextEditor + /* eslint-disable-next-line dot-notation */ + expect(client["determineProjectPath"](textEditor)).toBe(normalizePath(projectPath)) + textEditor.destroy() + + // "returns the project path when an external file is open and it is not in additional paths" + + const externalDir = join(dirname(projectPath), "lib") + const externalFile = join(externalDir, "main.js") + + // gives the open workspace folder + atom.project.addPath(projectPath) + await serverManager.startServer(projectPath) + + textEditor = (await atom.workspace.open(externalFile)) as TextEditor + /* eslint-disable-next-line dot-notation */ + expect(client["determineProjectPath"](textEditor)).toBeNull() + textEditor.destroy() + + // "returns the project path when an external file is open and it is in additional paths" + + // get server + const server = serverManager.getActiveServers()[0] + expect(typeof server.additionalPaths).toBe("object") // Set() + // add additional path + considerAdditionalPath(server as ActiveServer & { additionalPaths: Set }, externalDir) + expect(server.additionalPaths?.has(externalDir)).toBeTrue() + + textEditor = (await atom.workspace.open(externalFile)) as TextEditor + /* eslint-disable-next-line dot-notation */ + expect(client["determineProjectPath"](textEditor)).toBe(normalizePath(projectPath)) + textEditor.destroy() + }) + }) describe("ServerManager", () => { describe("WorkspaceFolders", () => { - let client: FakeAutoLanguageClient let serverManager: ServerManager - beforeEach(() => { - atom.workspace.getTextEditors().forEach((editor) => editor.destroy()) - atom.project.getPaths().forEach((project) => atom.project.removePath(project)) - client = new FakeAutoLanguageClient() - client.activate() - - /* eslint-disable-next-line dot-notation */ - serverManager = client["_serverManager"] + serverManager = setupServerManager() }) afterEach(() => {