Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions src/intellisense/citationHoverProvider.ts
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;
Comment on lines +69 to +83
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

provideHover() reparses every .bib file 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.

Copilot uses AI. Check for mistakes.
}

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),
];
}
}
97 changes: 97 additions & 0 deletions src/intellisense/citationMetadata.ts
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
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

normalizeFieldValue() removes all {} characters. That will mangle common LaTeX constructs inside BibTeX fields (e.g., \textit{Foo} becomes \textitFoo) and can reduce readability in the hover/completion UI. Consider only stripping outer wrapping braces and simple capitalization-protection braces (e.g. {C}omprehensive), rather than deleting every brace unconditionally.

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extractField() only matches braced ({...}) or quoted ("...") field values. Common BibTeX like year = 2020 (bare numeric/identifier values) won’t be extracted, so hover/completion metadata will often miss year (and potentially other fields). Extend the matcher to also accept unbraced values up to the next comma/newline (while still handling nested braces).

Copilot uses AI. Check for mistakes.
}

export function parseBibContent(content: string): CitationMetadata[] {
const entries: CitationMetadata[] = [];
let match: RegExpExecArray | null;

Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

entryStartRegex is a module-level /g RegExp. Because RegExp.exec with the global flag mutates lastIndex, consecutive calls to parseBibContent() can start scanning from a stale index and silently skip entries (or return none) when parsing multiple .bib files. Reset entryStartRegex.lastIndex = 0 at the start of parseBibContent, or instantiate a new RegExp inside parseBibContent to avoid cross-call state.

Suggested change
// Ensure we always start scanning from the beginning of the content.
entryStartRegex.lastIndex = 0;

Copilot uses AI. Check for mistakes.
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;
}
34 changes: 29 additions & 5 deletions src/intellisense/langCompletionProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -411,22 +412,45 @@ export class ReferenceCompletionProvider extends IntellisenseProvider implements
}

private async getReferenceCompletionItemsFromBib(vfs: VirtualFileSystem): Promise<vscode.CompletionItem[]> {
const bibRegex = /@(?:(?!STRING\b)[^{])+\{\s*([^},]+)/gm;
const items = new Array<vscode.CompletionItem>();
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<vscode.CompletionItem[]>{
const regex = /\\bibitem\{([^\}]*)\}/g;
const bibUri = vfs.pathToUri(`${OUTPUT_FOLDER_NAME}/output.bbl`);
Expand Down
2 changes: 2 additions & 0 deletions src/intellisense/langIntellisenseProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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),
];
Expand Down
Loading