linting.ts 6.6 KB

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