blob: b92635ffe232fbb4ab54159415dcb23a05cc5efa [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Service, type Context } from './plugin-kernel.js';
import { PluginScopeRegistry } from './plugin-scope-registry.js';
import {
pluginIdentity,
registerPluginContribution,
type MakaContributionIdentity,
type MakaPluginRootId,
MakaPluginRuntimeError,
} from './plugin-runtime.js';
declare module './plugin-kernel.js' {
interface Context {
readonly lsp: PluginLspService;
}
}
export type PluginLspOperation = 'definition' | 'references' | 'implementation' | 'hover';
export interface PluginLspPosition {
readonly line: number;
readonly character: number;
}
export interface PluginLspRequest {
readonly sessionId: string;
readonly filePath: string;
readonly position: PluginLspPosition;
readonly operation: PluginLspOperation;
}
export interface PluginLspProvider {
readonly id: string;
readonly extensionToLanguage: Readonly<Record<string, string>>;
query(
request: PluginLspRequest & { readonly languageId: string },
signal?: AbortSignal,
): Promise<unknown>;
}
interface RegisteredRoute extends MakaContributionIdentity {
readonly provider: PluginLspProvider;
readonly languageId: string;
readonly token: symbol;
retired: boolean;
}
/** High-level LSP router; providers own processes, callers receive no JSON-RPC escape hatch. */
export class PluginLspService extends Service {
private readonly routes = new PluginScopeRegistry<RegisteredRoute>();
constructor(ctx: Context) {
super(ctx, 'lsp');
}
registerProvider(provider: PluginLspProvider): () => Promise<void> {
validateProvider(provider);
const identity = pluginIdentity(this.ctx);
if (identity.scopeId === 'desktop-ui')
throw new MakaPluginRuntimeError(
'activation_failed',
'desktop-ui plugins cannot register Host LSP providers',
);
return registerPluginContribution(
this.ctx,
`lsp.registerProvider(${JSON.stringify(provider.id)})`,
() => {
const rootId = identity.scopeId as MakaPluginRootId;
const pending = Object.entries(provider.extensionToLanguage).map(
([extension, languageId]) => [normalizeExtension(extension), languageId] as const,
);
for (const [extension] of pending) {
const existing = this.routes.get(rootId, extension);
if (existing && existing.entryId !== identity.entryId)
throw new MakaPluginRuntimeError(
'activation_failed',
`LSP extension ${JSON.stringify(extension)} is already registered by ${existing.entryId}`,
);
}
const disposers = pending.map(([extension, languageId]) =>
this.routes.publish(rootId, extension, {
...identity,
provider,
languageId,
token: Symbol(`${provider.id}:${extension}`),
retired: false,
}),
);
return async () => {
await Promise.all(disposers.reverse().map((dispose) => dispose()));
};
},
);
}
async query(request: PluginLspRequest, signal?: AbortSignal): Promise<unknown> {
validateRequest(request);
const route = this.routes.visible(request.sessionId).get(finalExtension(request.filePath));
if (!route) throw new Error(`No LSP provider handles ${JSON.stringify(request.filePath)}`);
if (signal?.aborted) throw signal.reason ?? new Error('LSP query aborted');
return await route.provider.query(
Object.freeze({ ...request, languageId: route.languageId }),
signal,
);
}
}
export function finalExtension(filePath: string): string {
const base = filePath.slice(Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\')) + 1);
const dot = base.lastIndexOf('.');
return dot <= 0 ? '' : base.slice(dot).toLowerCase();
}
function normalizeExtension(value: string): string {
const extension = value.toLowerCase();
const result = extension.startsWith('.') ? extension : `.${extension}`;
if (!/^\.[^./\\]+$/u.test(result)) throw new TypeError(`Invalid LSP extension: ${value}`);
return result;
}
function validateProvider(provider: PluginLspProvider): void {
if (
!provider.id.trim() ||
typeof provider.query !== 'function' ||
Object.keys(provider.extensionToLanguage).length === 0
)
throw new TypeError('Invalid LSP provider');
for (const [extension, language] of Object.entries(provider.extensionToLanguage)) {
normalizeExtension(extension);
if (!language.trim()) throw new TypeError('LSP language id must not be empty');
}
}
function validateRequest(request: PluginLspRequest): void {
if (
!request.sessionId ||
!request.filePath ||
!Number.isSafeInteger(request.position.line) ||
request.position.line < 0 ||
!Number.isSafeInteger(request.position.character) ||
request.position.character < 0
)
throw new TypeError('Invalid LSP query');
}