first commit
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import { LRLanguage } from '@codemirror/language'
|
||||
import { parser } from '../../lezer-bibtex/bibtex.mjs'
|
||||
import { bibtexEntryCompletions } from './completions/snippets'
|
||||
|
||||
export const BibTeXLanguage = LRLanguage.define({
|
||||
name: 'bibtex',
|
||||
parser,
|
||||
languageData: {
|
||||
autocomplete: bibtexEntryCompletions,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { CompletionContext, snippet } from '@codemirror/autocomplete'
|
||||
|
||||
type Environment = {
|
||||
name: string
|
||||
requiredAttributes: string[]
|
||||
}
|
||||
|
||||
const environments: Environment[] = [
|
||||
{
|
||||
name: 'article',
|
||||
requiredAttributes: ['author', 'title', 'journal', 'year'],
|
||||
},
|
||||
{
|
||||
name: 'book',
|
||||
requiredAttributes: ['author', 'title', 'publisher', 'year'],
|
||||
},
|
||||
{
|
||||
name: 'booklet',
|
||||
requiredAttributes: ['key', 'title'],
|
||||
},
|
||||
{
|
||||
name: 'conference',
|
||||
requiredAttributes: ['author', 'booktitle', 'title', 'year'],
|
||||
},
|
||||
{
|
||||
name: 'inbook',
|
||||
requiredAttributes: ['author', 'title', 'publisher', 'year', 'chapter'],
|
||||
},
|
||||
{
|
||||
name: 'incollection',
|
||||
requiredAttributes: ['author', 'title', 'booktitle', 'publisher', 'year'],
|
||||
},
|
||||
{
|
||||
name: 'inproceedings',
|
||||
requiredAttributes: ['author', 'title', 'booktitle', 'year'],
|
||||
},
|
||||
{
|
||||
name: 'manual',
|
||||
requiredAttributes: ['key', 'title'],
|
||||
},
|
||||
{
|
||||
name: 'mastersthesis',
|
||||
requiredAttributes: ['author', 'title', 'school', 'year'],
|
||||
},
|
||||
{
|
||||
name: 'misc',
|
||||
requiredAttributes: ['key', 'note'],
|
||||
},
|
||||
{
|
||||
name: 'phdthesis',
|
||||
requiredAttributes: ['author', 'title', 'school', 'year'],
|
||||
},
|
||||
{
|
||||
name: 'proceedings',
|
||||
requiredAttributes: ['key', 'title', 'year'],
|
||||
},
|
||||
{
|
||||
name: 'techreport',
|
||||
requiredAttributes: ['author', 'title', 'institution', 'year'],
|
||||
},
|
||||
{
|
||||
name: 'unpublished',
|
||||
requiredAttributes: ['author', 'title', 'note'],
|
||||
},
|
||||
]
|
||||
|
||||
const prepareSnippet = (environment: Environment) => {
|
||||
return `@${
|
||||
environment.name
|
||||
}{#{citation-key},${environment.requiredAttributes.map(
|
||||
attribute => `
|
||||
${attribute} = #{}`
|
||||
)}
|
||||
}`
|
||||
}
|
||||
|
||||
export function bibtexEntryCompletions(context: CompletionContext) {
|
||||
const word = context.matchBefore(/@\w*/)
|
||||
if (word?.from === word?.to && !context.explicit) return null
|
||||
return {
|
||||
from: word?.from ?? context.pos,
|
||||
options: [
|
||||
...environments.map(env => ({
|
||||
label: `@${env.name}`,
|
||||
type: 'snippet',
|
||||
apply: snippet(prepareSnippet(env)),
|
||||
})),
|
||||
{
|
||||
label: '@string',
|
||||
type: 'snippet',
|
||||
apply: snippet('@string{#{string-key} = #{}}'),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { LanguageSupport } from '@codemirror/language'
|
||||
import { BibTeXLanguage } from './bibtex-language'
|
||||
import { bibtexLinter } from './linting'
|
||||
|
||||
export const bibtex = () => {
|
||||
return new LanguageSupport(BibTeXLanguage, [bibtexLinter()])
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import { Diagnostic, LintSource } from '@codemirror/lint'
|
||||
import {
|
||||
Declaration,
|
||||
EntryName,
|
||||
EntryTypeName,
|
||||
FieldName,
|
||||
Other,
|
||||
} from '../../lezer-bibtex/bibtex.terms.mjs'
|
||||
import { SyntaxNodeRef } from '@lezer/common'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { createLinter } from '../../extensions/linting'
|
||||
|
||||
type BibEntryValidationRule = {
|
||||
requiredAttributes: (string | string[])[]
|
||||
biblatex?: Record<string, string>
|
||||
}
|
||||
|
||||
export const bibtexLinter = () => createLinter(bibtexLintSource, { delay: 100 })
|
||||
|
||||
export const bibtexLintSource: LintSource = view => {
|
||||
const tree = syntaxTree(view.state)
|
||||
|
||||
const diagnostics: Diagnostic[] = []
|
||||
|
||||
// Linting be temporarily disabled by a %%begin novalidate directive. It can
|
||||
// be re-enabled by a %%end novalidate directive
|
||||
let lintingCurrentlyDisabled = false
|
||||
|
||||
// Linting is completely disabled by a %%novalidate so will return no linter
|
||||
// errors
|
||||
let fileLintingDisabled = false
|
||||
|
||||
tree.iterate({
|
||||
enter(node) {
|
||||
if (fileLintingDisabled) {
|
||||
return false
|
||||
}
|
||||
if (node.type.is(Other)) {
|
||||
// Content between declaration. Can be linter directive
|
||||
const content = view.state.sliceDoc(node.from, node.to).trim()
|
||||
if (content === '%%novalidate') {
|
||||
fileLintingDisabled = true
|
||||
} else if (content === '%%begin novalidate') {
|
||||
lintingCurrentlyDisabled = true
|
||||
} else if (content === '%%end novalidate') {
|
||||
lintingCurrentlyDisabled = false
|
||||
}
|
||||
}
|
||||
if (lintingCurrentlyDisabled) {
|
||||
return false
|
||||
}
|
||||
if (node.type.is(Declaration)) {
|
||||
diagnostics.push(...checkRequiredFields(node, view.state))
|
||||
return false
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
if (fileLintingDisabled) {
|
||||
return []
|
||||
} else {
|
||||
return diagnostics
|
||||
}
|
||||
}
|
||||
|
||||
const bibEntryValidationRules = new Map<string, BibEntryValidationRule>([
|
||||
[
|
||||
'article',
|
||||
{
|
||||
requiredAttributes: ['author', 'title', 'journal', 'year'],
|
||||
biblatex: {
|
||||
journal: 'journaltitle',
|
||||
year: 'date',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'book',
|
||||
{
|
||||
requiredAttributes: [['author', 'editor'], 'title', 'publisher', 'year'],
|
||||
biblatex: {
|
||||
year: 'date',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'booklet',
|
||||
{
|
||||
requiredAttributes: [['author', 'key'], 'title'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'conference',
|
||||
{
|
||||
requiredAttributes: ['author', 'title', 'year', 'booktitle'],
|
||||
biblatex: {
|
||||
year: 'date',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'inbook',
|
||||
{
|
||||
requiredAttributes: ['author', 'title', 'publisher', 'year'],
|
||||
biblatex: {
|
||||
year: 'date',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'incollection',
|
||||
{
|
||||
requiredAttributes: ['author', 'title', 'booktitle', 'publisher', 'year'],
|
||||
biblatex: {
|
||||
year: 'date',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'inproceedings',
|
||||
{
|
||||
requiredAttributes: ['author', 'title', 'booktitle', 'year'],
|
||||
biblatex: {
|
||||
year: 'date',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'manual',
|
||||
{
|
||||
requiredAttributes: [['author', 'key', 'organization'], 'title'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'mastersthesis',
|
||||
{
|
||||
requiredAttributes: ['author', 'title', 'school', 'year'],
|
||||
biblatex: {
|
||||
year: 'date',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'misc',
|
||||
{
|
||||
requiredAttributes: [['author', 'key']],
|
||||
},
|
||||
],
|
||||
[
|
||||
'phdthesis',
|
||||
{
|
||||
requiredAttributes: ['author', 'title', 'school', 'year'],
|
||||
biblatex: {
|
||||
year: 'date',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'proceedings',
|
||||
{
|
||||
requiredAttributes: [['editor', 'key', 'organization'], 'title', 'year'],
|
||||
biblatex: {
|
||||
year: 'date',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'techreport',
|
||||
{
|
||||
requiredAttributes: ['author', 'title', 'institution', 'year'],
|
||||
biblatex: {
|
||||
year: 'date',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'unpublished',
|
||||
{
|
||||
requiredAttributes: ['author', 'title', 'note'],
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const checkRequiredFields = (
|
||||
nodeRef: SyntaxNodeRef,
|
||||
state: EditorState
|
||||
): Diagnostic[] => {
|
||||
// We just return no errors if we don't find the info we're looking for in the
|
||||
// syntax tree
|
||||
const node = nodeRef.node
|
||||
|
||||
const entryNameNode = node.getChild(EntryName)
|
||||
if (!entryNameNode) {
|
||||
return []
|
||||
}
|
||||
|
||||
const entryTypeNameNode = entryNameNode.getChild(EntryTypeName)
|
||||
if (!entryTypeNameNode) {
|
||||
return []
|
||||
}
|
||||
const entryTypeName = state
|
||||
.sliceDoc(entryTypeNameNode.from, entryTypeNameNode.to)
|
||||
.toLowerCase()
|
||||
const environment = bibEntryValidationRules.get(entryTypeName)
|
||||
if (!environment) {
|
||||
return []
|
||||
}
|
||||
const requiredFields = environment.requiredAttributes
|
||||
|
||||
const actualFieldNodes = node.getChildren('Field')
|
||||
const actualFieldNames = new Set(
|
||||
actualFieldNodes
|
||||
.map(fieldNode => fieldNode.getChild(FieldName))
|
||||
.map(fieldNode =>
|
||||
fieldNode ? state.sliceDoc(fieldNode.from, fieldNode.to) : undefined
|
||||
)
|
||||
.filter(Boolean)
|
||||
.map(name => name?.toLowerCase())
|
||||
)
|
||||
|
||||
if (actualFieldNames.has('crossref')) {
|
||||
// We don't want to deal with crossrefs (key inheritance from other entries)
|
||||
return []
|
||||
}
|
||||
|
||||
const entryHasField = (fieldName: string): boolean => {
|
||||
if (actualFieldNames.has(fieldName)) {
|
||||
return true
|
||||
}
|
||||
if (environment.biblatex && environment.biblatex[fieldName]) {
|
||||
return actualFieldNames.has(environment.biblatex[fieldName])
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const missingFields = requiredFields.filter(field => {
|
||||
if (Array.isArray(field)) {
|
||||
return !field.some(f => entryHasField(f))
|
||||
} else {
|
||||
return !entryHasField(field)
|
||||
}
|
||||
})
|
||||
|
||||
if (missingFields.length === 0) {
|
||||
// All is good
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
from: entryNameNode.from,
|
||||
to: entryNameNode.to,
|
||||
message: createErrorMessage(missingFields, entryTypeName, state),
|
||||
severity: 'warning',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function createErrorMessage(
|
||||
missingFields: (string[] | string)[],
|
||||
entryTypeName: string,
|
||||
state: EditorState
|
||||
) {
|
||||
const translation =
|
||||
missingFields.length === 1
|
||||
? state.phrase('missing_field_for_entry')
|
||||
: state.phrase('missing_fields_for_entry')
|
||||
const or = state.phrase('or')
|
||||
const errorLines = missingFields
|
||||
.map(fieldOptions => {
|
||||
const options = Array.isArray(fieldOptions)
|
||||
? fieldOptions
|
||||
: [fieldOptions]
|
||||
return createOrList(options, or)
|
||||
})
|
||||
.map(field => ` • ${field}`)
|
||||
.join('\n')
|
||||
return `${translation} ${entryTypeName}:\n${errorLines}`
|
||||
}
|
||||
|
||||
function createOrList(fields: string[], orPhrase: string) {
|
||||
if (fields.length === 0) {
|
||||
return ''
|
||||
}
|
||||
if (fields.length === 1) {
|
||||
return fields[0]
|
||||
}
|
||||
return (
|
||||
fields.slice(0, -1).join(', ') + ` ${orPhrase} ` + fields[fields.length - 1]
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user