latex-log-parser.js 11 KB

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