linting.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. import { syntaxTree } from '@codemirror/language'
  2. import { Diagnostic, LintSource } from '@codemirror/lint'
  3. import {
  4. Entry,
  5. EntryCommand,
  6. EntryBody,
  7. EntryType,
  8. FieldName,
  9. Comment,
  10. } from '../../lezer-bibtex/bibtex.terms.mjs'
  11. import { SyntaxNodeRef } from '@lezer/common'
  12. import { EditorState } from '@codemirror/state'
  13. import { createLinter } from '../../extensions/linting'
  14. type BibEntryValidationRule = {
  15. requiredAttributes: (string | string[])[]
  16. biblatex?: Record<string, string>
  17. }
  18. export const bibtexLinter = () => createLinter(bibtexLintSource, { delay: 100 })
  19. export const bibtexLintSource: LintSource = view => {
  20. const tree = syntaxTree(view.state)
  21. const diagnostics: Diagnostic[] = []
  22. // Linting be temporarily disabled by a %%begin novalidate directive. It can
  23. // be re-enabled by a %%end novalidate directive
  24. let lintingCurrentlyDisabled = false
  25. // Linting is completely disabled by a %%novalidate so will return no linter
  26. // errors
  27. let fileLintingDisabled = false
  28. tree.iterate({
  29. enter(node) {
  30. if (fileLintingDisabled) {
  31. return false
  32. }
  33. if (node.type.is(Comment)) {
  34. // Content between declaration. Can be linter directive
  35. const content = view.state.sliceDoc(node.from, node.to).trim()
  36. if (content === '%%novalidate') {
  37. fileLintingDisabled = true
  38. } else if (content === '%%begin novalidate') {
  39. lintingCurrentlyDisabled = true
  40. } else if (content === '%%end novalidate') {
  41. lintingCurrentlyDisabled = false
  42. }
  43. }
  44. if (lintingCurrentlyDisabled) {
  45. return false
  46. }
  47. if (node.type.is(Entry)) {
  48. diagnostics.push(...checkRequiredFields(node, view.state))
  49. return false
  50. }
  51. },
  52. })
  53. if (fileLintingDisabled) {
  54. return []
  55. } else {
  56. return diagnostics
  57. }
  58. }
  59. const bibEntryValidationRules = new Map<string, BibEntryValidationRule>([
  60. [
  61. 'article',
  62. {
  63. requiredAttributes: ['author', 'title', 'journal', 'year'],
  64. biblatex: {
  65. journal: 'journaltitle',
  66. year: 'date',
  67. },
  68. },
  69. ],
  70. [
  71. 'book',
  72. {
  73. requiredAttributes: [['author', 'editor'], 'title', 'publisher', 'year'],
  74. biblatex: {
  75. year: 'date',
  76. },
  77. },
  78. ],
  79. [
  80. 'booklet',
  81. {
  82. requiredAttributes: [['author', 'key'], 'title'],
  83. },
  84. ],
  85. [
  86. 'conference',
  87. {
  88. requiredAttributes: ['author', 'title', 'year', 'booktitle'],
  89. biblatex: {
  90. year: 'date',
  91. },
  92. },
  93. ],
  94. [
  95. 'inbook',
  96. {
  97. requiredAttributes: ['author', 'title', 'publisher', 'year'],
  98. biblatex: {
  99. year: 'date',
  100. },
  101. },
  102. ],
  103. [
  104. 'incollection',
  105. {
  106. requiredAttributes: ['author', 'title', 'booktitle', 'publisher', 'year'],
  107. biblatex: {
  108. year: 'date',
  109. },
  110. },
  111. ],
  112. [
  113. 'inproceedings',
  114. {
  115. requiredAttributes: ['author', 'title', 'booktitle', 'year'],
  116. biblatex: {
  117. year: 'date',
  118. },
  119. },
  120. ],
  121. [
  122. 'manual',
  123. {
  124. requiredAttributes: [['author', 'key', 'organization'], 'title'],
  125. },
  126. ],
  127. [
  128. 'mastersthesis',
  129. {
  130. requiredAttributes: ['author', 'title', 'school', 'year'],
  131. biblatex: {
  132. year: 'date',
  133. },
  134. },
  135. ],
  136. [
  137. 'misc',
  138. {
  139. requiredAttributes: [['author', 'key']],
  140. },
  141. ],
  142. [
  143. 'phdthesis',
  144. {
  145. requiredAttributes: ['author', 'title', 'school', 'year'],
  146. biblatex: {
  147. year: 'date',
  148. },
  149. },
  150. ],
  151. [
  152. 'proceedings',
  153. {
  154. requiredAttributes: [['editor', 'key', 'organization'], 'title', 'year'],
  155. biblatex: {
  156. year: 'date',
  157. },
  158. },
  159. ],
  160. [
  161. 'techreport',
  162. {
  163. requiredAttributes: ['author', 'title', 'institution', 'year'],
  164. biblatex: {
  165. year: 'date',
  166. },
  167. },
  168. ],
  169. [
  170. 'unpublished',
  171. {
  172. requiredAttributes: ['author', 'title', 'note'],
  173. },
  174. ],
  175. ])
  176. export const checkRequiredFields = (
  177. nodeRef: SyntaxNodeRef,
  178. state: EditorState
  179. ): Diagnostic[] => {
  180. // We just return no errors if we don't find the info we're looking for in the
  181. // syntax tree
  182. const node = nodeRef.node
  183. const entryCommandNode = node.getChild(EntryCommand)
  184. if (!entryCommandNode) {
  185. return []
  186. }
  187. const entryTypeNode = entryCommandNode.getChild(EntryType)
  188. if (!entryTypeNode) {
  189. return []
  190. }
  191. const entryType = state
  192. .sliceDoc(entryTypeNode.from, entryTypeNode.to)
  193. .toLowerCase()
  194. const environment = bibEntryValidationRules.get(entryType)
  195. if (!environment) {
  196. return []
  197. }
  198. const requiredFields = environment.requiredAttributes
  199. const entryBodyNode = node.getChild(EntryBody)
  200. if (!entryBodyNode) {
  201. return []
  202. }
  203. const actualFieldNodes = entryBodyNode.getChildren('Field')
  204. const actualFieldNames = new Set(
  205. actualFieldNodes
  206. .map(fieldNode => fieldNode.getChild(FieldName))
  207. .map(fieldNode =>
  208. fieldNode ? state.sliceDoc(fieldNode.from, fieldNode.to) : undefined
  209. )
  210. .filter(Boolean)
  211. .map(name => name?.toLowerCase())
  212. )
  213. if (actualFieldNames.has('crossref')) {
  214. // We don't want to deal with crossrefs (key inheritance from other entries)
  215. return []
  216. }
  217. const entryHasField = (fieldName: string): boolean => {
  218. if (actualFieldNames.has(fieldName)) {
  219. return true
  220. }
  221. if (environment.biblatex && environment.biblatex[fieldName]) {
  222. return actualFieldNames.has(environment.biblatex[fieldName])
  223. }
  224. return false
  225. }
  226. const missingFields = requiredFields.filter(field => {
  227. if (Array.isArray(field)) {
  228. return !field.some(f => entryHasField(f))
  229. } else {
  230. return !entryHasField(field)
  231. }
  232. })
  233. if (missingFields.length === 0) {
  234. // All is good
  235. return []
  236. }
  237. return [
  238. {
  239. from: entryCommandNode.from,
  240. to: entryCommandNode.to,
  241. message: createErrorMessage(missingFields, entryType, state),
  242. severity: 'warning',
  243. },
  244. ]
  245. }
  246. function createErrorMessage(
  247. missingFields: (string[] | string)[],
  248. entryTypeName: string,
  249. state: EditorState
  250. ) {
  251. const translation =
  252. missingFields.length === 1
  253. ? state.phrase('missing_field_for_entry')
  254. : state.phrase('missing_fields_for_entry')
  255. const or = state.phrase('or')
  256. const errorLines = missingFields
  257. .map(fieldOptions => {
  258. const options = Array.isArray(fieldOptions)
  259. ? fieldOptions
  260. : [fieldOptions]
  261. return createOrList(options, or)
  262. })
  263. .map(field => ` • ${field}`)
  264. .join('\n')
  265. return `${translation} ${entryTypeName}:\n${errorLines}`
  266. }
  267. function createOrList(fields: string[], orPhrase: string) {
  268. if (fields.length === 0) {
  269. return ''
  270. }
  271. if (fields.length === 1) {
  272. return fields[0]
  273. }
  274. return (
  275. fields.slice(0, -1).join(', ') + ` ${orPhrase} ` + fields[fields.length - 1]
  276. )
  277. }