From 45ca0f796c679103efd305ddbef28073c4a5de32 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Wed, 27 Sep 2023 08:58:21 +0100 Subject: [PATCH] Merge pull request #14934 from overleaf/revert-14926-revert-14121-bg-best-allow-underscore-in-hyperref-labels Revert "Revert "allow underscore in hyperref labels"" GitOrigin-RevId: f7b2dd418fa9c0940b778604ed08eccab78f97d2 --- .../latex/linter/latex-linter.worker.js | 96 ++++++++++++++++++- .../languages/latex/latex-linter.test.ts | 50 ++++++++++ 2 files changed, 141 insertions(+), 5 deletions(-) diff --git a/services/web/frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker.js b/services/web/frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker.js index 4ae5f23248..19b9532297 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker.js +++ b/services/web/frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker.js @@ -271,7 +271,7 @@ const read1name = function (TokeniseResult, k) { // handle names like FOO_BAR let delimiterName = '' let j, tok - for (j = k + 2, tok; (tok = Tokens[j]); j++) { + for (j = k + 2; (tok = Tokens[j]); j++) { if (tok[1] === 'Text') { const str = text.substring(tok[2], tok[3]) if (!str.match(/^\S*$/)) { @@ -302,7 +302,7 @@ const read1filename = function (TokeniseResult, k) { let fileName = '' let j, tok - for (j = k + 1, tok; (tok = Tokens[j]); j++) { + for (j = k + 1; (tok = Tokens[j]); j++) { if (tok[1] === 'Text') { const str = text.substring(tok[2], tok[3]) if (!str.match(/^\S*$/)) { @@ -322,6 +322,54 @@ const read1filename = function (TokeniseResult, k) { } } +const readOptionalLabel = function (TokeniseResult, k) { + // read a label my_label:text.. + const Tokens = TokeniseResult.tokens + const text = TokeniseResult.text + + const params = Tokens[k + 1] + + // Quick check for arguments like [label] + if (params && params[1] === 'Text') { + const paramNum = text.substring(params[2], params[3]) + if (paramNum.match(/^(\[[^\]]*\])*\s*$/)) { + return k + 1 // got it + } + } + + let label = '' + let j, tok + for (j = k + 1; (tok = Tokens[j]); j++) { + if (tok[1] === '{') { + // unclosed label + break + } else if (tok[1] === 'Text') { + const str = text.substring(tok[2], tok[3]) + label = label + str + if (str.match(/\]/)) { + // breaking due to ] + break + } + } else if (tok[1] === '_') { + label = label + tok[1] + } else { + break // breaking due to unrecognised token + } + } + + if (label.length === 0) { + return null + } else if (label.length > 0 && /^\[[^\]]*\]\s*$/.test(label)) { + // make sure the label is of the form [label] + return j - 1 // advance past these tokens + } else { + // invalid label + const e = new Error('Invalid label') + e.pos = j + 1 + return e + } +} + const readOptionalParams = function (TokeniseResult, k) { // read an optional parameter [N] where N is a number, used // for \newcommand{\foo}[2]... meaning 2 parameters @@ -596,20 +644,34 @@ const InterpretTokens = function (TokeniseResult, ErrorReporter) { const nextGroupMathModeStack = [] // tracking all nextGroupMathModes let seenUserDefinedBeginEquation = false // if we have seen macros like \beq let seenUserDefinedEndEquation = false // if we have seen macros like \eeq + let seenInfiniteLoop = false // if we have seen an infinite loop in the linter // Iterate over the tokens, looking for environments to match // // Push environment command found (\begin, \end) onto the // Environments array. - for (let i = 0, len = Tokens.length; i < len; i++) { + for (let i = 0, len = Tokens.length, lastPos = -1; i < len; i++) { const token = Tokens[i] // const line = token[0] const type = token[1] // const start = token[2] // const end = token[3] const seq = token[4] - + if (i > lastPos) { + // advanced successfully through the tokens + lastPos = i + } else { + // we're not moving forward, so force the parsing to advance + if (!seenInfiniteLoop) + console.error('infinite loop in linter detected', lastPos, i, token) + lastPos = lastPos + 1 + i = lastPos + 1 + seenInfiniteLoop = true + if (i >= len) { + break + } + } if (type === '{') { // handle open group as a type of environment Environments.push({ @@ -667,7 +729,7 @@ const InterpretTokens = function (TokeniseResult, ErrorReporter) { if (open && open[1] === '{' && delimiter && delimiter[1] === 'Text') { let delimiterName = '' let j, tok - for (j = i + 2, tok; (tok = Tokens[j]); j++) { + for (j = i + 2; (tok = Tokens[j]); j++) { if (tok[1] === 'Text') { const str = text.substring(tok[2], tok[3]) if (!str.match(/^\S*$/)) { @@ -973,6 +1035,30 @@ const InterpretTokens = function (TokeniseResult, ErrorReporter) { i = newPos } nextGroupMathMode = false + } else if (seq === 'hyperref') { + // try to read any optional params [LABEL].... allowing for + // underscores, advance if found + let newPos = readOptionalLabel(TokeniseResult, i) + if (newPos instanceof Error) { + TokenErrorFromTo( + Tokens[i + 1], + Tokens[Math.min(newPos.pos, len - 1)], + 'invalid hyperref label' + ) + i = newPos.pos + } else if (newPos == null) { + /* do nothing */ + } else { + i = newPos + } + // try to read parameter {....}, advance if found + newPos = readDefinition(TokeniseResult, i) + if (newPos === null) { + /* do nothing */ + } else { + i = newPos + } + nextGroupMathMode = false } else if (seq === 'resizebox') { // try to read any optional params [BAR]...., advance if found let newPos = readOptionalGeneric(TokeniseResult, i) diff --git a/services/web/test/frontend/features/source-editor/languages/latex/latex-linter.test.ts b/services/web/test/frontend/features/source-editor/languages/latex/latex-linter.test.ts index 4cc6167868..a83715bb72 100644 --- a/services/web/test/frontend/features/source-editor/languages/latex/latex-linter.test.ts +++ b/services/web/test/frontend/features/source-editor/languages/latex/latex-linter.test.ts @@ -444,6 +444,56 @@ describe('LatexLinter', function () { assert.equal(errors.length, 0) }) + it('should accept a plain hyperref command', function () { + const { errors } = Parse('\\hyperref{http://www.overleaf.com/}') + assert.equal(errors.length, 0) + }) + + it('should accept a hyperref command with underscores in the url ', function () { + const { errors } = Parse('\\hyperref{http://www.overleaf.com/my_page.html}') + assert.equal(errors.length, 0) + }) + + it('should accept a hyperref command with category, name and text arguments ', function () { + const { errors } = Parse( + '\\hyperref{http://www.overleaf.com/}{category}{name}{text}' + ) + assert.equal(errors.length, 0) + }) + + it('should accept an underscore in a hyperref label', function () { + const { errors } = Parse('\\hyperref[foo_bar]{foo bar}') + assert.equal(errors.length, 0) + }) + + it('should reject a $ in a hyperref label', function () { + const { errors } = Parse('\\hyperref[foo$bar]{foo bar}') + assert.equal(errors.length, 1) + }) + + it('should reject an unclosed hyperref label', function () { + const { errors } = Parse('\\hyperref[foo_bar{foo bar}') + assert.equal(errors.length, 2) + assert.equal(errors[0].text, 'invalid hyperref label') + assert.equal(errors[1].text, 'unexpected close group }') + }) + + it('should accept a hyperref command without an optional argument', function () { + const { errors } = Parse('{\\hyperref{hello}}') + assert.equal(errors.length, 0) + }) + + it('should accept a hyperref command without an optional argument and multiple other arguments', function () { + const { errors } = Parse('{\\hyperref{}{}{fig411}}') + assert.equal(errors.length, 0) + }) + + it('should accept a hyperref command without an optional argument in an unclosed group', function () { + const { errors } = Parse('{\\hyperref{}{}{fig411}') + assert.equal(errors.length, 1) + assert.equal(errors[0].text, 'unclosed group {') + }) + // %novalidate // %begin novalidate // %end novalidate