-
Notifications
You must be signed in to change notification settings - Fork 62
feat(intellisense): add citation hover metadata display #341
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<CitationMetadata | undefined> { | ||
| 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<vscode.Hover | undefined> { | ||
| 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), | ||
| ]; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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(); | ||||||||||
|
Comment on lines
+28
to
+40
|
||||||||||
| 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; | ||||||||||
|
Comment on lines
+44
to
+56
|
||||||||||
| } | ||||||||||
|
|
||||||||||
| export function parseBibContent(content: string): CitationMetadata[] { | ||||||||||
| const entries: CitationMetadata[] = []; | ||||||||||
| let match: RegExpExecArray | null; | ||||||||||
|
|
||||||||||
|
||||||||||
| // Ensure we always start scanning from the beginning of the content. | |
| entryStartRegex.lastIndex = 0; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
provideHover()reparses every.bibfile on each hover request (openFile+parseBibContent+find). Hover is invoked frequently and this can become noticeable on large bibliographies or remote FS latency. Consider caching parsed entries (e.g., a Map keyed by bib path/uri + file version/mtime) and reusing it across hover calls, invalidating when the bib file changes.