import type { DialectSpec } from './dialect' import type { Token } from './tokenizer' import { isKeyword, isPunctuation, tokenize } from './tokenizer' export interface Statement { text: string start: number end: number terminatorEnd: number tokens: Token[] } // Matched by text: most are not keywords in any dialect's list. const TRANSACTION_OPENERS = new Set([ ';', 'DEFERRABLE', 'DEFERRED', 'DISTRIBUTED', 'EXCLUSIVE', 'IMMEDIATE', 'ISOLATION', 'READ', 'TRAN', 'TRANSACTION', 'WORK', ]) const WHOLE_STATEMENT_ENDINGS = new Set([undefined, 'TRANSACTION', 'WORK']) const END_QUALIFIERS = ['IF', 'LOOP', 'WHILE', 'REPEAT'] const ACCESS_MODES = ['read only', 'read write'] as const const ISOLATION_LEVELS = [ 'read uncommitted', 'read committed', 'repeatable read', 'serializable', 'snapshot', ] as const const GO_REPEAT_COUNT = /^\s*\d*\s*$/u const BLANK = /^\s*$/u const separatorRest = (token: Token, dialect: DialectSpec) => { if (dialect.goBatches && isKeyword(token, 'GO')) { return GO_REPEAT_COUNT } return dialect.plsqlUnits && token.text === '/' ? BLANK : null } export const separatorLineEnd = ( text: string, tokens: Token[], index: number, dialect: DialectSpec ) => { const token = tokens[index] const previous = tokens[index - 1] const rest = token && separatorRest(token, dialect) if (!(token && rest)) { return null } const lineEnd = text.indexOf('\n', token.end) + 1 || text.length const startsLine = previous === undefined || text.slice(previous.end, token.start).includes('\n') return startsLine && rest.test(text.slice(token.end, lineEnd)) ? lineEnd : null } const upperTexts = (tokens: Token[]) => tokens.map((token) => token.text.toUpperCase()) const PLSQL_UNITS = new Set([ 'FUNCTION', 'PACKAGE', 'PROCEDURE', 'TRIGGER', 'TYPE', ]) const CREATE_MODIFIERS = new Set([ 'OR', 'REPLACE', 'EDITIONABLE', 'NONEDITIONABLE', ]) const startsPlsqlUnit = (tokens: Token[], index: number) => { const [word, ...rest] = upperTexts(tokens.slice(index, index + 5)) if (word === 'DECLARE' || word === 'BEGIN') { return true } const unit = rest.find((next) => !CREATE_MODIFIERS.has(next)) return word === 'CREATE' && unit !== undefined && PLSQL_UNITS.has(unit) } const opensTransactionAt = (tokens: Token[], index: number) => { const [word, next, after] = upperTexts(tokens.slice(index, index + 3)) if (word === 'START') { return next === 'TRANSACTION' } if (next === 'NOT') { return word === 'BEGIN' && after === 'DEFERRABLE' } return ( word === 'BEGIN' && (next === undefined || TRANSACTION_OPENERS.has(next)) ) } const transactionCloser = (tokens: Token[]) => { const words = upperTexts(tokens.slice(0, 3)) const [word, next, after] = words if (word === 'COMMIT' || (word === 'ROLLBACK' && !words.includes('TO'))) { return word } if ( (word === 'END' || word === 'ABORT') && after === undefined && WHOLE_STATEMENT_ENDINGS.has(next) ) { return word } } const isColumnPosition = (previous: Token | undefined) => isKeyword(previous, 'SELECT') || isPunctuation(previous, ',') || isPunctuation(previous, '(') export const leavesTransactionOpen = (text: string, dialect: DialectSpec) => { const tokens = tokenize(text, dialect).tokens.filter( (token) => token.kind !== 'comment' ) return ( opensTransactionAt(tokens, 0) && !tokens.some((_, index) => transactionCloser(tokens.slice(index))) ) } export const endsTransaction = (text: string, dialect: DialectSpec) => !!transactionCloser( tokenize(text, dialect).tokens.filter((token) => token.kind !== 'comment') ) /** `END CASE` closes the CASE its END already counted. */ const blockDepthChange = (tokens: Token[], index: number) => { const token = tokens[index] const previous = tokens[index - 1] if ( (isKeyword(token, 'CASE') && !isKeyword(previous, 'END')) || (isKeyword(token, 'BEGIN') && !isColumnPosition(previous)) ) { return 1 } return isKeyword(token, 'END') && !isKeyword(tokens[index + 1], ...END_QUALIFIERS) ? -1 : 0 } export const statementsFromTokens = ( text: string, tokens: Token[], dialect: DialectSpec, { groupTransactions = true }: { groupTransactions?: boolean } = {} ): Statement[] => { const statements: Statement[] = [] const significant = tokens.filter((token) => token.kind !== 'comment') const groups = groupTransactions && dialect.transactions let current: Token[] = [] let innerStart = 0 let depth = 0 let inTransaction = false let inPlsqlUnit = false const flush = (terminator?: Token) => { const [first] = current const last = current.at(-1) if (inTransaction && !transactionCloser(current.slice(innerStart))) { statements.push( ...statementsFromTokens(text, current, dialect, { groupTransactions: false, }) ) } else if (first && last) { statements.push({ end: last.end, start: first.start, terminatorEnd: terminator?.end ?? last.end, text: text.slice(first.start, last.end), tokens: current, }) } current = [] innerStart = 0 depth = 0 inTransaction = false inPlsqlUnit = false } let separatorEnd = 0 for (const [index, token] of significant.entries()) { if (token.start < separatorEnd) { continue } const lineEnd = separatorLineEnd(text, significant, index, dialect) if (lineEnd) { separatorEnd = lineEnd flush() continue } if ( dialect.plsqlUnits && current.length === 0 && startsPlsqlUnit(significant, index) ) { inPlsqlUnit = true } else if (opensTransactionAt(significant, index)) { inTransaction = groups } else { depth = Math.max(0, depth + blockDepthChange(significant, index)) } if (isPunctuation(token, ';') && depth === 0 && !inPlsqlUnit) { if (!inTransaction || transactionCloser(current.slice(innerStart))) { flush(token) continue } innerStart = current.length + 1 } current.push(token) } flush() return statements } export const splitStatements = ( text: string, dialect: DialectSpec, options?: { groupTransactions?: boolean } ) => statementsFromTokens(text, tokenize(text, dialect).tokens, dialect, options) export const unwrapTransaction = (text: string, dialect: DialectSpec) => { if (!dialect.transactions) { return null } const parts = splitStatements(text, dialect, { groupTransactions: false }) const [first] = parts const ending = transactionCloser(parts.at(-1)?.tokens ?? []) if (!(first && ending && opensTransactionAt(first.tokens, 0))) { return null } const opener = first.text.toLowerCase().replaceAll(/\s+/gu, ' ') return { accessMode: ACCESS_MODES.find((mode) => opener.includes(mode)), commit: ending === 'COMMIT' || ending === 'END', isolationLevel: ISOLATION_LEVELS.find((level) => opener.includes(`isolation level ${level}`) ), statements: parts.slice(1, -1).map((part) => part.text), } } export const statementAt = ( statements: Statement[], offset: number, text: string ) => statements.findLast( ({ start, terminatorEnd }) => offset >= start && (offset <= terminatorEnd || !text.slice(terminatorEnd, offset).includes('\n')) )