diff --git a/src/intellisense/citationHoverProvider.ts b/src/intellisense/citationHoverProvider.ts new file mode 100644 index 0000000..fd62e97 --- /dev/null +++ b/src/intellisense/citationHoverProvider.ts @@ -0,0 +1,105 @@ +import * as vscode from 'vscode'; +import { IntellisenseProvider } from '.'; +import { RemoteFileSystemProvider } from '../core/remoteFileSystemProvider'; +import { TexDocumentSymbolProvider } from './texDocumentSymbolProvider'; +import { CitationMetadata, parseBibContent } from './citationMetadata'; + +export class CitationHoverProvider extends IntellisenseProvider implements vscode.HoverProvider { + protected readonly contextPrefix = []; + + constructor( + vfsm: RemoteFileSystemProvider, + private readonly texSymbolProvider: TexDocumentSymbolProvider, + ) { + super(vfsm); + } + + private findCitationKeyAtPosition(document: vscode.TextDocument, position: vscode.Position): string | undefined { + const line = document.lineAt(position.line); + const lineText = line.text; + const targetOffset = position.character; + const citationRegex = /\\(?:cite\w*|\w*cite)(?:\[[^\]]*\])*\{([^}]*)\}/g; + + let match: RegExpExecArray | null; + while ((match = citationRegex.exec(lineText))) { + const full = match[0]; + const keysRaw = match[1] ?? ''; + const keysStart = match.index + full.lastIndexOf('{') + 1; + const keysEnd = keysStart + keysRaw.length; + if (targetOffset < keysStart || targetOffset > keysEnd) { + continue; + } + + let cursor = 0; + const parts = keysRaw.split(','); + for (const part of parts) { + const leadingSpaces = part.match(/^\s*/)?.[0].length ?? 0; + const trailingSpaces = part.match(/\s*$/)?.[0].length ?? 0; + const keyStart = cursor + leadingSpaces; + const keyEnd = cursor + part.length - trailingSpaces; + if (targetOffset >= keysStart + keyStart && targetOffset <= keysStart + keyEnd) { + const key = part.trim(); + return key === '' ? undefined : key; + } + cursor += part.length + 1; + } + } + + return undefined; + } + + private formatHover(entry: CitationMetadata): vscode.Hover { + const escapeMarkdown = (value: string) => value.replace(/[\\`*_{}\[\]()#+\-.!|>]/g, '\\$&'); + const markdown = new vscode.MarkdownString(); + const title = entry.title ?? entry.key; + markdown.appendMarkdown(`### ${escapeMarkdown(title)}\n\n`); + if (entry.author) { + markdown.appendMarkdown(`**Authors**: ${escapeMarkdown(entry.author)} \n`); + } + if (entry.year) { + markdown.appendMarkdown(`**Year**: ${escapeMarkdown(entry.year)} \n`); + } + if (entry.journal ?? entry.booktitle) { + markdown.appendMarkdown(`**Venue**: *${escapeMarkdown(entry.journal ?? entry.booktitle ?? '')}* \n`); + } + markdown.appendMarkdown(`\n---\nKey: \`${escapeMarkdown(entry.key)}\``); + return new vscode.Hover(markdown); + } + + private async getCitationMetadata(uri: vscode.Uri, citationKey: string): Promise { + const vfs = await this.vfsm.prefetch(uri); + for (const path of this.texSymbolProvider.currentBibPathArray) { + try { + const raw = await vfs.openFile(vfs.pathToUri(path)); + const entries = parseBibContent(new TextDecoder().decode(raw)); + const found = entries.find(entry => entry.key === citationKey); + if (found) { + return found; + } + } catch { + continue; + } + } + return undefined; + } + + async provideHover(document: vscode.TextDocument, position: vscode.Position): Promise { + const citationKey = this.findCitationKeyAtPosition(document, position); + if (!citationKey) { + return undefined; + } + + const metadata = await this.getCitationMetadata(document.uri, citationKey); + if (!metadata) { + return undefined; + } + + return this.formatHover(metadata); + } + + get triggers(): vscode.Disposable[] { + return [ + vscode.languages.registerHoverProvider(this.selector, this), + ]; + } +} diff --git a/src/intellisense/citationMetadata.ts b/src/intellisense/citationMetadata.ts new file mode 100644 index 0000000..bb823af --- /dev/null +++ b/src/intellisense/citationMetadata.ts @@ -0,0 +1,97 @@ +export type CitationMetadata = { + key: string; + title?: string; + author?: string; + year?: string; + journal?: string; + booktitle?: string; +}; + +const entryStartRegex = /@(?:(?!STRING\b)[^{])+\{\s*([^},]+),/gim; + +function findMatchingBrace(content: string, openBraceIndex: number): number { + let depth = 0; + for (let i = openBraceIndex; i < content.length; i++) { + const ch = content[i]; + if (ch === '{') { + depth += 1; + } else if (ch === '}') { + depth -= 1; + if (depth === 0) { + return i; + } + } + } + return -1; +} + +function normalizeFieldValue(rawValue: string): string { + // Normalize BibTeX formatting for UI display. + // This strips capitalization/grouping braces (e.g. {C}omprehensive -> Comprehensive) + // and unescapes a few common escaped characters. + const normalized = rawValue + .replace(/[{}]/g, '') + .replace(/\\&/g, '&') + .replace(/\\_/g, '_') + .replace(/\\%/g, '%') + .replace(/\\#/g, '#') + .replace(/[\r\n\t]+/g, ' ') + .replace(/\s{2,}/g, ' ') + .trim(); + return normalized; +} + +function extractField(entryBody: string, fieldName: string): string | undefined { + const regex = new RegExp(`(?:^|,)\\s*${fieldName}\\s*=\\s*(\\{(?:[^{}]|\\{[^{}]*\\})*\\}|\"[^\"]*\")`, 'im'); + const match = regex.exec(entryBody); + if (!match) { + return undefined; + } + + let value = match[1].trim(); + if ((value.startsWith('{') && value.endsWith('}')) || (value.startsWith('"') && value.endsWith('"'))) { + value = value.slice(1, -1); + } + value = normalizeFieldValue(value); + return value === '' ? undefined : value; +} + +export function parseBibContent(content: string): CitationMetadata[] { + const entries: CitationMetadata[] = []; + let match: RegExpExecArray | null; + + while ((match = entryStartRegex.exec(content))) { + const key = match[1]?.trim(); + if (!key) { + continue; + } + + const openBraceIndex = content.indexOf('{', match.index); + if (openBraceIndex < 0) { + continue; + } + const closeBraceIndex = findMatchingBrace(content, openBraceIndex); + if (closeBraceIndex < 0) { + continue; + } + + const entryBody = content.slice(openBraceIndex + 1, closeBraceIndex); + const firstCommaIndex = entryBody.indexOf(','); + if (firstCommaIndex < 0) { + entries.push({ key }); + continue; + } + + const fieldBody = entryBody.slice(firstCommaIndex + 1); + entries.push({ + key, + title: extractField(fieldBody, 'title'), + author: extractField(fieldBody, 'author'), + year: extractField(fieldBody, 'year'), + journal: extractField(fieldBody, 'journal'), + booktitle: extractField(fieldBody, 'booktitle'), + }); + } + + return entries; +} diff --git a/src/intellisense/langCompletionProvider.ts b/src/intellisense/langCompletionProvider.ts index 9acf05e..47619b1 100644 --- a/src/intellisense/langCompletionProvider.ts +++ b/src/intellisense/langCompletionProvider.ts @@ -4,6 +4,7 @@ import { SnippetItemSchema } from '../api/base'; import { fuzzyFilter, IntellisenseProvider } from '.'; import { RemoteFileSystemProvider, VirtualFileSystem, parseUri } from '../core/remoteFileSystemProvider'; import { TexDocumentSymbolProvider } from './texDocumentSymbolProvider'; +import { CitationMetadata, parseBibContent } from './citationMetadata'; type SnippetItemMap = {[K:string]: SnippetItemSchema}; type FilePathCompletionType = 'text' | 'image' | 'bib'; @@ -411,22 +412,45 @@ export class ReferenceCompletionProvider extends IntellisenseProvider implements } private async getReferenceCompletionItemsFromBib(vfs: VirtualFileSystem): Promise { - const bibRegex = /@(?:(?!STRING\b)[^{])+\{\s*([^},]+)/gm; const items = new Array(); for (const path of this.texSymbolProvider.currentBibPathArray) { try{ const rawContent = await vfs.openFile( vfs.pathToUri(path) ); const content = new TextDecoder().decode(rawContent); - let match: RegExpExecArray | null; - while (match = bibRegex.exec(content)) { - const item = new vscode.CompletionItem(match[1], vscode.CompletionItemKind.Reference); - items.push(item); + const entries = parseBibContent(content); + for (const entry of entries) { + items.push(this.createCitationCompletionItem(entry)); } } catch{} }; return items; } + private createCitationCompletionItem(entry: CitationMetadata): vscode.CompletionItem { + const escapeMarkdown = (value: string) => value.replace(/[\\`*_{}\[\]()#+\-.!|>]/g, '\\$&'); + const item = new vscode.CompletionItem(entry.key, vscode.CompletionItemKind.Reference); + const subtitle = [entry.author, entry.year].filter(Boolean).join(' - '); + if (subtitle) { + item.detail = subtitle; + } + + const markdown = new vscode.MarkdownString(); + const title = entry.title ?? entry.key; + markdown.appendMarkdown(`**${escapeMarkdown(title)}** \n`); + if (entry.author) { + markdown.appendMarkdown(`**Authors**: ${escapeMarkdown(entry.author)} \n`); + } + if (entry.year) { + markdown.appendMarkdown(`**Year**: ${escapeMarkdown(entry.year)} \n`); + } + if (entry.journal ?? entry.booktitle) { + markdown.appendMarkdown(`**Venue**: *${escapeMarkdown(entry.journal ?? entry.booktitle ?? '')}* \n`); + } + markdown.appendMarkdown(`Key: \`${escapeMarkdown(entry.key)}\``); + item.documentation = markdown; + return item; + } + private async getReferenceCompletionItemsFromBbl(vfs: VirtualFileSystem): Promise{ const regex = /\\bibitem\{([^\}]*)\}/g; const bibUri = vfs.pathToUri(`${OUTPUT_FOLDER_NAME}/output.bbl`); diff --git a/src/intellisense/langIntellisenseProvider.ts b/src/intellisense/langIntellisenseProvider.ts index 0b6dd5d..1950550 100644 --- a/src/intellisense/langIntellisenseProvider.ts +++ b/src/intellisense/langIntellisenseProvider.ts @@ -7,6 +7,7 @@ import { TexDocumentSymbolProvider } from './texDocumentSymbolProvider'; import { TexDocumentFormatProvider } from './texDocumentFormatProvider'; import { MisspellingCheckProvider } from './langMisspellingCheckProvider'; import { CommandCompletionProvider, ConstantCompletionProvider, FilePathCompletionProvider, ReferenceCompletionProvider } from './langCompletionProvider'; +import { CitationHoverProvider } from './citationHoverProvider'; export class LangIntellisenseProvider { private status: vscode.StatusBarItem; @@ -24,6 +25,7 @@ export class LangIntellisenseProvider { new ConstantCompletionProvider(vfsm, context.extensionUri), new FilePathCompletionProvider(vfsm), new ReferenceCompletionProvider(vfsm, texSymbolProvider), + new CitationHoverProvider(vfsm, texSymbolProvider), // misspelling check provider new MisspellingCheckProvider(vfsm), ];