latex-log-parser.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. // Define some constants
  2. const LOG_WRAP_LIMIT = 79
  3. const LATEX_WARNING_REGEX = /^LaTeX(?:3| Font)? Warning: (.*)$/
  4. const HBOX_WARNING_REGEX = /^(Over|Under)full \\(v|h)box/
  5. const PACKAGE_WARNING_REGEX = /^((?:Package|Class|Module) \b.+\b Warning:.*)$/
  6. // This is used to parse the line number from common latex warnings
  7. const LINES_REGEX = /lines? ([0-9]+)/
  8. // This is used to parse the package name from the package warnings
  9. const PACKAGE_REGEX = /^(?:Package|Class|Module) (\b.+\b) Warning/
  10. const FILE_LINE_ERROR_REGEX = /^([./].*):(\d+): (.*)/
  11. const STATE = {
  12. NORMAL: 0,
  13. ERROR: 1,
  14. }
  15. export type LatexParserOptions = {
  16. fileBaseNames?: RegExp[]
  17. ignoreDuplicates?: boolean
  18. }
  19. export type LatexLogEntry = {
  20. line: string | number | null
  21. file: string | undefined
  22. level: 'error' | 'warning' | 'typesetting'
  23. message: string
  24. content?: string
  25. raw: string
  26. ruleId?: string
  27. contentDetails?: string[]
  28. command?: string
  29. suppressed?: boolean
  30. }
  31. type File = {
  32. path: string
  33. files: File[]
  34. }
  35. export type ParseResult = {
  36. all: LatexLogEntry[]
  37. errors: LatexLogEntry[]
  38. warnings: LatexLogEntry[]
  39. typesetting: LatexLogEntry[]
  40. files: File[]
  41. }
  42. export default class LatexParser {
  43. state: number
  44. fileBaseNames: RegExp[]
  45. ignoreDuplicates?: boolean
  46. data: LatexLogEntry[]
  47. fileStack: File[]
  48. rootFileList: File[]
  49. currentFileList: File[]
  50. openParens: number
  51. latexWarningRegex: RegExp
  52. packageWarningRegex: RegExp
  53. packageRegex: RegExp
  54. log: LogText
  55. currentLine: string
  56. currentFilePath: string | undefined
  57. currentError: LatexLogEntry | undefined
  58. constructor(text: string, options: LatexParserOptions = {}) {
  59. this.state = STATE.NORMAL
  60. this.fileBaseNames = options.fileBaseNames || [/compiles/, /\/usr\/local/]
  61. this.ignoreDuplicates = options.ignoreDuplicates
  62. this.data = []
  63. this.fileStack = []
  64. this.currentFileList = this.rootFileList = []
  65. this.openParens = 0
  66. this.latexWarningRegex = LATEX_WARNING_REGEX
  67. this.packageWarningRegex = PACKAGE_WARNING_REGEX
  68. this.packageRegex = PACKAGE_REGEX
  69. this.currentLine = ''
  70. this.log = new LogText(text)
  71. }
  72. parse(): ParseResult {
  73. let nextLine: string | false
  74. while ((nextLine = this.log.nextLine()) !== false) {
  75. this.currentLine = nextLine
  76. if (this.state === STATE.NORMAL) {
  77. if (this.currentLineIsError()) {
  78. this.state = STATE.ERROR
  79. this.currentError = {
  80. line: null,
  81. file: this.currentFilePath,
  82. level: 'error',
  83. message: this.currentLine.slice(2),
  84. content: '',
  85. raw: this.currentLine + '\n',
  86. }
  87. } else if (this.currentLineIsFileLineError()) {
  88. this.state = STATE.ERROR
  89. this.parseFileLineError()
  90. } else if (this.currentLineIsRunawayArgument()) {
  91. this.parseRunawayArgumentError()
  92. } else if (this.currentLineIsWarning()) {
  93. this.parseSingleWarningLine(this.latexWarningRegex)
  94. } else if (this.currentLineIsHboxWarning()) {
  95. this.parseHboxLine()
  96. } else if (this.currentLineIsPackageWarning()) {
  97. this.parseMultipleWarningLine()
  98. } else {
  99. this.parseParensForFilenames()
  100. }
  101. }
  102. if (this.state === STATE.ERROR) {
  103. if (!this.currentError) {
  104. throw new Error('LatexParser Error: currentError is undefined')
  105. }
  106. this.currentError.content += this.log
  107. .linesUpToNextMatchingLine(/^l\.[0-9]+/)
  108. .join('\n')
  109. this.currentError.content += '\n'
  110. this.currentError.content += this.log
  111. .linesUpToNextWhitespaceLine(true)
  112. .join('\n')
  113. this.currentError.content += '\n'
  114. this.currentError.content += this.log
  115. .linesUpToNextWhitespaceLine(true)
  116. .join('\n')
  117. this.currentError.raw += this.currentError.content
  118. const lineNo = this.currentError.raw.match(/l\.([0-9]+)/)
  119. if (lineNo && this.currentError.line === null) {
  120. this.currentError.line = parseInt(lineNo[1], 10)
  121. }
  122. this.data.push(this.currentError)
  123. this.state = STATE.NORMAL
  124. }
  125. }
  126. return this.postProcess(this.data)
  127. }
  128. currentLineIsError() {
  129. return (
  130. this.currentLine[0] === '!' &&
  131. this.currentLine !==
  132. '! ==> Fatal error occurred, no output PDF file produced!'
  133. )
  134. }
  135. currentLineIsFileLineError() {
  136. return FILE_LINE_ERROR_REGEX.test(this.currentLine)
  137. }
  138. currentLineIsRunawayArgument() {
  139. return this.currentLine.match(/^Runaway argument/)
  140. }
  141. currentLineIsWarning() {
  142. return !!this.currentLine.match(this.latexWarningRegex)
  143. }
  144. currentLineIsPackageWarning() {
  145. return !!this.currentLine.match(this.packageWarningRegex)
  146. }
  147. currentLineIsHboxWarning() {
  148. return !!this.currentLine.match(HBOX_WARNING_REGEX)
  149. }
  150. parseFileLineError() {
  151. const result = this.currentLine.match(FILE_LINE_ERROR_REGEX)
  152. if (!result) {
  153. throw new Error('LatexParser Error: Unable to extract error from line.')
  154. }
  155. this.currentError = {
  156. line: result[2],
  157. file: result[1],
  158. level: 'error',
  159. message: result[3],
  160. content: '',
  161. raw: this.currentLine + '\n',
  162. }
  163. }
  164. parseRunawayArgumentError() {
  165. this.currentError = {
  166. line: null,
  167. file: this.currentFilePath,
  168. level: 'error',
  169. message: this.currentLine,
  170. content: '',
  171. raw: this.currentLine + '\n',
  172. }
  173. this.currentError.content += this.log
  174. .linesUpToNextWhitespaceLine()
  175. .join('\n')
  176. this.currentError.content += '\n'
  177. this.currentError.content += this.log
  178. .linesUpToNextWhitespaceLine()
  179. .join('\n')
  180. this.currentError.raw += this.currentError.content
  181. const lineNo = this.currentError.raw.match(/l\.([0-9]+)/)
  182. if (lineNo) {
  183. this.currentError.line = parseInt(lineNo[1], 10)
  184. }
  185. return this.data.push(this.currentError)
  186. }
  187. parseSingleWarningLine(prefixRegex: RegExp) {
  188. const warningMatch = this.currentLine.match(prefixRegex)
  189. if (!warningMatch) {
  190. return
  191. }
  192. const warning = warningMatch[1]
  193. const lineMatch = warning.match(LINES_REGEX)
  194. const line = lineMatch ? parseInt(lineMatch[1], 10) : null
  195. this.data.push({
  196. line,
  197. file: this.currentFilePath,
  198. level: 'warning',
  199. message: warning,
  200. raw: warning,
  201. })
  202. }
  203. parseMultipleWarningLine() {
  204. // Some package warnings are multiple lines, let's parse the first line
  205. let warningMatch = this.currentLine.match(this.packageWarningRegex)
  206. // Something strange happened, return early
  207. if (!warningMatch) {
  208. return
  209. }
  210. const warningLines = [warningMatch[1]]
  211. let lineMatch = this.currentLine.match(LINES_REGEX)
  212. let line = lineMatch ? parseInt(lineMatch[1], 10) : null
  213. const packageMatch = this.currentLine.match(this.packageRegex)
  214. if (!packageMatch) {
  215. throw new Error(
  216. 'LatexParser Error: Unable to extract package name from warning.'
  217. )
  218. }
  219. const packageName = packageMatch[1]
  220. // Regex to get rid of the unnecesary (packagename) prefix in most multi-line warnings
  221. const prefixRegex = new RegExp(
  222. '(?:\\(' + packageName + '\\))*[\\s]*(.*)',
  223. 'i'
  224. )
  225. // After every warning message there's a blank line, let's use it
  226. let currentLine: string | false
  227. while ((currentLine = this.log.nextLine())) {
  228. this.currentLine = currentLine
  229. lineMatch = this.currentLine.match(LINES_REGEX)
  230. line = lineMatch ? parseInt(lineMatch[1], 10) : line
  231. warningMatch = this.currentLine.match(prefixRegex)
  232. if (!warningMatch) {
  233. throw new Error('LatexParser Error: Unable to extract warning message.')
  234. }
  235. warningLines.push(warningMatch[1])
  236. }
  237. const rawMessage = warningLines.join(' ')
  238. this.data.push({
  239. line,
  240. file: this.currentFilePath,
  241. level: 'warning',
  242. message: rawMessage,
  243. raw: rawMessage,
  244. })
  245. }
  246. parseHboxLine() {
  247. const lineMatch = this.currentLine.match(LINES_REGEX)
  248. const line = lineMatch ? parseInt(lineMatch[1], 10) : null
  249. this.data.push({
  250. line,
  251. file: this.currentFilePath,
  252. level: 'typesetting',
  253. message: this.currentLine,
  254. raw: this.currentLine,
  255. })
  256. }
  257. // Check if we're entering or leaving a new file in this line
  258. parseParensForFilenames() {
  259. const pos = this.currentLine.search(/[()]/)
  260. if (pos !== -1) {
  261. const token = this.currentLine[pos]
  262. this.currentLine = this.currentLine.slice(pos + 1)
  263. if (token === '(') {
  264. const filePath = this.consumeFilePath()
  265. if (filePath) {
  266. this.currentFilePath = filePath
  267. const newFile = {
  268. path: filePath,
  269. files: [],
  270. }
  271. this.fileStack.push(newFile)
  272. this.currentFileList.push(newFile)
  273. this.currentFileList = newFile.files
  274. } else {
  275. this.openParens++
  276. }
  277. } else if (token === ')') {
  278. if (this.openParens > 0) {
  279. this.openParens--
  280. } else {
  281. if (this.fileStack.length > 1) {
  282. this.fileStack.pop()
  283. const previousFile = this.fileStack[this.fileStack.length - 1]
  284. this.currentFilePath = previousFile.path
  285. this.currentFileList = previousFile.files
  286. }
  287. }
  288. }
  289. // else {
  290. // Something has gone wrong but all we can do now is ignore it :(
  291. // }
  292. // Process the rest of the line
  293. this.parseParensForFilenames()
  294. }
  295. }
  296. consumeFilePath() {
  297. // Our heuristic for detecting file names are rather crude
  298. // To contain a file path this line must have at least one / before any '(', ')' or '\'
  299. if (!this.currentLine.match(/^\/?([^ ()\\]+\/)+/)) {
  300. return false
  301. }
  302. // A file may not contain a '(', ')' or '\'
  303. let endOfFilePath = this.currentLine.search(/[ ()\\]/)
  304. // handle the case where there is a space in a filename
  305. while (endOfFilePath !== -1 && this.currentLine[endOfFilePath] === ' ') {
  306. const partialPath = this.currentLine.slice(0, endOfFilePath)
  307. // consider the file matching done if the space is preceded by a file extension (e.g. ".tex")
  308. if (/\.\w+$/.test(partialPath)) {
  309. break
  310. }
  311. // advance to next space or ) or end of line
  312. const remainingPath = this.currentLine.slice(endOfFilePath + 1)
  313. // consider file matching done if current path is followed by any of "()[]
  314. if (/^\s*["()[\]]/.test(remainingPath)) {
  315. break
  316. }
  317. const nextEndOfPath = remainingPath.search(/[ "()[\]]/)
  318. if (nextEndOfPath === -1) {
  319. endOfFilePath = -1
  320. } else {
  321. endOfFilePath += nextEndOfPath + 1
  322. }
  323. }
  324. let path
  325. if (endOfFilePath === -1) {
  326. path = this.currentLine
  327. this.currentLine = ''
  328. } else {
  329. path = this.currentLine.slice(0, endOfFilePath)
  330. this.currentLine = this.currentLine.slice(endOfFilePath)
  331. }
  332. return path
  333. }
  334. postProcess(data: LatexLogEntry[]) {
  335. const all: LatexLogEntry[] = []
  336. const errorsByLevel: Record<
  337. 'error' | 'warning' | 'typesetting',
  338. LatexLogEntry[]
  339. > = {
  340. error: [],
  341. warning: [],
  342. typesetting: [],
  343. }
  344. const hashes = new Set()
  345. const hashEntry = (entry: LatexLogEntry) => entry.raw
  346. data.forEach(item => {
  347. const hash = hashEntry(item)
  348. if (this.ignoreDuplicates && hashes.has(hash)) {
  349. return
  350. }
  351. errorsByLevel[item.level]?.push(item)
  352. all.push(item)
  353. hashes.add(hash)
  354. })
  355. return {
  356. errors: errorsByLevel.error,
  357. warnings: errorsByLevel.warning,
  358. typesetting: errorsByLevel.typesetting,
  359. all,
  360. files: this.rootFileList,
  361. }
  362. }
  363. }
  364. class LogText {
  365. text: string
  366. lines: string[]
  367. row: number
  368. constructor(text: string) {
  369. this.text = text.replace(/(\r\n)|\r/g, '\n')
  370. // Join any lines which look like they have wrapped.
  371. const wrappedLines = this.text.split('\n')
  372. this.lines = [wrappedLines[0]]
  373. for (let i = 1; i < wrappedLines.length; i++) {
  374. // If the previous line is as long as the wrap limit then
  375. // append this line to it.
  376. // Some lines end with ... when LaTeX knows it's hit the limit
  377. // These shouldn't be wrapped.
  378. // If the next line looks like it could be an error (i.e. start with a !),
  379. // do not unwrap the line.
  380. const prevLine = wrappedLines[i - 1]
  381. const currentLine = wrappedLines[i]
  382. if (
  383. prevLine.length === LOG_WRAP_LIMIT &&
  384. prevLine.slice(-3) !== '...' &&
  385. currentLine.charAt(0) !== '!'
  386. ) {
  387. this.lines[this.lines.length - 1] += currentLine
  388. } else {
  389. this.lines.push(currentLine)
  390. }
  391. }
  392. this.row = 0
  393. }
  394. nextLine(): string | false {
  395. this.row++
  396. if (this.row >= this.lines.length) {
  397. return false
  398. } else {
  399. return this.lines[this.row]
  400. }
  401. }
  402. rewindLine() {
  403. this.row--
  404. }
  405. linesUpToNextWhitespaceLine(stopAtError: boolean = false) {
  406. return this.linesUpToNextMatchingLine(/^ *$/, stopAtError)
  407. }
  408. linesUpToNextMatchingLine(match: RegExp, stopAtError: boolean = false) {
  409. const lines = []
  410. while (true) {
  411. const nextLine = this.nextLine()
  412. if (nextLine === false) {
  413. break
  414. }
  415. if (stopAtError && nextLine.match(/^! /)) {
  416. this.rewindLine()
  417. break
  418. }
  419. lines.push(nextLine)
  420. if (nextLine.match(match)) {
  421. break
  422. }
  423. }
  424. return lines
  425. }
  426. }