Skip to content

Commit cc5a264

Browse files
committed
fix(generator): lex regex-in-keyword-position and template interpolation
The scanner chose regex-vs-division from the previous significant character, so a regex in operand position (`return /x/`, `typeof /x/`, `case /x/`, ...) lexed as a division off the keyword's last letter and its body stayed in the structural view; `azure_devops.ts` is inert today only because the braces in its `return /^\d{4}-\d{2}-\d{2}$/` happen to balance. The `${}` depth counter was also not string-aware, so a brace inside a quoted expression miscounted, the closing backtick was lost and the block was reported unreadable — which silently stops filtering resolver-derived hidden params. Both scans now run on one set of lexer primitives: `${}` expressions are lexed with the same string, comment, regex and template handling as top-level code. Regenerated docs, tool metadata and the integration catalog are byte-identical and the generator's warning count is unchanged.
1 parent 06bf99f commit cc5a264

2 files changed

Lines changed: 276 additions & 71 deletions

File tree

scripts/generate-docs.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,3 +807,83 @@ describe('the scanner survives regex literals in a block config', () => {
807807
expect(extractUserSettableParamIds("subBlocks: [{ id: 'a }],")).toBeNull()
808808
})
809809
})
810+
811+
describe('the scanner reads a regex that opens in keyword position', () => {
812+
/**
813+
* The scanner chose regex-vs-division from the previous significant character alone, so a
814+
* regex in operand position was lexed as a division off the keyword's last letter and its
815+
* body was left in the structural view — a brace inside it then closed the object early.
816+
*/
817+
it('does not let a brace inside a regex after return close the object early', () => {
818+
const ids = extractUserSettableParamIds(
819+
"subBlocks: [{ id: 'a', condition: (v) => { return /}/.test(v) } }, { id: 'b' }],"
820+
)
821+
822+
expect(ids).toEqual(['a', 'b'])
823+
})
824+
825+
it('treats every operand-position keyword as opening a regex', () => {
826+
const keywords = [
827+
'return',
828+
'typeof',
829+
'case',
830+
'in',
831+
'of',
832+
'new',
833+
'delete',
834+
'void',
835+
'instanceof',
836+
'do',
837+
'else',
838+
'yield',
839+
'await',
840+
]
841+
842+
for (const keyword of keywords) {
843+
const ids = extractUserSettableParamIds(
844+
`subBlocks: [{ id: 'a', v: (x) => ${keyword} /}/.source }, { id: 'b' }],`
845+
)
846+
847+
expect(ids, keyword).toEqual(['a', 'b'])
848+
}
849+
})
850+
851+
it('still reads a division after a property or an identifier that merely ends in a keyword', () => {
852+
const ids = extractUserSettableParamIds(
853+
"subBlocks: [{ id: 'a', n: counts.in / 2, m: preturn / 2 }, { id: 'b' }],"
854+
)
855+
856+
expect(ids).toEqual(['a', 'b'])
857+
})
858+
})
859+
860+
describe('template interpolation is lexed rather than brace-counted', () => {
861+
/**
862+
* The `${}` depth counter was not string-aware, so a brace inside a quoted expression
863+
* miscounted, the closing backtick was never found and the whole block was reported
864+
* unreadable — which silently stops filtering resolver-derived hidden params for it.
865+
*/
866+
it('does not lose the closing backtick to an opening brace inside a quoted expression', () => {
867+
const ids = extractUserSettableParamIds(
868+
"subBlocks: [{ id: 'a', label: `${format('{')}` }, { id: 'b' }],"
869+
)
870+
871+
expect(ids).toEqual(['a', 'b'])
872+
})
873+
874+
it('does not let a closing brace inside a quoted expression end the interpolation', () => {
875+
const ids = extractUserSettableParamIds(
876+
'subBlocks: [{ id: \'a\', label: `${format("}") + "{"}` }, { id: \'b\' }],'
877+
)
878+
879+
expect(ids).toEqual(['a', 'b'])
880+
})
881+
882+
it('lexes a regex, a comment and a nested template inside the expression', () => {
883+
const ids = extractUserSettableParamIds(
884+
"subBlocks: [{ id: 'a', label: `${/[{]/.source /* { */ + `${'{'}`}` }, { id: 'b' }],"
885+
)
886+
887+
expect(ids).toEqual(['a', 'b'])
888+
})
889+
})

scripts/generate-docs.ts

Lines changed: 196 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1317,6 +1317,184 @@ const REGEX_ALLOWED_AFTER = new Set([
13171317
'\n',
13181318
])
13191319

