latex-log-parser.js 11 KB

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