latex-log-parser.js 12 KB

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