1320+
/**
1321+
* Keywords after which a `/` begins a regex literal rather than a division. In every one of
1322+
* these positions an operand is expected, so `return /x/`, `typeof /x/` or `case /x/` opens a
1323+
* regex — a check on the previous character alone reads the keyword's last letter as an
1324+
* identifier and lexes the `/` as division.
1325+
*/
1326+
const REGEX_START_KEYWORDS = new Set([
1327+
'return',
1328+
'typeof',
1329+
'case',
1330+
'in',
1331+
'of',
1332+
'new',
1333+
'delete',
1334+
'void',
1335+
'instanceof',
1336+
'do',
1337+
'else',
1338+
'yield',
1339+
'await',
1340+
])
1341+
1342+
/**
1343+
* Whether the word immediately before `index` is a {@link REGEX_START_KEYWORDS} keyword.
1344+
* A property access (`counts.in / 2`) is excluded, since there the word is an identifier
1345+
* and the `/` really is a division.
1346+
*/
1347+
function precededByRegexStartKeyword(content: string, index: number): boolean {
1348+
let j = index - 1
1349+
while (j >= 0 && /\s/.test(content[j])) j--
1350+
const wordEnd = j + 1
1351+
while (j >= 0 && /[A-Za-z0-9_$]/.test(content[j])) j--
1352+
if (!REGEX_START_KEYWORDS.has(content.slice(j + 1, wordEnd))) return false
1353+
return content[j] !== '.' && content[j] !== '#'
1354+
}
1355+
1356+
/** Index just past a `//` comment opening at `start`. */
1357+
function scanLineComment(content: string, start: number): number {
1358+
const newline = content.indexOf('\n', start)
1359+
return newline === -1 ? content.length : newline
1360+
}
1361+
1362+
/** Index just past the block comment opening at `start`, or null when it never closes. */
1363+
function scanBlockComment(content: string, start: number): number | null {
1364+
const close = content.indexOf('*/', start + 2)
1365+
return close === -1 ? null : close + 2
1366+
}
1367+
1368+
/** Index just past a regex literal (and its flags) opening at `start`, or null when unterminated. */
1369+
function scanRegexLiteral(content: string, start: number): number | null {
1370+
let j = start + 1
1371+
let inClass = false
1372+
let closed = false
1373+
while (j < content.length) {
1374+
const c = content[j]
1375+
if (c === '\\') {
1376+
j += 2
1377+
continue
1378+
}
1379+
if (c === '\n') break
1380+
if (c === '[') inClass = true
1381+
else if (c === ']') inClass = false
1382+
else if (c === '/' && !inClass) {
1383+
closed = true
1384+
break
1385+
}
1386+
j++
1387+
}
1388+
if (!closed) return null
1389+
j++
1390+
while (j < content.length && /[a-z]/.test(content[j])) j++
1391+
return j
1392+
}
1393+
1394+
/** Index OF the closing quote of the string opening at `start`, or null when unterminated. */
1395+
function scanQuoted(content: string, start: number): number | null {
1396+
const quote = content[start]
1397+
let j = start + 1
1398+
while (j < content.length) {
1399+
if (content[j] === '\\') {
1400+
j += 2
1401+
continue
1402+
}
1403+
if (content[j] === '\n') break
1404+
if (content[j] === quote) return j
1405+
j++
1406+
}
1407+
return null
1408+
}
1409+
1410+
/** Index OF the closing backtick of the template literal opening at `start`, or null. */
1411+
function scanTemplateLiteral(content: string, start: number): number | null {
1412+
let j = start + 1
1413+
while (j < content.length) {
1414+
const c = content[j]
1415+
if (c === '\\') {
1416+
j += 2
1417+
continue
1418+
}
1419+
if (c === '`') return j
1420+
if (c === '$' && content[j + 1] === '{') {
1421+
const end = scanTemplateExpression(content, j + 2)
1422+
if (end === null) return null
1423+
j = end
1424+
continue
1425+
}
1426+
j++
1427+
}
1428+
return null
1429+
}
1430+
1431+
/**
1432+
* Index just past the `}` that closes the `${` expression starting at `start`, or null.
1433+
*
1434+
* The expression is lexed with the same primitives as top-level code, so a brace inside a
1435+
* nested string, comment, regex or template never counts toward the depth — a plain counter
1436+
* loses the closing backtick of `` `${ f("}") }` `` and reports the whole block unreadable.
1437+
*/
1438+
function scanTemplateExpression(content: string, start: number): number | null {
1439+
let j = start
1440+
let depth = 0
1441+
let prevSignificant = ''
1442+
while (j < content.length) {
1443+
const c = content[j]
1444+
1445+
if (c === '/' && content[j + 1] === '/') {
1446+
j = scanLineComment(content, j)
1447+
continue
1448+
}
1449+
if (c === '/' && content[j + 1] === '*') {
1450+
const end = scanBlockComment(content, j)
1451+
if (end === null) return null
1452+
j = end
1453+
continue
1454+
}
1455+
if (c === '/' && startsRegexLiteral(content, j, prevSignificant)) {
1456+
const end = scanRegexLiteral(content, j)
1457+
if (end === null) return null
1458+
prevSignificant = ')'
1459+
j = end
1460+
continue
1461+
}
1462+
if (c === "'" || c === '"') {
1463+
const close = scanQuoted(content, j)
1464+
if (close === null) return null
1465+
prevSignificant = c
1466+
j = close + 1
1467+
continue
1468+
}
1469+
if (c === '`') {
1470+
const close = scanTemplateLiteral(content, j)
1471+
if (close === null) return null
1472+
prevSignificant = '`'
1473+
j = close + 1
1474+
continue
1475+
}
1476+
if (c === '{') depth++
1477+
else if (c === '}') {
1478+
if (depth === 0) return j + 1
1479+
depth--
1480+
}
1481+
1482+
if (!/\s/.test(c)) prevSignificant = c
1483+
else if (c === '\n') prevSignificant = '\n'
1484+
j++
1485+
}
1486+
return null
1487+
}
1488+
1489+
/** Whether the `/` at `index` opens a regex literal rather than a division. */
1490+
function startsRegexLiteral(content: string, index: number, prevSignificant: string): boolean {
1491+
return (
1492+
prevSignificant === '' ||
1493+
REGEX_ALLOWED_AFTER.has(prevSignificant) ||
1494+
precededByRegexStartKeyword(content, index)
1495+
)
1496+
}
1497+
13201498
/**
13211499
* Blank out string literals, template literals, comments and regex literals so a structural
13221500
* scan sees only code punctuation. Length and newlines are preserved, which the `readLiteral`
@@ -1342,97 +1520,44 @@ function blankStringsAndComments(content: string): string | null {
13421520
const char = content[i]
13431521

13441522
if (char === '/' && content[i + 1] === '/') {
1345-
const nl = content.indexOf('\n', i)
1346-
const end = nl === -1 ? content.length : nl
1523+
const end = scanLineComment(content, i)
13471524
blank(i, end)
13481525
i = end
13491526
continue
13501527
}
13511528

13521529
if (char === '/' && content[i + 1] === '*') {
1353-
const close = content.indexOf('*/', i + 2)
1354-
if (close === -1) return null
1355-
blank(i, close + 2)
1356-
i = close + 2
1530+
const end = scanBlockComment(content, i)
1531+
if (end === null) return null
1532+
blank(i, end)
1533+
i = end
13571534
continue
13581535
}
13591536

