diff --git a/extensions/studio/src/browser/c-language/c-declaration-service.ts b/extensions/studio/src/browser/c-language/c-declaration-service.ts new file mode 100644 --- /dev/null +++ b/extensions/studio/src/browser/c-language/c-declaration-service.ts @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: 2026 Star Haven contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { injectable, inject } from "@theia/core/shared/inversify"; +import { FileService } from "@theia/filesystem/lib/browser/file-service"; +import URI from "@theia/core/lib/common/uri"; +import type { ILanguageFeaturesService } from "@theia/monaco-editor-core/esm/vs/editor/common/services/languageFeatures"; +import type { CancellationToken } from "@theia/monaco-editor-core/esm/vs/editor/editor.api"; +import type { ITextModel } from "@theia/monaco-editor-core/esm/vs/editor/common/model"; + +export interface DeclarationInfo { + fileLines: string[]; + lineIndex: number; + docLines?: string[]; + declLine: string; +} + +export function parseDocComment( + fileLines: string[], + declarationLineIndex: number, +): string[] | undefined { + const lines: string[] = []; + for (let i = declarationLineIndex - 1; i >= 0; i--) { + const trimmed = fileLines[i].trimStart(); + if (trimmed.startsWith("///")) { + const content = trimmed.startsWith("/// ") + ? trimmed.slice(4) + : trimmed.slice(3); + lines.unshift(content); + } else { + break; + } + } + return lines.length > 0 ? lines : undefined; +} + +export function parseEvtParams(docLines: string[]): string[] { + const params: string[] = []; + for (const line of docLines) { + const match = line.match(/^@param\s+(\w+)/); + if (match) { + params.push(match[1]); + } + } + return params; +} + +export interface EvtOutput { + name: string; + description: string; +} + +export function parseEvtOutputs(docLines: string[]): EvtOutput[] { + const outputs: EvtOutput[] = []; + for (const line of docLines) { + const match = line.match(/^@evtout\s+(\w+)\s+(.*)/); + if (match) { + outputs.push({ name: match[1], description: match[2] }); + } + } + return outputs; +} + +export function stripEvtTags(docLines: string[]): string[] { + return docLines.filter( + (line) => + !line.startsWith("@param") && + !line.startsWith("@evtout") && + !line.startsWith("@evtapi"), + ); +} + +@injectable() +export class CDeclarationService { + @inject(FileService) + protected readonly fileService!: FileService; + + // Cache file contents by URI + protected readonly fileCache = new Map(); + + /** Find declaration/definition locations for a symbol, checking declaration first. */ + async findLocations( + featuresService: ILanguageFeaturesService, + model: ITextModel, + position: { lineNumber: number; column: number }, + token: CancellationToken, + ): Promise<{ uri: string; line: number }[]> { + const locations: { uri: string; line: number }[] = []; + const seen = new Set(); + + for (const registry of [ + featuresService.declarationProvider, + featuresService.definitionProvider, + ]) { + for (const provider of registry.ordered(model)) { + if (token.isCancellationRequested) { + return locations; + } + const method = + "provideDeclaration" in provider + ? (provider as any).provideDeclaration + : (provider as any).provideDefinition; + const result = await method.call(provider, model, position, token); + if (!result) { + continue; + } + const items = Array.isArray(result) ? result : [result]; + for (const item of items) { + if (!item?.uri || item.range == null) { + continue; + } + const line: number = + item.range.startLineNumber ?? item.range.start?.line + 1; + if (!line) { + continue; + } + const key = `${item.uri.toString()}:${line}`; + if (!seen.has(key)) { + seen.add(key); + locations.push({ uri: item.uri.toString(), line }); + } + } + break; + } + } + + return locations; + } + + /** Read a file and return declaration info at the given location. */ + async getDeclarationInfo( + uri: string, + line: number, + ): Promise { + const fileLines = await this.readFileLines(uri); + if (!fileLines) { + return null; + } + + const lineIndex = line - 1; + if (lineIndex < 0 || lineIndex >= fileLines.length) { + return null; + } + + return { + fileLines, + lineIndex, + docLines: parseDocComment(fileLines, lineIndex), + declLine: fileLines[lineIndex], + }; + } + + protected async readFileLines(uri: string): Promise { + const cached = this.fileCache.get(uri); + if (cached !== undefined) { + return cached.split("\n"); + } + + try { + const fileContent = await this.fileService.read(new URI(uri)); + this.fileCache.set(uri, fileContent.value); + return fileContent.value.split("\n"); + } catch { + return null; + } + } +} diff --git a/extensions/studio/src/browser/c-language/doc-comment-hover.ts b/extensions/studio/src/browser/c-language/doc-comment-hover.ts --- a/extensions/studio/src/browser/c-language/doc-comment-hover.ts +++ b/extensions/studio/src/browser/c-language/doc-comment-hover.ts @@ -4,8 +4,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { injectable, inject } from "@theia/core/shared/inversify"; import { FrontendApplicationContribution } from "@theia/core/lib/browser"; -import { FileService } from "@theia/filesystem/lib/browser/file-service"; -import URI from "@theia/core/lib/common/uri"; import { StandaloneServices } from "@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneServices"; import { ILanguageFeaturesService } from "@theia/monaco-editor-core/esm/vs/editor/common/services/languageFeatures"; import type { @@ -13,61 +11,12 @@ Position, CancellationToken, } from "@theia/monaco-editor-core/esm/vs/editor/editor.api"; import type { ITextModel } from "@theia/monaco-editor-core/esm/vs/editor/common/model"; - -interface EvtOutput { - name: string; - description: string; -} - -function parseDocComment( - fileLines: string[], - declarationLineIndex: number, -): string[] | undefined { - const lines: string[] = []; - for (let i = declarationLineIndex - 1; i >= 0; i--) { - const trimmed = fileLines[i].trimStart(); - if (trimmed.startsWith("///")) { - const content = trimmed.startsWith("/// ") - ? trimmed.slice(4) - : trimmed.slice(3); - lines.unshift(content); - } else { - break; - } - } - return lines.length > 0 ? lines : undefined; -} - -function parseEvtParams(docLines: string[]): string[] { - const params: string[] = []; - for (const line of docLines) { - const match = line.match(/^@param\s+(\w+)/); - if (match) { - params.push(match[1]); - } - } - return params; -} - -function parseEvtOutputs(docLines: string[]): EvtOutput[] { - const outputs: EvtOutput[] = []; - for (const line of docLines) { - const match = line.match(/^@evtout\s+(\w+)\s+(.*)/); - if (match) { - outputs.push({ name: match[1], description: match[2] }); - } - } - return outputs; -} - -function stripEvtTags(docLines: string[]): string[] { - return docLines.filter( - (line) => - !line.startsWith("@param") && - !line.startsWith("@evtout") && - !line.startsWith("@evtapi"), - ); -} +import { + CDeclarationService, + parseEvtParams, + parseEvtOutputs, + stripEvtTags, +} from "./c-declaration-service"; function formatHover(signature: string, docLines?: string[]): string { const body = docLines?.join("\n").trim(); @@ -79,8 +28,8 @@ function buildHover( fileLines: string[], lineIndex: number, + docLines?: string[], ): string | undefined { - const docLines = parseDocComment(fileLines, lineIndex); const declLine = fileLines[lineIndex]; const apiCallableMatch = declLine.match(/API_CALLABLE\((\w+)\)/); @@ -156,8 +105,8 @@ @injectable() export class DocCommentHoverContribution implements FrontendApplicationContribution { - @inject(FileService) - protected readonly fileService!: FileService; + @inject(CDeclarationService) + protected readonly declarationService!: CDeclarationService; onStart(): void { const featuresService = StandaloneServices.get(ILanguageFeaturesService); @@ -237,9 +186,7 @@ model: ITextModel, position: Position, token: CancellationToken, ): Promise { - // Try declaration first (header files with doc comments), - // then fall back to definition - const locations = await this.findLocations( + const locations = await this.declarationService.findLocations( featuresService, model, position, @@ -247,84 +194,19 @@ token, ); for (const loc of locations) { - const hover = await this.buildHoverFromLocation(loc); + const info = await this.declarationService.getDeclarationInfo( + loc.uri, + loc.line, + ); + if (!info) { + continue; + } + const hover = buildHover(info.fileLines, info.lineIndex, info.docLines); if (hover) { return hover; } } return null; - } - - protected async findLocations( - featuresService: ILanguageFeaturesService, - model: ITextModel, - position: Position, - token: CancellationToken, - ): Promise<{ uri: string; line: number }[]> { - const locations: { uri: string; line: number }[] = []; - const seen = new Set(); - - for (const registry of [ - featuresService.declarationProvider, - featuresService.definitionProvider, - ]) { - for (const provider of registry.ordered(model)) { - if (token.isCancellationRequested) { - return locations; - } - const method = - "provideDeclaration" in provider - ? (provider as any).provideDeclaration - : (provider as any).provideDefinition; - const result = await method.call(provider, model, position, token); - if (!result) { - continue; - } - const items = Array.isArray(result) ? result : [result]; - for (const item of items) { - if (!item?.uri || item.range == null) { - continue; - } - const line: number = - item.range.startLineNumber ?? item.range.start?.line + 1; - if (!line) { - continue; - } - const key = `${item.uri.toString()}:${line}`; - if (!seen.has(key)) { - seen.add(key); - locations.push({ uri: item.uri.toString(), line }); - } - } - break; - } - } - - return locations; - } - - protected async buildHoverFromLocation(loc: { - uri: string; - line: number; - }): Promise { - const targetUri = new URI(loc.uri); - - let content: string; - try { - const fileContent = await this.fileService.read(targetUri); - content = fileContent.value; - } catch { - return null; - } - - const fileLines = content.split("\n"); - const lineIndex = loc.line - 1; - - if (lineIndex < 0 || lineIndex >= fileLines.length) { - return null; - } - - return buildHover(fileLines, lineIndex) ?? null; } } diff --git a/extensions/studio/src/browser/c-language/evt-call-inlay-hints.ts b/extensions/studio/src/browser/c-language/evt-call-inlay-hints.ts new file mode 100644 --- /dev/null +++ b/extensions/studio/src/browser/c-language/evt-call-inlay-hints.ts @@ -0,0 +1,228 @@ +// SPDX-FileCopyrightText: 2026 Star Haven contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { injectable, inject } from "@theia/core/shared/inversify"; +import { FrontendApplicationContribution } from "@theia/core/lib/browser"; +import { Disposable, DisposableCollection } from "@theia/core/lib/common"; +import { StandaloneServices } from "@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneServices"; +import { ILanguageFeaturesService } from "@theia/monaco-editor-core/esm/vs/editor/common/services/languageFeatures"; +import type { CancellationToken } from "@theia/monaco-editor-core/esm/vs/editor/editor.api"; +import type { ITextModel } from "@theia/monaco-editor-core/esm/vs/editor/common/model"; +import { CDeclarationService, parseEvtParams } from "./c-declaration-service"; + +const CALL_PATTERN = /\bCall\s*\(/g; + +/** Parse Call() arguments, handling nested parens like Float(1.0). */ +function parseCallArgs( + lineContent: string, + callOpenIndex: number, +): { text: string; startCol: number }[] | undefined { + const args: { text: string; startCol: number }[] = []; + let depth = 1; + let current = ""; + let startCol = callOpenIndex + 1; + let i = callOpenIndex + 1; + + while (i < lineContent.length && depth > 0) { + const ch = lineContent[i]; + if (ch === "(") { + depth++; + current += ch; + } else if (ch === ")") { + depth--; + if (depth === 0) { + if (current.trim()) { + args.push({ text: current.trim(), startCol }); + } + } else { + current += ch; + } + } else if (ch === "," && depth === 1) { + if (current.trim()) { + args.push({ text: current.trim(), startCol }); + } + current = ""; + startCol = i + 1; + } else { + if (current.trim() === "" && ch !== " " && ch !== "\t") { + startCol = i; + } + current += ch; + } + i++; + } + + if (depth !== 0) { + return undefined; + } + + return args.length > 0 ? args : undefined; +} + +@injectable() +export class EvtCallInlayHintsContribution + implements FrontendApplicationContribution +{ + @inject(CDeclarationService) + protected readonly declarationService!: CDeclarationService; + + protected readonly toDispose = new DisposableCollection(); + + // Cache param names per function name + protected readonly paramCache = new Map(); + + onStart(): void { + const featuresService = StandaloneServices.get(ILanguageFeaturesService); + + for (const languageId of ["c", "cpp"]) { + const disposable = featuresService.inlayHintsProvider.register( + languageId, + { + provideInlayHints: async ( + model: ITextModel, + range: any, + token: CancellationToken, + ) => { + try { + return await this.provideInlayHints( + featuresService, + model, + range, + token, + ); + } catch { + return { hints: [], dispose() {} }; + } + }, + }, + ); + this.toDispose.push(Disposable.create(() => disposable.dispose())); + } + } + + onStop(): void { + this.toDispose.dispose(); + } + + protected async provideInlayHints( + featuresService: ILanguageFeaturesService, + model: ITextModel, + range: { startLineNumber: number; endLineNumber: number }, + token: CancellationToken, + ): Promise<{ hints: any[]; dispose(): void }> { + const hints: any[] = []; + + for ( + let lineNumber = range.startLineNumber; + lineNumber <= range.endLineNumber; + lineNumber++ + ) { + if (token.isCancellationRequested) { + break; + } + + const lineContent = model.getLineContent(lineNumber); + CALL_PATTERN.lastIndex = 0; + + let match; + while ((match = CALL_PATTERN.exec(lineContent)) !== null) { + const callStart = match.index + match[0].length - 1; + const args = parseCallArgs(lineContent, callStart); + if (!args || args.length < 2) { + continue; + } + + const funcName = args[0].text; + const paramArgs = args.slice(1); + + const paramNames = await this.getParamNames( + featuresService, + model, + lineNumber, + args[0].startCol + 1, + funcName, + token, + ); + + if (!paramNames || paramNames.length === 0) { + continue; + } + + for (let i = 0; i < paramArgs.length && i < paramNames.length; i++) { + hints.push({ + label: `${paramNames[i]}:`, + position: { + lineNumber, + column: paramArgs[i].startCol + 1, + }, + kind: 2, // InlayHintKind.Parameter + paddingRight: true, + }); + } + } + } + + return { hints, dispose() {} }; + } + + protected async getParamNames( + featuresService: ILanguageFeaturesService, + model: ITextModel, + lineNumber: number, + column: number, + funcName: string, + token: CancellationToken, + ): Promise { + const cached = this.paramCache.get(funcName); + if (cached !== undefined) { + return cached; + } + + const params = await this.resolveParamNames( + featuresService, + model, + { lineNumber, column }, + token, + ); + + this.paramCache.set(funcName, params); + return params; + } + + protected async resolveParamNames( + featuresService: ILanguageFeaturesService, + model: ITextModel, + position: { lineNumber: number; column: number }, + token: CancellationToken, + ): Promise { + const locations = await this.declarationService.findLocations( + featuresService, + model, + position, + token, + ); + + for (const loc of locations) { + const info = await this.declarationService.getDeclarationInfo( + loc.uri, + loc.line, + ); + if (!info) { + continue; + } + if (!info.declLine.match(/API_CALLABLE\(/)) { + continue; + } + if (!info.docLines) { + continue; + } + const params = parseEvtParams(info.docLines); + if (params.length > 0) { + return params; + } + } + + return null; + } +} diff --git a/extensions/studio/src/browser/studio-frontend-module.ts b/extensions/studio/src/browser/studio-frontend-module.ts --- a/extensions/studio/src/browser/studio-frontend-module.ts +++ b/extensions/studio/src/browser/studio-frontend-module.ts @@ -22,7 +22,9 @@ AssetBrowserUndoRedoHandler, } from "./asset-browser/contribution"; import { LayoutContribution } from "./layout-contribution"; import { CommentFontDecorator } from "./comment-font-decorator"; +import { CDeclarationService } from "./c-language/c-declaration-service"; import { DocCommentHoverContribution } from "./c-language/doc-comment-hover"; +import { EvtCallInlayHintsContribution } from "./c-language/evt-call-inlay-hints"; import "../../src/browser/asset-browser/style.css"; import "../../src/browser/fonts/fonts.css"; @@ -54,6 +56,13 @@ bind(CommentFontDecorator).toSelf().inSingletonScope(); bind(FrontendApplicationContribution).toService(CommentFontDecorator); + bind(CDeclarationService).toSelf().inSingletonScope(); + bind(DocCommentHoverContribution).toSelf().inSingletonScope(); bind(FrontendApplicationContribution).toService(DocCommentHoverContribution); + + bind(EvtCallInlayHintsContribution).toSelf().inSingletonScope(); + bind(FrontendApplicationContribution).toService( + EvtCallInlayHintsContribution, + ); });