-
Notifications
You must be signed in to change notification settings - Fork 539
/
Copy pathcitation.ts
339 lines (314 loc) · 12.8 KB
/
citation.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
import * as vscode from 'vscode'
import { bibtexParser } from 'latex-utensils'
import { lw } from '../../lw'
import type { CitationField, CitationItem, CompletionArgs, CompletionItem, CompletionProvider } from '../../types'
import type { FileCache } from '../../types'
import { trimMultiLineString } from '../../utils/utils'
import { computeFilteringRange } from './completerutils'
const logger = lw.log('Intelli', 'Citation')
export const provider: CompletionProvider = { from }
export const citation = {
parse,
browser,
getItem,
parseBibFile
}
const data = {
bibEntries: new Map<string, CitationItem[]>()
}
lw.watcher.bib.onCreate(uri => parseBibFile(uri.fsPath))
lw.watcher.bib.onChange(uri => parseBibFile(uri.fsPath))
lw.watcher.bib.onDelete(uri => removeEntriesInFile(uri.fsPath))
/**
* Read the value `intellisense.citation.format`
* @param configuration workspace configuration
* @param excludedField A field to exclude from the list of citation fields. Primary usage is to not include `citation.label` twice.
*/
function readCitationFormat(configuration: vscode.WorkspaceConfiguration, excludedField?: string): string[] {
const fields = (configuration.get('intellisense.citation.format') as string[]).map(f => { return f.toLowerCase() })
if (excludedField) {
return fields.filter(f => f !== excludedField.toLowerCase())
}
return fields
}
export const bibTools = {
expandField,
deParenthesis,
parseAbbrevations
}
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(' ')
}
if (bibtexParser.isAbbreviationValue(value)) {
if (value.content in abbreviations) {
return abbreviations[value.content]
}
return ''
}
return value.content
}
function deParenthesis(str: string): string {
// Remove wrapping { }
// Extract the content of \url{}
return str.replace(/\\url{([^\\{}]+)}/g, '$1').replace(/{+([^\\{}]+)}+/g, '$1')
}
function parseAbbrevations(ast: bibtexParser.BibtexAst) {
const abbreviations: {[key: string]: string} = {}
ast.content.filter(bibtexParser.isStringEntry).forEach((entry: bibtexParser.StringEntry) => {
// @string{string1 = "Proceedings of the "}
// @string{string2 = string1 # "Foo"}
if (typeof entry.value.content === 'string') {
abbreviations[entry.abbreviation] = entry.value.content
} else {
abbreviations[entry.abbreviation] =
(entry.value.content as (bibtexParser.AbbreviationValue | bibtexParser.TextStringValue)[]).map(subEntry => {
if (bibtexParser.isAbbreviationValue(subEntry)) {
return abbreviations[subEntry.content] ?? `undefined @string "${subEntry.content}"`
} else {
return subEntry.content
}
}).join('')
}
})
return abbreviations
}
function from(_result: RegExpMatchArray, args: CompletionArgs) {
return provide(args.uri, args.line, args.position)
}
function provide(uri: vscode.Uri, line: string, position: vscode.Position): CompletionItem[] {
// Compile the suggestion array to vscode completion array
const configuration = vscode.workspace.getConfiguration('latex-workshop', uri)
const label = configuration.get('intellisense.citation.label') as string
const fields = readCitationFormat(configuration)
const range: vscode.Range | undefined = computeFilteringRange(line, position)
const items = updateAll(lw.cache.getIncludedBib(lw.root.file.path))
const alts: CitationItem[] = []
items.forEach(item => {
if (item.fields.has('ids')) {
const ids = item.fields.get('ids')?.split(',').map(id => id.trim())
if (ids === undefined || ids.length === 0) {
return
}
for (const id of ids) {
const alt = Object.assign({}, item)
alt.key = id
alts.push(alt)
}
}
})
return [...items, ...alts].map(item => {
// Compile the completion item label
switch(label) {
case 'bibtex key':
default:
item.label = item.key
break
case 'title':
if (item.fields.title) {
item.label = item.fields.title
}
break
case 'authors':
if (item.fields.author) {
item.label = item.fields.author
}
break
}
item.filterText = item.key + ' ' + item.fields.title + ' ' + item.fields.join(fields.filter(field => field !== 'title'), false)
item.insertText = item.key
item.range = range
// We need two spaces to ensure md newline
item.documentation = new vscode.MarkdownString( '\n' + item.fields.join(fields, true, ' \n') + '\n\n')
return item
})
}
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(lw.cache.getIncludedBib(lw.root.file.path)).map(item => {
return {
label: item.fields.title ? trimMultiLineString(item.fields.title) : '',
description: item.key,
detail: item.fields.join(fields, true, ', ')
}
}), {
placeHolder: 'Press ENTER to insert citation key at cursor',
matchOnDetail: true,
matchOnDescription: true,
ignoreFocusOut: true
}).then(selected => {
if (!selected) {
return
}
if (vscode.window.activeTextEditor) {
const editor = vscode.window.activeTextEditor
const content = editor.document.getText(new vscode.Range(new vscode.Position(0, 0), editor.selection.start))
let start = editor.selection.start
if (content.lastIndexOf('\\cite') > content.lastIndexOf('}')) {
const curlyStart = content.lastIndexOf('{') + 1
const commaStart = content.lastIndexOf(',') + 1
start = editor.document.positionAt(curlyStart > commaStart ? curlyStart : commaStart)
}
void editor.edit(edit => edit.replace(new vscode.Range(start, editor.selection.end), selected.description || ''))
.then(() => editor.selection = new vscode.Selection(editor.selection.end, editor.selection.end))
}
})
}
function getRawItem(key: string): CitationItem | undefined {
const suggestions = updateAll()
const entry = suggestions.find((elm) => elm.key === key)
return entry
}
function getItem(key: string, configurationScope?: vscode.ConfigurationScope): CitationItem | undefined {
const entry = getRawItem(key)
if (entry && !(entry.detail || entry.documentation)) {
const configuration = vscode.workspace.getConfiguration('latex-workshop', configurationScope)
const fields = readCitationFormat(configuration)
// We need two spaces to ensure md newline
entry.documentation = new vscode.MarkdownString( '\n' + entry.fields.join(fields, true, ' \n') + '\n\n')
}
return entry
}
/**
* Returns aggregated bib entries from `.bib` files and bibitems 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[]): CitationItem[] {
let suggestions: CitationItem[] = []
// From bib files
if (bibFiles === undefined) {
bibFiles = Array.from(data.bibEntries.keys())
}
bibFiles.forEach(file => {
const entry = data.bibEntries.get(file)
if (entry) {
suggestions = suggestions.concat(entry)
}
})
// From caches
lw.cache.getIncludedTeX().forEach(cachedFile => {
const cachedBibs = lw.cache.get(cachedFile)?.elements.bibitem
if (cachedBibs === undefined) {
return
}
suggestions = suggestions.concat(cachedBibs)
})
return suggestions
}
/**
* Parses `.bib` file. The results are stored in this instance.
*
* @param fileName The path of `.bib` file.
*/
async function parseBibFile(fileName: string) {
logger.log(`Parsing .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: CitationItem[] = []
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 = parseAbbrevations(ast)
ast.content
.filter(bibtexParser.isEntry)
.forEach((entry: bibtexParser.Entry) => {
if (entry.internalKey === undefined) {
return
}
const item: CitationItem = {
key: entry.internalKey,
label: entry.internalKey,
file: fileName,
position: new vscode.Position(entry.location.start.line - 1, entry.location.start.column - 1),
kind: vscode.CompletionItemKind.Reference,
fields: new Fields()
}
entry.content.forEach(field => {
const value = deParenthesis(expandField(abbreviations, field.value))
item.fields.set(field.name, value)
})
newEntry.push(item)
})
data.bibEntries.set(fileName, newEntry)
logger.log(`Parsed ${newEntry.length} 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)
}
/**
* Updates the Manager cache for bibitems with Cache.
* Cache `content` is parsed with regular expressions,
* and the result is used to update the cache bibitem element.
*/
function parse(cache: FileCache) {
cache.elements.bibitem = parseContent(cache.filePath, cache.content)
}
function parseContent(file: string, content: string): CitationItem[] {
const itemReg = /^(?!%).*\\bibitem(?:\[[^[\]{}]*\])?{([^}]*)}/gm
const items: CitationItem[] = []
while (true) {
const result = itemReg.exec(content)
if (result === null) {
break
}
const postContent = content.substring(result.index + result[0].length, content.indexOf('\n', result.index)).trim()
const positionContent = content.substring(0, result.index).split('\n')
items.push({
key: result[1],
label: result[1],
file,
kind: vscode.CompletionItemKind.Reference,
detail: `${postContent}\n...`,
fields: new Fields(),
position: new vscode.Position(positionContent.length - 1, positionContent[positionContent.length - 1].length)
})
}
return items
}
class Fields extends Map<string, string> implements CitationField {
get author() { return this.get('author') }
get journal() { return this.get('journal') }
get journaltitle() { return this.get('journaltitle') }
get title() { return this.get('title') }
get publisher() { return this.get('publisher') }
/**
* Concatenate the values of the fields listed in `selectedFields`
* @param selectedFields an array of field names
* @param prefixWithKeys if true, every field is prefixed by 'Fieldname: '
* @param joinString the string to use for joining the fields
* @returns a string
*/
join(selectedFields: string[], prefixWithKeys: boolean, joinString: string = ' '): string {
const s: string[] = []
for (const key of this.keys()) {
if (selectedFields.includes(key)) {
const value = this.get(key) as string
if (prefixWithKeys) {
s.push(key.charAt(0).toUpperCase() + key.slice(1) + ': ' + value)
} else {
s.push(value)
}
}
}
return s.join(joinString)
}
}