1360-
if (char === '/' && (prevSignificant === '' || REGEX_ALLOWED_AFTER.has(prevSignificant))) {
1361-
let j = i + 1
1362-
let inClass = false
1363-
let closed = false
1364-
while (j < content.length) {
1365-
const c = content[j]
1366-
if (c === '\\') {
1367-
j += 2
1368-
continue
1369-
}
1370-
if (c === '\n') break
1371-
if (c === '[') inClass = true
1372-
else if (c === ']') inClass = false
1373-
else if (c === '/' && !inClass) {
1374-
closed = true
1375-
break
1376-
}
1377-
j++
1378-
}
1379-
if (!closed) return null
1380-
j++
1381-
while (j < content.length && /[a-z]/.test(content[j])) j++
1382-
blank(i, j)
1537+
if (char === '/' && startsRegexLiteral(content, i, prevSignificant)) {
1538+
const end = scanRegexLiteral(content, i)
1539+
if (end === null) return null
1540+
blank(i, end)
13831541
prevSignificant = ')'
1384-
i = j
1542+
i = end
13851543
continue
13861544
}
13871545

13881546
if (char === "'" || char === '"') {
1389-
let j = i + 1
1390-
let closed = false
1391-
while (j < content.length) {
1392-
if (content[j] === '\\') {
1393-
j += 2
1394-
continue
1395-
}
1396-
if (content[j] === '\n') break
1397-
if (content[j] === char) {
1398-
closed = true
1399-
break
1400-
}
1401-
j++
1402-
}
1403-
if (!closed) return null
1404-
blank(i + 1, j)
1547+
const close = scanQuoted(content, i)
1548+
if (close === null) return null
1549+
blank(i + 1, close)
14051550
prevSignificant = char
1406-
i = j + 1
1551+
i = close + 1
14071552
continue
14081553
}
14091554

14101555
if (char === '`') {
1411-
let j = i + 1
1412-
let depth = 0
1413-
let closed = false
1414-
while (j < content.length) {
1415-
if (content[j] === '\\') {
1416-
j += 2
1417-
continue
1418-
}
1419-
if (depth === 0 && content[j] === '`') {
1420-
closed = true
1421-
break
1422-
}
1423-
if (content[j] === '$' && content[j + 1] === '{') {
1424-
depth++
1425-
j += 2
1426-
continue
1427-
}
1428-
if (depth > 0 && content[j] === '{') depth++
1429-
else if (depth > 0 && content[j] === '}') depth--
1430-
j++
1431-
}
1432-
if (!closed) return null
1433-
blank(i + 1, j)
1556+
const close = scanTemplateLiteral(content, i)
1557+
if (close === null) return null
1558+
blank(i + 1, close)
14341559
prevSignificant = '`'
1435-
i = j + 1
1560+
i = close + 1
14361561
continue
14371562
}
14381563

0 commit comments

Comments
 (0)