latex-log-parser.js 11 KB

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