diff --git a/src/completion/completer/citation.ts b/src/completion/completer/citation.ts index b54d1307a..c53e19a15 100644 --- a/src/completion/completer/citation.ts +++ b/src/completion/completer/citation.ts @@ -44,7 +44,10 @@ export const bibTools = { parseAbbrevations } -function expandField(abbreviations: {[key: string]: string}, value: bibtexParser.FieldValue): string { +function expandField(abbreviations: {[key: string]: string}, value: bibtexParser.FieldValue | undefined): string { + if (value === undefined) { + return '' + } if (value.kind === 'concat') { const args = value.content as bibtexParser.FieldValue[] return args.map(arg => expandField(abbreviations, arg)).join(' ') @@ -97,7 +100,7 @@ function provide(uri: vscode.Uri, line: string, position: vscode.Position): Comp const label = configuration.get('intellisense.citation.label') as string const fields = readCitationFormat(configuration) const range: vscode.Range | undefined = computeFilteringRange(line, position) - return updateAll(getIncludedBibs(lw.root.file.path)).map(item => { + return updateAll(lw.cache.getIncludedBib(lw.root.file.path)).map(item => { // Compile the completion item label switch(label) { case 'bibtex key': @@ -128,7 +131,7 @@ function browser(args?: CompletionArgs) { const configuration = vscode.workspace.getConfiguration('latex-workshop', args?.uri) const label = configuration.get('intellisense.citation.label') as string const fields = readCitationFormat(configuration, label) - void vscode.window.showQuickPick(updateAll(getIncludedBibs(lw.root.file.path)).map(item => { + void vscode.window.showQuickPick(updateAll(lw.cache.getIncludedBib(lw.root.file.path)).map(item => { return { label: item.fields.title ? trimMultiLineString(item.fields.title) : '', description: item.key, @@ -175,33 +178,6 @@ function getItem(key: string, configurationScope?: vscode.ConfigurationScope): C return entry } -/** - * Returns the array of the paths of `.bib` files referenced from `file`. - * - * @param file The path of a LaTeX file. If `undefined`, the keys of `bibEntries` are used. - * @param visitedTeX Internal use only. - */ -function getIncludedBibs(file?: string, visitedTeX: string[] = []): string[] { - if (file === undefined) { - // Only happens when rootFile is undefined - return Array.from(data.bibEntries.keys()) - } - const cache = lw.cache.get(file) - if (cache === undefined) { - return [] - } - let bibs = Array.from(cache.bibfiles) - visitedTeX.push(file) - for (const child of cache.children) { - if (visitedTeX.includes(child.filePath)) { - // Already included - continue - } - bibs = Array.from(new Set(bibs.concat(getIncludedBibs(child.filePath, visitedTeX)))) - } - return bibs -} - /** * Returns aggregated bib entries from `.bib` files and bibitems defined on LaTeX files included in the root file. * diff --git a/src/completion/completer/glossary.ts b/src/completion/completer/glossary.ts index fda300e98..b1ca6b037 100644 --- a/src/completion/completer/glossary.ts +++ b/src/completion/completer/glossary.ts @@ -1,29 +1,35 @@ import * as vscode from 'vscode' import type * as Ast from '@unified-latex/unified-latex-types' +import { bibtexParser } from 'latex-utensils' import { lw } from '../../lw' import { GlossaryType } from '../../types' import type { CompletionProvider, FileCache, GlossaryItem } from '../../types' import { argContentToStr } from '../../utils/parser' import { getLongestBalancedString } from '../../utils/utils' +import { bibTools } from './citation' +const logger = lw.log('Intelli', 'Glossary') export const provider: CompletionProvider = { from } export const glossary = { parse, - getItem + getItem, + parseBibFile } const data = { + // The keys are the labels of the glossary items. glossaries: new Map(), - acronyms: new Map() + acronyms: new Map(), + // The keys are the paths of the `.bib` files. + bibEntries: new Map() } -interface GlossaryEntry { - label: string | undefined, - description: string | undefined -} +lw.watcher.bib.onCreate(uri => parseBibFile(uri.fsPath)) +lw.watcher.bib.onChange(uri => parseBibFile(uri.fsPath)) +lw.watcher.bib.onDelete(uri => removeEntriesInFile(uri.fsPath)) function from(result: RegExpMatchArray): vscode.CompletionItem[] { - updateAll() + updateAll(lw.cache.getIncludedGlossaryBib(lw.root.file.path)) let suggestions: Map if (result[1] && result[1].match(/^ac/i)) { @@ -38,14 +44,33 @@ function from(result: RegExpMatchArray): vscode.CompletionItem[] { } function getItem(token: string): GlossaryItem | undefined { - updateAll() + updateAll(lw.cache.getIncludedGlossaryBib(lw.root.file.path)) return data.glossaries.get(token) || data.acronyms.get(token) } -function updateAll() { + +/** + * Returns aggregated glossary entries from `.bib` files and glossary items defined on LaTeX files included in the root file. + * + * @param bibFiles The array of the paths of `.bib` files. If `undefined`, the keys of `bibEntries` are used. + */ +function updateAll(bibFiles: string[]) { // Extract cached references const glossaryList: string[] = [] + // From bib files + bibFiles.forEach(file => { + const entries = data.bibEntries.get(file) + entries?.forEach(entry => { + if (entry.type === GlossaryType.glossary) { + data.glossaries.set(entry.label, entry) + } else { + data.acronyms.set(entry.label, entry) + } + glossaryList.push(entry.label) + }) + }) + lw.cache.getIncludedTeX().forEach(cachedFile => { const cachedGlossaries = lw.cache.get(cachedFile)?.elements.glossary if (cachedGlossaries === undefined) { @@ -61,7 +86,7 @@ function updateAll() { }) }) - // Remove references that has been deleted + // Remove references that have been deleted data.glossaries.forEach((_, key) => { if (!glossaryList.includes(key)) { data.glossaries.delete(key) @@ -74,6 +99,64 @@ function updateAll() { }) } +/** + * Parse a glossary `.bib` file. The results are stored in this instance. + * + * @param fileName The path of `.bib` file. + */ +async function parseBibFile(fileName: string) { + logger.log(`Parsing glossary .bib entries from ${fileName}`) + const configuration = vscode.workspace.getConfiguration('latex-workshop', vscode.Uri.file(fileName)) + if ((await lw.external.stat(vscode.Uri.file(fileName))).size >= (configuration.get('bibtex.maxFileSize') as number) * 1024 * 1024) { + logger.log(`Bib file is too large, ignoring it: ${fileName}`) + data.bibEntries.delete(fileName) + return + } + const newEntry: GlossaryItem[] = [] + const bibtex = await lw.file.read(fileName) + logger.log(`Parse BibTeX AST from ${fileName} .`) + const ast = await lw.parser.parse.bib(vscode.Uri.file(fileName), bibtex ?? '') + if (ast === undefined) { + logger.log(`Parsed 0 bib entries from ${fileName}.`) + lw.event.fire(lw.event.FileParsed, fileName) + return + } + const abbreviations = bibTools.parseAbbrevations(ast) + ast.content + .filter(bibtexParser.isEntry) + .forEach((entry: bibtexParser.Entry) => { + if (entry.internalKey === undefined) { + return + } + let type: GlossaryType + if ( ['entry'].includes(entry.entryType) ) { + type = GlossaryType.glossary + } else { + type = GlossaryType.acronym + } + const name = bibTools.expandField(abbreviations, entry.content.find(field => field.name === 'name')?.value) + const description = bibTools.expandField(abbreviations, entry.content.find(field => field.name === 'description')?.value) + const item: GlossaryItem = { + type, + label: entry.internalKey, + filePath: fileName, + position: new vscode.Position(entry.location.start.line - 1, entry.location.start.column - 1), + kind: vscode.CompletionItemKind.Reference, + detail: name + ': ' + description + } + newEntry.push(item) + }) + data.bibEntries.set(fileName, newEntry) + logger.log(`Parsed ${newEntry.length} glossary bib entries from ${fileName} .`) + void lw.outline.reconstruct() + lw.event.fire(lw.event.FileParsed, fileName) +} + +function removeEntriesInFile(file: string) { + logger.log(`Remove parsed bib entries for ${file}`) + data.bibEntries.delete(file) +} + function parse(cache: FileCache) { if (cache.ast !== undefined) { cache.elements.glossary = parseAst(cache.ast, cache.filePath) @@ -84,12 +167,13 @@ function parse(cache: FileCache) { function parseAst(node: Ast.Node, filePath: string): GlossaryItem[] { let glos: GlossaryItem[] = [] - let entry: GlossaryEntry = { label: '', description: '' } + let label: string = '' + let description: string = '' let type: GlossaryType | undefined if (node.type === 'macro' && ['newglossaryentry', 'provideglossaryentry'].includes(node.content)) { type = GlossaryType.glossary - let description = argContentToStr(node.args?.[1]?.content || [], true) + description = argContentToStr(node.args?.[1]?.content || [], true) const index = description.indexOf('description=') if (index >= 0) { description = description.slice(index + 12) @@ -101,28 +185,23 @@ function parseAst(node: Ast.Node, filePath: string): GlossaryItem[] { } else { description = '' } - entry = { - label: argContentToStr(node.args?.[0]?.content || []), - description - } + label = argContentToStr(node.args?.[0]?.content || []) } else if (node.type === 'macro' && ['longnewglossaryentry', 'longprovideglossaryentry', 'newacronym', 'newabbreviation', 'newabbr'].includes(node.content)) { if (['longnewglossaryentry', 'longprovideglossaryentry'].includes(node.content)) { type = GlossaryType.glossary } else { type = GlossaryType.acronym } - entry = { - label: argContentToStr(node.args?.[1]?.content || []), - description: argContentToStr(node.args?.[3]?.content || []), - } + label = argContentToStr(node.args?.[1]?.content || []) + description = argContentToStr(node.args?.[3]?.content || []) } - if (type !== undefined && entry.label && entry.description && node.position !== undefined) { + if (type !== undefined && label && description && node.position !== undefined) { glos.push({ type, filePath, position: new vscode.Position(node.position.start.line - 1, node.position.start.column - 1), - label: entry.label, - detail: entry.description, + label, + detail: description, kind: vscode.CompletionItemKind.Reference }) } diff --git a/src/core/cache.ts b/src/core/cache.ts index 4088f92a5..48f46c6b1 100644 --- a/src/core/cache.ts +++ b/src/core/cache.ts @@ -36,6 +36,7 @@ export const cache = { promises, getIncludedTeX, getIncludedBib, + getIncludedGlossaryBib, getFlsChildren, wait, reset, @@ -250,6 +251,7 @@ async function refreshCache(filePath: string, rootPath?: string): Promise { lw.completion.subsuperscript.parse(fileCache) lw.completion.input.parseGraphicsPath(fileCache) await updateBibfiles(fileCache) + await updateGlossaryBibFiles(fileCache) const elapsed = performance.now() - start logger.log(`Updated elements in ${elapsed.toFixed(2)} ms: ${fileCache.filePath} .`) } @@ -516,6 +519,41 @@ async function updateBibfiles(fileCache: FileCache) { } } +/** + * Updates the glossary files associated with a given file cache. + * + * This function parses the content of a file cache to find `\GlsXtrLoadResources`` + * using a regular expression. It extracts the file paths specified in these + * macros, resolves their full paths, and adds them to the set of glossary + * files in the file cache. If a glossary file is not excluded, it logs the + * action, adds the file to the cache, and ensures that it is being watched for + * changes. + * + * @param {FileCache} fileCache - The file cache object to update with + * bibliography files. + */ +async function updateGlossaryBibFiles(fileCache: FileCache) { + const glossaryReg = /\\GlsXtrLoadResources\s*\[.*?src=\{([^}]+)\}.*?\]/gs + + let result: RegExpExecArray | null + while ((result = glossaryReg.exec(fileCache.contentTrimmed)) !== null) { + const bibs = (result[1] ? result[1] : result[2]).split(',').map(bib => bib.trim()) + + for (const bib of bibs) { + const bibPath = await utils.resolveFile([path.dirname(fileCache.filePath)], bib, '.bib') + if (!bibPath || isExcluded(bibPath)) { + continue + } + fileCache.glossarybibfiles.add(bibPath) + logger.log(`Glossary bib ${bibPath} from ${fileCache.filePath} .`) + const bibUri = vscode.Uri.file(bibPath) + if (!lw.watcher.bib.has(bibUri)) { + lw.watcher.bib.add(bibUri) + } + } + } +} + /** * Loads and processes a .fls file related to a specified file path. * @@ -684,29 +722,29 @@ async function parseAuxFile(filePath: string, srcDir: string) { } } - /** - * Retrieves a list of included bibliography files for a given file, ensuring + * Retrieves a list of included bib files for a given file, ensuring * uniqueness. * * This function processes a specified file path to extract and return all - * associated bibliography files. It starts with the provided file path (or the + * associated bib files. It starts with the provided file path (or the * root file path if not specified) and checks its cache entry. If the cache - * entry exists, the function collects the bibliography files associated with + * entry exists, the function collects the bib files associated with * the file and its children. The function ensures that the same file is not * processed multiple times by keeping track of checked files. The result is an - * array of unique bibliography file paths. + * array of unique bib file paths. * + * @param {string} [bibType] - The type of .bib file to search for. * @param {string} [filePath] - The path to the file to check for included - * bibliography files. Defaults to the root file path if not provided. - * @param {string[]} [includedBib=[]] - An array to accumulate the bibliography + * bib files. Defaults to the root file path if not provided. + * @param {string[]} [includedBib=[]] - An array to accumulate the bib * files found. * @param {string[]} [checkedTeX=[]] - An array to store the paths of TeX files * already checked. - * @returns {string[]} - An array of unique bibliography file paths included in + * @returns {string[]} - An array of unique bib file paths included in * the specified file and its children. */ -function getIncludedBib(filePath?: string, includedBib: string[] = [], checkedTeX: string[] = []): string[] { +function getIncludedBibGeneric(bibType: 'bibtex' | 'glossary', filePath?: string, includedBib: string[] = [], checkedTeX: string[] = []): string[] { filePath = filePath ?? lw.root.file.path if (filePath === undefined) { return [] @@ -716,18 +754,48 @@ function getIncludedBib(filePath?: string, includedBib: string[] = [], checkedTe return [] } checkedTeX.push(filePath) - includedBib.push(...fileCache.bibfiles) + if (bibType === 'bibtex') { + includedBib.push(...fileCache.bibfiles) + } else if (bibType === 'glossary') { + includedBib.push(...fileCache.glossarybibfiles) + } for (const child of fileCache.children) { if (checkedTeX.includes(child.filePath)) { // Already parsed continue } - getIncludedBib(child.filePath, includedBib, checkedTeX) + getIncludedBibGeneric(bibType, child.filePath, includedBib, checkedTeX) } // Make sure to return an array with unique entries return Array.from(new Set(includedBib)) } +/** + * Retrieves a list of included bibliography files for a given file, ensuring + * uniqueness. + * + * @param {string} [filePath] - The path to the file to check for included + * bibliography files. + * @returns {string[]} - An array of unique bibliography file paths included in + * the specified file and its children. + */ +function getIncludedBib(filePath?: string): string[] { + return getIncludedBibGeneric('bibtex', filePath) +} + +/** + * Retrieves a list of included glossary bib files for a given file, ensuring + * uniqueness. + * + * @param {string} [filePath] - The path to the file to check for included + * bibliography files. + * @returns {string[]} - An array of unique glossary bib file paths included in + * the specified file and its children. + */ +function getIncludedGlossaryBib(filePath?: string): string[] { + return getIncludedBibGeneric('glossary', filePath) +} + /** * Retrieves a list of included TeX files, starting from a given file path. * diff --git a/src/outline/structure/bibtex.ts b/src/outline/structure/bibtex.ts index 294bf177d..dc6b52990 100644 --- a/src/outline/structure/bibtex.ts +++ b/src/outline/structure/bibtex.ts @@ -11,7 +11,7 @@ const logger = lw.log('Structure', 'BibTeX') * Convert a bibtexParser.FieldValue to a string * @param field the bibtexParser.FieldValue to parse */ -function fieldValueToString(field: bibtexParser.FieldValue, abbreviations: {[abbr: string]: string}): string { +export function fieldValueToString(field: bibtexParser.FieldValue, abbreviations: {[abbr: string]: string}): string { if (field.kind === 'concat') { return field.content.map(value => fieldValueToString(value, abbreviations)).reduce((acc, cur) => {return acc + ' # ' + cur}) } else if (field.kind === 'abbreviation') { diff --git a/src/types.ts b/src/types.ts index d26d33f1b..c2ea14273 100644 --- a/src/types.ts +++ b/src/types.ts @@ -37,6 +37,8 @@ export type FileCache = { }[], /** The array of the paths of `.bib` files referenced from the LaTeX file */ bibfiles: Set, + /** The array of the paths of `.bib` files listed by `\GlsXtrLoadResources` to provide glossary entries */ + glossarybibfiles: Set, /** A dictionary of external documents provided by `\externaldocument` of * `xr` package. The value is its prefix `\externaldocument[prefix]{*}` */ external: {[filePath: string]: string}, diff --git a/test/fixtures/armory/intellisense/glossary.bib b/test/fixtures/armory/intellisense/glossary.bib new file mode 100644 index 000000000..90320755d --- /dev/null +++ b/test/fixtures/armory/intellisense/glossary.bib @@ -0,0 +1,35 @@ +@symbol{fs, + name = {\ensuremath{f_s}}, + description = {sample rate} +} + +@symbol{theta, + name = {\ensuremath{\theta}}, + description = {horizontal angle} +} + +@entry{caesar, + name={\sortname{Gaius Julius}{Caesar}}, + first={\sortname{Julius}{Caesar}}, + text={Caesar}, + description={Roman politician and general}, + born={13~July 100 BC}, + died={15~March 44 BC}, + identifier={person} +} + +@entry{wellesley, + name={\sortname{Arthur}{Wellesley}}, + text={Wellington}, + description={Anglo-Irish soldier and statesman}, + born={1~May 1769 AD}, + died={14~September 1852 AD}, + othername={1st Duke of Wellington}, + identifier={person} +} + +@index{wellington, + name={Wellington}, + alias={wellesley}, + identifier={person} +} diff --git a/test/fixtures/armory/intellisense/glossary_bib.tex b/test/fixtures/armory/intellisense/glossary_bib.tex new file mode 100644 index 000000000..b33d56873 --- /dev/null +++ b/test/fixtures/armory/intellisense/glossary_bib.tex @@ -0,0 +1,9 @@ +\documentclass{article} +\usepackage{glossaries-extra} +\GlsXtrLoadResources[ + src={glos.bib}, +] + +\begin{document} +abc\gls{} +\end{document} diff --git a/test/fixtures/unittest/22_completion_glossary/glossary.bib b/test/fixtures/unittest/22_completion_glossary/glossary.bib new file mode 100644 index 000000000..90320755d --- /dev/null +++ b/test/fixtures/unittest/22_completion_glossary/glossary.bib @@ -0,0 +1,35 @@ +@symbol{fs, + name = {\ensuremath{f_s}}, + description = {sample rate} +} + +@symbol{theta, + name = {\ensuremath{\theta}}, + description = {horizontal angle} +} + +@entry{caesar, + name={\sortname{Gaius Julius}{Caesar}}, + first={\sortname{Julius}{Caesar}}, + text={Caesar}, + description={Roman politician and general}, + born={13~July 100 BC}, + died={15~March 44 BC}, + identifier={person} +} + +@entry{wellesley, + name={\sortname{Arthur}{Wellesley}}, + text={Wellington}, + description={Anglo-Irish soldier and statesman}, + born={1~May 1769 AD}, + died={14~September 1852 AD}, + othername={1st Duke of Wellington}, + identifier={person} +} + +@index{wellington, + name={Wellington}, + alias={wellesley}, + identifier={person} +} diff --git a/test/suites/04_intellisense.test.ts b/test/suites/04_intellisense.test.ts index 7de6815f4..1c7bfa961 100644 --- a/test/suites/04_intellisense.test.ts +++ b/test/suites/04_intellisense.test.ts @@ -420,6 +420,20 @@ suite.skip('Intellisense test suite', () => { assert.ok(suggestions.items.find(item => item.label === 'abbr_x' && item.detail === 'A first abbreviation')) }) + test.run('glossary intellisense from .bib files', async (fixture: string) => { + await test.load(fixture, [ + {src: 'intellisense/glossary_bib.tex', dst: 'main.tex'}, + {src: 'intellisense/glossary.bib', dst: 'glos.bib'} + ]) + const suggestions = test.suggest(7, 8) + assert.strictEqual(suggestions.items.length, 5) + assert.ok(suggestions.items.find(item => item.label === 'fs' && item.detail?.includes('\\ensuremath{f_s}'))) + assert.ok(suggestions.items.find(item => item.label === 'theta' && item.detail?.includes('\\ensuremath{\theta}'))) + assert.ok(suggestions.items.find(item => item.label === 'caesar' && item.detail?.includes('\\sortname{Gaius Julius}{Caesar}'))) + assert.ok(suggestions.items.find(item => item.label === 'wellesley' && item.detail?.includes('\\sortname{Arthur}{Wellesley}'))) + assert.ok(suggestions.items.find(item => item.label === 'wellington' && item.detail?.includes('Wellington'))) + }) + test.run('@-snippet intellisense and configs intellisense.atSuggestion*', async (fixture: string) => { const replaces = {'@+': '\\sum', '@8': '', '@M': '\\sum'} await vscode.workspace.getConfiguration('latex-workshop').update('intellisense.atSuggestion.user', replaces) diff --git a/test/units/22_completion_glossary.test.ts b/test/units/22_completion_glossary.test.ts index bfe6bf3cf..288d36d7d 100644 --- a/test/units/22_completion_glossary.test.ts +++ b/test/units/22_completion_glossary.test.ts @@ -3,7 +3,7 @@ import * as path from 'path' import * as sinon from 'sinon' import { lw } from '../../src/lw' import { assert, get, mock, set } from './utils' -import { provider } from '../../src/completion/completer/glossary' +import { glossary, provider } from '../../src/completion/completer/glossary' describe(path.basename(__filename).split('.')[0] + ':', () => { const fixture = path.basename(__filename).split('.')[0] @@ -24,16 +24,16 @@ describe(path.basename(__filename).split('.')[0] + ':', () => { sinon.restore() }) - describe('lw.completion->glossary', () => { - function getSuggestions() { - return provider.from(['', ''], { - uri: vscode.Uri.file(texPath), - langId: 'latex', - line: '', - position: new vscode.Position(0, 0), - }) - } + function getSuggestions() { + return provider.from(['', ''], { + uri: vscode.Uri.file(texPath), + langId: 'latex', + line: '', + position: new vscode.Position(0, 0), + }) + } + describe('lw.completion->glossary', () => { it('should parse and provide \\newacronym definition', async () => { readStub.resolves('\\newacronym{rf}{RF}{radio-frequency}') await lw.cache.refreshCache(texPath) @@ -107,4 +107,24 @@ describe(path.basename(__filename).split('.')[0] + ':', () => { assert.strictEqual(suggestions.find(s => s.label === 'rf')?.detail, 'radio-frequency') }) }) + + describe('lw.completion->glossary.parseBibFile', () => { + const bibFile = 'glossary.bib' + const bibPath = get.path(fixture, bibFile) + + it('should parse the bib file', async () => { + readStub.withArgs(texPath).resolves(`\\GlsXtrLoadResources[src={${bibFile}}]`) + await lw.cache.refreshCache(texPath) + sinon.restore() + + await glossary.parseBibFile(bibPath) + + const suggestions = getSuggestions() + assert.ok(suggestions.find(item => item.label === 'fs' && item.detail?.includes('\\ensuremath{f_s}'))) + assert.ok(suggestions.find(item => item.label === 'theta' && item.detail?.includes('\\ensuremath{\\theta}'))) + assert.ok(suggestions.find(item => item.label === 'caesar' && item.detail?.includes('\\sortname{Gaius Julius}{Caesar}'))) + assert.ok(suggestions.find(item => item.label === 'wellesley' && item.detail?.includes('\\sortname{Arthur}{Wellesley}'))) + assert.ok(suggestions.find(item => item.label === 'wellington' && item.detail?.includes('Wellington'))) + }) + }) })