Merge pull request #27819 from overleaf/mj-human-readable-logs-typescript

[web] Convert logs parsing to typescript

GitOrigin-RevId: 7a338740db50c8a3a0b70dd2212083f17348d4f1
This commit is contained in:
Mathias Jakobsen
2025-08-13 10:19:31 +01:00
committed by Copybot
parent e166b09d46
commit 114a2af181
6 changed files with 196 additions and 85 deletions

View File

@@ -1,14 +1,17 @@
import LatexLogParser from '../log-parser/latex-log-parser'
import LatexLogParser, {
LatexParserOptions,
ParseResult,
} from '../log-parser/latex-log-parser'
import ruleset from './HumanReadableLogsRules'
export default {
parse(rawLog, options) {
parse(rawLog: string | ParseResult, options: LatexParserOptions) {
const parsedLogEntries =
typeof rawLog === 'string'
? new LatexLogParser(rawLog, options).parse()
: rawLog
const seenErrorTypes = {} // keep track of types of errors seen
const seenErrorTypes: Record<string, boolean> = {} // keep track of types of errors seen
for (const entry of parsedLogEntries.all) {
const ruleDetails = ruleset.find(rule =>
@@ -28,9 +31,11 @@ export default {
}
if (ruleDetails.contentRegex) {
const match = entry.content.match(ruleDetails.contentRegex)
if (match) {
entry.contentDetails = match.slice(1)
if (entry.content != null) {
const match = entry.content.match(ruleDetails.contentRegex)
if (match) {
entry.contentDetails = match.slice(1)
}
}
}
@@ -74,14 +79,14 @@ export default {
}
// filter out the suppressed errors (from the array entries in parsedLogEntries)
for (const [key, errors] of Object.entries(parsedLogEntries)) {
if (typeof errors === 'object' && errors.length > 0) {
parsedLogEntries[key] = Array.from(errors).filter(
for (const type of ['errors', 'warnings', 'typesetting'] as const) {
const errors = parsedLogEntries[type]
if (Array.isArray(errors) && errors.length > 0) {
parsedLogEntries[type] = Array.from(errors).filter(
err => !err.suppressed
)
}
}
return parsedLogEntries
},
}

View File

@@ -172,26 +172,28 @@ const hints: { [ruleId: string]: LogHint } = {
},
hint_undefined_control_sequence: {
formattedContent: details => {
if (details?.length && packageSuggestionsForCommands.has(details[0])) {
if (details?.length) {
const command = details[0]
const suggestion = packageSuggestionsForCommands.get(command)
return (
<>
<p>
We think youve got a missing package! The <code>{command}</code>{' '}
command won't work unless you include
<code>{suggestion.command}</code> in your{' '}
<WikiLink url="https://www.overleaf.com/learn/latex/Learn_LaTeX_in_30_minutes#The_preamble_of_a_document">
document preamble
</WikiLink>
.{' '}
<WikiLink url="https://www.overleaf.com/learn/latex/Learn_LaTeX_in_30_minutes#Finding_and_using_LaTeX_packages">
Learn more about packages
</WikiLink>
.
</p>
</>
)
if (suggestion) {
return (
<>
<p>
We think youve got a missing package! The{' '}
<code>{command}</code> command won't work unless you include
<code>{suggestion.command}</code> in your{' '}
<WikiLink url="https://www.overleaf.com/learn/latex/Learn_LaTeX_in_30_minutes#The_preamble_of_a_document">
document preamble
</WikiLink>
.{' '}
<WikiLink url="https://www.overleaf.com/learn/latex/Learn_LaTeX_in_30_minutes#Finding_and_using_LaTeX_packages">
Learn more about packages
</WikiLink>
.
</p>
</>
)
}
}
return (
<>
@@ -215,29 +217,28 @@ const hints: { [ruleId: string]: LogHint } = {
},
hint_undefined_environment: {
formattedContent: details => {
if (
details?.length &&
packageSuggestionsForEnvironments.has(details[0])
) {
if (details?.length) {
const environment = details[0]
const suggestion = packageSuggestionsForEnvironments.get(environment)
return (
<>
<p>
We think youve got a missing package! The{' '}
<code>{environment}</code> environment won't work unless you
include <code>{suggestion.command}</code> in your{' '}
<WikiLink url="https://www.overleaf.com/learn/latex/Learn_LaTeX_in_30_minutes#The_preamble_of_a_document">
document preamble
</WikiLink>
.{' '}
<WikiLink url="https://www.overleaf.com/learn/latex/Learn_LaTeX_in_30_minutes#Finding_and_using_LaTeX_packages">
Learn more about packages
</WikiLink>
.
</p>
</>
)
if (suggestion) {
return (
<>
<p>
We think youve got a missing package! The{' '}
<code>{environment}</code> environment won't work unless you
include <code>{suggestion.command}</code> in your{' '}
<WikiLink url="https://www.overleaf.com/learn/latex/Learn_LaTeX_in_30_minutes#The_preamble_of_a_document">
document preamble
</WikiLink>
.{' '}
<WikiLink url="https://www.overleaf.com/learn/latex/Learn_LaTeX_in_30_minutes#Finding_and_using_LaTeX_packages">
Learn more about packages
</WikiLink>
.
</p>
</>
)
}
}
return (
<>

View File

@@ -1,4 +1,6 @@
const commandSuggestions = [
type Suggestion = [string, { name: string; command: string }]
const commandSuggestions: Suggestion[] = [
[
'\\includegraphics',
{ name: 'graphicx', command: '\\usepackage{graphicx}' },
@@ -30,7 +32,7 @@ const commandSuggestions = [
['\\arraybackslash', { name: 'array', command: '\\usepackage{array}' }],
]
const environmentSuggestions = [
const environmentSuggestions: Suggestion[] = [
['justify', { name: 'ragged2e', command: '\\usepackage{ragged2e}' }],
['align', { name: 'amsmath', command: '\\usepackage{amsmath}' }],
['align*', { name: 'amsmath', command: '\\usepackage{amsmath}' }],

View File

@@ -13,7 +13,7 @@ interface Rule {
contentRegex?: RegExp
improvedTitle?: (
currentTitle: string,
details?: [string]
details?: string[]
) => string | [string, JSX.Element]
package?: string
highlightCommand?: (contentDetails: string[]) => string | undefined
@@ -65,10 +65,13 @@ const rules: Rule[] = [
regexToMatch: /Undefined control sequence/,
// Match the last control sequence in the line
contentRegex: /^[^\n]*(\\\S+)\s*[\n]/,
improvedTitle: (currentTitle: string, details?: [string]) => {
if (details?.length && packageSuggestionsForCommands.has(details[0])) {
const command = details[0]
const suggestion = packageSuggestionsForCommands.get(command)
improvedTitle: (currentTitle: string, details?: string[]) => {
if (!details?.length) {
return currentTitle
}
const command = details[0]
const suggestion = packageSuggestionsForCommands.get(command)
if (suggestion) {
return [
`Is ${suggestion.command} missing?`,
// eslint-disable-next-line react/jsx-key
@@ -87,13 +90,13 @@ const rules: Rule[] = [
ruleId: 'hint_undefined_environment',
regexToMatch: /LaTeX Error: Environment .+ undefined/,
contentRegex: /\\begin\{(\S+)\}/,
improvedTitle: (currentTitle: string, details?: [string]) => {
if (
details?.length &&
packageSuggestionsForEnvironments.has(details[0])
) {
const environment = details[0]
const suggestion = packageSuggestionsForEnvironments.get(environment)
improvedTitle: (currentTitle: string, details?: string[]) => {
if (!details?.length) {
return currentTitle
}
const environment = details[0]
const suggestion = packageSuggestionsForEnvironments.get(environment)
if (suggestion) {
return [
`Is ${suggestion.command} missing?`,
// eslint-disable-next-line react/jsx-key

View File

@@ -18,12 +18,21 @@ const MESSAGE_LEVELS = {
ERROR: 'error',
}
const parserReducer = function (maxErrors) {
return function (accumulator, parser) {
const consume = function (logText, regex, process) {
type Parser<T> = [RegExp, (match: RegExpExecArray) => T]
const parserReducer = function (maxErrors: number | null) {
return function <T>(
accumulator: [T[], string],
parser: Parser<T>
): [T[], string] {
const consume = function (
logText: string,
regex: RegExp,
process: (match: RegExpExecArray) => T
): [T[], string] {
let match
let text = logText
const result = []
const result: T[] = []
let iterationCount = 0
while ((match = regex.exec(text))) {
@@ -55,8 +64,22 @@ const parserReducer = function (maxErrors) {
}
}
type BibLogEntry = {
file: string
level: string
message: string
line: string
raw: string
}
export default class BibLogParser {
constructor(text, options = {}) {
text: string
options: Record<string, any>
lines: string[]
warningParsers: Parser<BibLogEntry>[]
errorParsers: Parser<BibLogEntry>[]
constructor(text: string, options = {}) {
if (typeof text !== 'string') {
throw new Error('BibLogParser Error: text parameter must be a string')
}
@@ -156,11 +179,11 @@ export default class BibLogParser {
// reduce over the parsers, starting with the log text,
const [allWarnings, remainingText] = this.warningParsers.reduce(
parserReducer(this.options.maxErrors),
[[], this.text]
[[] as BibLogEntry[], this.text]
)
const [allErrors] = this.errorParsers.reduce(
parserReducer(this.options.maxErrors),
[[], remainingText]
[[] as BibLogEntry[], remainingText]
)
return {
@@ -173,7 +196,10 @@ export default class BibLogParser {
}
parseBiber() {
const result = {
const result: Record<
'all' | 'errors' | 'warnings' | 'files' | 'typesetting',
BibLogEntry[]
> = {
all: [],
errors: [],
warnings: [],
@@ -186,7 +212,9 @@ export default class BibLogParser {
const [fullLine, , messageType, message] = match
const newEntry = {
file: '',
level: MESSAGE_LEVELS[messageType] || 'INFO',
level:
MESSAGE_LEVELS[messageType as keyof typeof MESSAGE_LEVELS] ||
'INFO',
message,
line: '',
raw: fullLine,

View File

@@ -14,8 +14,54 @@ const STATE = {
ERROR: 1,
}
export type LatexParserOptions = {
fileBaseNames?: RegExp[]
ignoreDuplicates?: boolean
}
type LatexLogEntry = {
line: string | number | null
file: string | undefined
level: 'error' | 'warning' | 'typesetting'
message: string
content?: string
raw: string
ruleId?: string
contentDetails?: string[]
command?: string
suppressed?: boolean
}
type File = {
path: string
files: File[]
}
export type ParseResult = {
all: LatexLogEntry[]
errors: LatexLogEntry[]
warnings: LatexLogEntry[]
typesetting: LatexLogEntry[]
files: File[]
}
export default class LatexParser {
constructor(text, options = {}) {
state: number
fileBaseNames: RegExp[]
ignoreDuplicates?: boolean
data: LatexLogEntry[]
fileStack: File[]
rootFileList: File[]
currentFileList: File[]
openParens: number
latexWarningRegex: RegExp
packageWarningRegex: RegExp
packageRegex: RegExp
log: LogText
currentLine: string
currentFilePath: string | undefined
currentError: LatexLogEntry | undefined
constructor(text: string, options: LatexParserOptions = {}) {
this.state = STATE.NORMAL
this.fileBaseNames = options.fileBaseNames || [/compiles/, /\/usr\/local/]
this.ignoreDuplicates = options.ignoreDuplicates
@@ -26,11 +72,14 @@ export default class LatexParser {
this.latexWarningRegex = LATEX_WARNING_REGEX
this.packageWarningRegex = PACKAGE_WARNING_REGEX
this.packageRegex = PACKAGE_REGEX
this.currentLine = ''
this.log = new LogText(text)
}
parse() {
while ((this.currentLine = this.log.nextLine()) !== false) {
parse(): ParseResult {
let nextLine: string | false
while ((nextLine = this.log.nextLine()) !== false) {
this.currentLine = nextLine
if (this.state === STATE.NORMAL) {
if (this.currentLineIsError()) {
this.state = STATE.ERROR
@@ -58,6 +107,9 @@ export default class LatexParser {
}
}
if (this.state === STATE.ERROR) {
if (!this.currentError) {
throw new Error('LatexParser Error: currentError is undefined')
}
this.currentError.content += this.log
.linesUpToNextMatchingLine(/^l\.[0-9]+/)
.join('\n')
@@ -111,6 +163,9 @@ export default class LatexParser {
parseFileLineError() {
const result = this.currentLine.match(FILE_LINE_ERROR_REGEX)
if (!result) {
throw new Error('LatexParser Error: Unable to extract error from line.')
}
this.currentError = {
line: result[2],
file: result[1],
@@ -145,7 +200,7 @@ export default class LatexParser {
return this.data.push(this.currentError)
}
parseSingleWarningLine(prefixRegex) {
parseSingleWarningLine(prefixRegex: RegExp) {
const warningMatch = this.currentLine.match(prefixRegex)
if (!warningMatch) {
return
@@ -173,6 +228,11 @@ export default class LatexParser {
let lineMatch = this.currentLine.match(LINES_REGEX)
let line = lineMatch ? parseInt(lineMatch[1], 10) : null
const packageMatch = this.currentLine.match(this.packageRegex)
if (!packageMatch) {
throw new Error(
'LatexParser Error: Unable to extract package name from warning.'
)
}
const packageName = packageMatch[1]
// Regex to get rid of the unnecesary (packagename) prefix in most multi-line warnings
const prefixRegex = new RegExp(
@@ -180,10 +240,15 @@ export default class LatexParser {
'i'
)
// After every warning message there's a blank line, let's use it
while ((this.currentLine = this.log.nextLine())) {
let currentLine: string | false
while ((currentLine = this.log.nextLine())) {
this.currentLine = currentLine
lineMatch = this.currentLine.match(LINES_REGEX)
line = lineMatch ? parseInt(lineMatch[1], 10) : line
warningMatch = this.currentLine.match(prefixRegex)
if (!warningMatch) {
throw new Error('LatexParser Error: Unable to extract warning message.')
}
warningLines.push(warningMatch[1])
}
const rawMessage = warningLines.join(' ')
@@ -291,16 +356,19 @@ export default class LatexParser {
return path
}
postProcess(data) {
const all = []
const errorsByLevel = {
postProcess(data: LatexLogEntry[]) {
const all: LatexLogEntry[] = []
const errorsByLevel: Record<
'error' | 'warning' | 'typesetting',
LatexLogEntry[]
> = {
error: [],
warning: [],
typesetting: [],
}
const hashes = new Set()
const hashEntry = entry => entry.raw
const hashEntry = (entry: LatexLogEntry) => entry.raw
data.forEach(item => {
const hash = hashEntry(item)
@@ -326,7 +394,11 @@ export default class LatexParser {
}
class LogText {
constructor(text) {
text: string
lines: string[]
row: number
constructor(text: string) {
this.text = text.replace(/(\r\n)|\r/g, '\n')
// Join any lines which look like they have wrapped.
const wrappedLines = this.text.split('\n')
@@ -355,7 +427,7 @@ class LogText {
this.row = 0
}
nextLine() {
nextLine(): string | false {
this.row++
if (this.row >= this.lines.length) {
return false
@@ -368,11 +440,11 @@ class LogText {
this.row--
}
linesUpToNextWhitespaceLine(stopAtError) {
linesUpToNextWhitespaceLine(stopAtError: boolean = false) {
return this.linesUpToNextMatchingLine(/^ *$/, stopAtError)
}
linesUpToNextMatchingLine(match, stopAtError) {
linesUpToNextMatchingLine(match: RegExp, stopAtError: boolean = false) {
const lines = []
while (true) {