| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297 |
- import { syntaxTree } from '@codemirror/language'
- import { Diagnostic, LintSource } from '@codemirror/lint'
- import {
- Entry,
- EntryCommand,
- EntryBody,
- EntryType,
- FieldName,
- Comment,
- } 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(Comment)) {
- // 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(Entry)) {
- 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'],
- },
- ],
- ])
- export 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 entryCommandNode = node.getChild(EntryCommand)
- if (!entryCommandNode) {
- return []
- }
- const entryTypeNode = entryCommandNode.getChild(EntryType)
- if (!entryTypeNode) {
- return []
- }
- const entryType = state
- .sliceDoc(entryTypeNode.from, entryTypeNode.to)
- .toLowerCase()
- const environment = bibEntryValidationRules.get(entryType)
- if (!environment) {
- return []
- }
- const requiredFields = environment.requiredAttributes
- const entryBodyNode = node.getChild(EntryBody)
- if (!entryBodyNode) {
- return []
- }
- const actualFieldNodes = entryBodyNode.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: entryCommandNode.from,
- to: entryCommandNode.to,
- message: createErrorMessage(missingFields, entryType, 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]
- )
- }
|