From 44f4c835c8310595059bdc6209f2ee0ae3aaa05f Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Wed, 13 Aug 2025 10:19:31 +0100 Subject: [PATCH] Merge pull request #27819 from overleaf/mj-human-readable-logs-typescript [web] Convert logs parsing to typescript GitOrigin-RevId: 7a338740db50c8a3a0b70dd2212083f17348d4f1 --- ...anReadableLogs.js => HumanReadableLogs.ts} | 25 +++-- .../HumanReadableLogsHints.tsx | 79 +++++++-------- ...=> HumanReadableLogsPackageSuggestions.ts} | 6 +- .../HumanReadableLogsRules.tsx | 27 ++--- .../{bib-log-parser.js => bib-log-parser.ts} | 46 +++++++-- ...atex-log-parser.js => latex-log-parser.ts} | 98 ++++++++++++++++--- 6 files changed, 196 insertions(+), 85 deletions(-) rename services/web/frontend/js/ide/human-readable-logs/{HumanReadableLogs.js => HumanReadableLogs.ts} (76%) rename services/web/frontend/js/ide/human-readable-logs/{HumanReadableLogsPackageSuggestions.js => HumanReadableLogsPackageSuggestions.ts} (94%) rename services/web/frontend/js/ide/log-parser/{bib-log-parser.js => bib-log-parser.ts} (86%) rename services/web/frontend/js/ide/log-parser/{latex-log-parser.js => latex-log-parser.ts} (82%) diff --git a/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogs.js b/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogs.ts similarity index 76% rename from services/web/frontend/js/ide/human-readable-logs/HumanReadableLogs.js rename to services/web/frontend/js/ide/human-readable-logs/HumanReadableLogs.ts index 57a35adfdc..c4fb93459e 100644 --- a/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogs.js +++ b/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogs.ts @@ -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 = {} // 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 }, } diff --git a/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogsHints.tsx b/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogsHints.tsx index dd188ea54e..ef7da6af9c 100644 --- a/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogsHints.tsx +++ b/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogsHints.tsx @@ -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 ( - <> -

- We think you’ve got a missing package! The {command}{' '} - command won't work unless you include - {suggestion.command} in your{' '} - - document preamble - - .{' '} - - Learn more about packages - - . -

- - ) + if (suggestion) { + return ( + <> +

+ We think you’ve got a missing package! The{' '} + {command} command won't work unless you include + {suggestion.command} in your{' '} + + document preamble + + .{' '} + + Learn more about packages + + . +

+ + ) + } } 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 ( - <> -

- We think you’ve got a missing package! The{' '} - {environment} environment won't work unless you - include {suggestion.command} in your{' '} - - document preamble - - .{' '} - - Learn more about packages - - . -

- - ) + if (suggestion) { + return ( + <> +

+ We think you’ve got a missing package! The{' '} + {environment} environment won't work unless you + include {suggestion.command} in your{' '} + + document preamble + + .{' '} + + Learn more about packages + + . +

+ + ) + } } return ( <> diff --git a/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogsPackageSuggestions.js b/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogsPackageSuggestions.ts similarity index 94% rename from services/web/frontend/js/ide/human-readable-logs/HumanReadableLogsPackageSuggestions.js rename to services/web/frontend/js/ide/human-readable-logs/HumanReadableLogsPackageSuggestions.ts index cc09a6e509..e7b4253cc4 100644 --- a/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogsPackageSuggestions.js +++ b/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogsPackageSuggestions.ts @@ -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}' }], diff --git a/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogsRules.tsx b/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogsRules.tsx index 1dd0cd5ec7..04bc594492 100644 --- a/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogsRules.tsx +++ b/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogsRules.tsx @@ -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 diff --git a/services/web/frontend/js/ide/log-parser/bib-log-parser.js b/services/web/frontend/js/ide/log-parser/bib-log-parser.ts similarity index 86% rename from services/web/frontend/js/ide/log-parser/bib-log-parser.js rename to services/web/frontend/js/ide/log-parser/bib-log-parser.ts index a9669f7953..f6d1a9d05d 100644 --- a/services/web/frontend/js/ide/log-parser/bib-log-parser.js +++ b/services/web/frontend/js/ide/log-parser/bib-log-parser.ts @@ -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 = [RegExp, (match: RegExpExecArray) => T] + +const parserReducer = function (maxErrors: number | null) { + return function ( + accumulator: [T[], string], + parser: Parser + ): [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 + lines: string[] + warningParsers: Parser[] + errorParsers: Parser[] + + 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, diff --git a/services/web/frontend/js/ide/log-parser/latex-log-parser.js b/services/web/frontend/js/ide/log-parser/latex-log-parser.ts similarity index 82% rename from services/web/frontend/js/ide/log-parser/latex-log-parser.js rename to services/web/frontend/js/ide/log-parser/latex-log-parser.ts index 52733d274c..8ad5cad231 100644 --- a/services/web/frontend/js/ide/log-parser/latex-log-parser.js +++ b/services/web/frontend/js/ide/log-parser/latex-log-parser.ts @@ -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) {