Files
orca/config/scripts/mobile-localization-value-flow.mjs
T

639 lines
20 KiB
JavaScript

// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
import ts from 'typescript-api'
import { destructuredLocalizationTargets } from './mobile-localization-destructuring-targets.mjs'
const ASSIGNMENT_KINDS = new Set([
ts.SyntaxKind.EqualsToken,
ts.SyntaxKind.AmpersandAmpersandEqualsToken,
ts.SyntaxKind.BarBarEqualsToken,
ts.SyntaxKind.QuestionQuestionEqualsToken
])
const LOGICAL_ASSIGNMENT_KINDS = new Set([
ts.SyntaxKind.AmpersandAmpersandEqualsToken,
ts.SyntaxKind.BarBarEqualsToken,
ts.SyntaxKind.QuestionQuestionEqualsToken
])
function unwrapExpression(node) {
if (
ts.isParenthesizedExpression(node) ||
ts.isAsExpression(node) ||
ts.isSatisfiesExpression(node) ||
ts.isNonNullExpression(node)
) {
return unwrapExpression(node.expression)
}
return node
}
function propertyNameText(name) {
if (ts.isIdentifier(name) || ts.isStringLiteralLike(name) || ts.isNumericLiteral(name)) {
return name.text
}
if (ts.isComputedPropertyName(name) && ts.isStringLiteralLike(name.expression)) {
return name.expression.text
}
return undefined
}
function memberParts(node) {
const expression = unwrapExpression(node)
if (ts.isPropertyAccessExpression(expression)) {
return { object: expression.expression, propertyName: expression.name.text }
}
if (
ts.isElementAccessExpression(expression) &&
expression.argumentExpression &&
ts.isStringLiteralLike(expression.argumentExpression)
) {
return { object: expression.expression, propertyName: expression.argumentExpression.text }
}
return undefined
}
function statementContainer(node) {
let current = node
while (current.parent) {
if (
ts.isSourceFile(current.parent) ||
ts.isBlock(current.parent) ||
ts.isModuleBlock(current.parent) ||
ts.isCaseBlock(current.parent) ||
ts.isClassStaticBlockDeclaration(current.parent)
) {
return { container: current.parent, statement: current }
}
current = current.parent
}
return undefined
}
function isConstBinding(binding) {
const declaration = ts.isBindingElement(binding) ? binding.parent.parent : binding
const declarationList = ts.isVariableDeclaration(declaration) ? declaration.parent : undefined
return Boolean(
declarationList &&
ts.isVariableDeclarationList(declarationList) &&
(declarationList.flags & ts.NodeFlags.Const) !== 0
)
}
function isOrderedBefore(write, use) {
const writeStatement = statementContainer(write.node)
const useStatement = statementContainer(use)
return Boolean(
writeStatement &&
useStatement &&
writeStatement.container === useStatement.container &&
writeStatement.statement.end <= useStatement.statement.pos
)
}
function isDefiniteBefore(write, use) {
return !write.conditional && isOrderedBefore(write, use)
}
function functionOwner(node) {
let current = node.parent
while (current && !ts.isSourceFile(current)) {
if (ts.isFunctionLike(current)) {
return current
}
current = current.parent
}
return undefined
}
function loopOwner(node) {
let current = node.parent
while (current && !ts.isFunctionLike(current) && !ts.isSourceFile(current)) {
if (
ts.isForStatement(current) ||
ts.isForInStatement(current) ||
ts.isForOfStatement(current) ||
ts.isWhileStatement(current) ||
ts.isDoStatement(current)
) {
return current
}
current = current.parent
}
return undefined
}
function canReachUse(write, use, sourceFile, scopeOwners = new Set()) {
if (write.position < use.getStart(sourceFile)) {
return true
}
const useOwner = functionOwner(use)
if (!useOwner || functionOwner(write.node) !== useOwner) {
return Boolean(useOwner)
}
if (loopOwner(write.node) && loopOwner(write.node) === loopOwner(use)) {
return true
}
return [...scopeOwners].some((owner) => owner !== useOwner)
}
export function createMobileLocalizationValueFlow(sourceFile, bindings) {
const writes = new Map()
const propertyWrites = new Map()
const pendingPropertyWrites = []
function addWrite(identifier, expression, node, propertyPath, operator, defaultExpression) {
const binding = bindings.resolveBinding(identifier)
if (!binding) {
return
}
const entries = writes.get(binding) ?? []
entries.push({
conditional: LOGICAL_ASSIGNMENT_KINDS.has(operator),
expression,
node,
operator,
position: node.getStart(sourceFile),
propertyPath,
defaultExpression
})
writes.set(binding, entries)
}
function addTargetWrites(name, expression, node, operator) {
const target = unwrapExpression(name)
if (ts.isIdentifier(target)) {
addWrite(target, expression, node, undefined, operator)
return
}
for (const entry of destructuredLocalizationTargets(target)) {
addWrite(
entry.identifier,
expression,
node,
entry.propertyPath,
operator,
entry.defaultExpression
)
}
}
function memberWriteTarget(node) {
const member = memberParts(node)
return member ? { ...member, target: unwrapExpression(node) } : undefined
}
function visit(node) {
if (ts.isVariableDeclaration(node) && node.initializer) {
addTargetWrites(node.name, node.initializer, node)
} else if (ts.isBinaryExpression(node) && ASSIGNMENT_KINDS.has(node.operatorToken.kind)) {
const memberTarget = memberWriteTarget(node.left)
if (memberTarget) {
pendingPropertyWrites.push({
...memberTarget,
conditional: LOGICAL_ASSIGNMENT_KINDS.has(node.operatorToken.kind),
expression: node.right,
node,
operator: node.operatorToken.kind,
position: node.getStart(sourceFile)
})
} else {
addTargetWrites(node.left, node.right, node, node.operatorToken.kind)
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
for (const entries of writes.values()) {
entries.sort((left, right) => left.position - right.position)
}
function staticValueState(node) {
const expression = unwrapExpression(node)
if (
expression.kind === ts.SyntaxKind.NullKeyword ||
ts.isVoidExpression(expression) ||
(ts.isIdentifier(expression) &&
expression.text === 'undefined' &&
!bindings.resolveBinding(expression))
) {
return { nullish: true, truthy: false }
}
if (expression.kind === ts.SyntaxKind.TrueKeyword) {
return { nullish: false, truthy: true }
}
if (expression.kind === ts.SyntaxKind.FalseKeyword) {
return { nullish: false, truthy: false }
}
if (ts.isStringLiteralLike(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) {
return { nullish: false, truthy: expression.text.length > 0 }
}
if (ts.isNumericLiteral(expression)) {
return { nullish: false, truthy: Number(expression.text) !== 0 }
}
if (ts.isBigIntLiteral(expression)) {
return { nullish: false, truthy: expression.text !== '0n' }
}
if (
ts.isArrowFunction(expression) ||
ts.isFunctionExpression(expression) ||
ts.isClassExpression(expression) ||
ts.isObjectLiteralExpression(expression) ||
ts.isArrayLiteralExpression(expression) ||
ts.isNewExpression(expression)
) {
return { nullish: false, truthy: true }
}
if (ts.isIdentifier(expression)) {
const binding = bindings.resolveBinding(expression)
if (binding && bindings.rootDescriptors?.has(binding)) {
return { nullish: false, truthy: true }
}
}
return { nullish: undefined, truthy: undefined }
}
function logicalWriteAction(operator, expressions, knownEmpty = false) {
const values = expressions.length > 0 ? expressions : knownEmpty ? [undefined] : []
const actions = new Set()
for (const expression of values) {
const state =
expression === undefined ? { nullish: true, truthy: false } : staticValueState(expression)
const applies =
operator === ts.SyntaxKind.QuestionQuestionEqualsToken
? state.nullish
: operator === ts.SyntaxKind.BarBarEqualsToken
? state.truthy === undefined
? undefined
: !state.truthy
: state.truthy
actions.add(applies === true ? 'assign' : applies === false ? 'keep' : 'unknown')
}
return actions.size === 1 ? [...actions][0] : 'unknown'
}
function isUninitializedBinding(binding) {
return ts.isVariableDeclaration(binding) && binding.initializer === undefined
}
function selectedWrites(identifier, use, all) {
const binding = bindings.resolveBinding(identifier)
const entries = binding ? (writes.get(binding) ?? []) : []
if (all || (binding && isConstBinding(binding))) {
return entries
}
const available = entries.filter((entry) =>
canReachUse(entry, use, sourceFile, new Set([functionOwner(binding)]))
)
let selected = []
for (const entry of available) {
const action = entry.conditional
? logicalWriteAction(
entry.operator,
selected.flatMap((write) => (write.propertyPath ? [] : [write.expression])),
selected.length === 0 && isUninitializedBinding(binding)
)
: 'assign'
if (!isOrderedBefore(entry, use)) {
if (action !== 'keep') {
selected.push(entry)
}
continue
}
if (!entry.conditional) {
selected = [entry]
continue
}
if (action === 'assign') {
selected = [entry]
} else if (action === 'unknown') {
selected.push(entry)
}
}
return selected
}
function locationKey(location) {
return `${location.binding.pos}:${location.path.join('\0')}`
}
function objectLocations(node, use = node, seen = new Set(), all = false) {
const expression = unwrapExpression(node)
if (ts.isIdentifier(expression)) {
const binding = bindings.resolveBinding(expression)
if (!binding) {
return []
}
const direct = [{ binding, path: [] }]
if (seen.has(binding)) {
return direct
}
const nextSeen = new Set(seen).add(binding)
const sources = selectedWrites(expression, use, all)
const resolved = sources.flatMap((source) => {
const locations = objectLocations(source.expression, source.node, nextSeen, all)
return source.propertyPath
? locations.map((location) => ({
binding: location.binding,
path: [...location.path, ...source.propertyPath]
}))
: locations
})
const locations = resolved.length > 0 ? resolved : direct
return [...new Map(locations.map((location) => [locationKey(location), location])).values()]
}
if (ts.isObjectLiteralExpression(expression) || ts.isArrayLiteralExpression(expression)) {
return [{ binding: expression, path: [] }]
}
const member = memberParts(expression)
if (!member) {
return []
}
return objectLocations(member.object, use, seen, all).map((location) => ({
binding: location.binding,
path: [...location.path, member.propertyName]
}))
}
function indexedPropertyWrites(location) {
const byPath = propertyWrites.get(location.binding) ?? new Map()
propertyWrites.set(location.binding, byPath)
const key = location.path.join('\0')
const entries = byPath.get(key) ?? []
byPath.set(key, entries)
return entries
}
for (const entry of pendingPropertyWrites) {
const targetLocations = objectLocations(entry.object, entry.node).map((location) => ({
binding: location.binding,
path: [...location.path, entry.propertyName]
}))
const rightMember = memberParts(entry.expression)
const rightLocations = rightMember
? objectLocations(rightMember.object, entry.node).map((location) => ({
binding: location.binding,
path: [...location.path, rightMember.propertyName]
}))
: []
const rightKeys = new Set(rightLocations.map(locationKey))
const selfAssignment =
targetLocations.length === 1 &&
rightLocations.length === 1 &&
rightKeys.has(locationKey(targetLocations[0]))
const indexed = {
...entry,
scopeOwners: new Set(targetLocations.map((location) => functionOwner(location.binding))),
selfAssignment
}
for (const location of targetLocations) {
indexedPropertyWrites(location).push(indexed)
}
}
for (const byPath of propertyWrites.values()) {
for (const entries of byPath.values()) {
entries.sort((left, right) => left.position - right.position)
}
}
function propertyWriteEntries(node, propertyName, use, all) {
const entries = objectLocations(node, use, new Set(), all).flatMap((location) => {
const key = [...location.path, propertyName].join('\0')
return propertyWrites.get(location.binding)?.get(key) ?? []
})
return [
...new Set(
entries.filter(
(entry) =>
!entry.selfAssignment && (all || canReachUse(entry, use, sourceFile, entry.scopeOwners))
)
)
].sort((left, right) => left.position - right.position)
}
function applyPropertyWrites(resolved, known, node, propertyName, use, all) {
for (const entry of propertyWriteEntries(node, propertyName, use, all)) {
if (all) {
resolved.push(entry.expression)
continue
}
const action = entry.conditional
? logicalWriteAction(entry.operator, resolved, known && resolved.length === 0)
: 'assign'
if (!isOrderedBefore(entry, use)) {
if (action !== 'keep') {
resolved.push(entry.expression)
}
continue
}
if (action === 'assign') {
resolved.splice(0, resolved.length, entry.expression)
known = true
} else if (action === 'unknown') {
resolved.push(entry.expression)
}
}
return known
}
function propertyPathValues(node, propertyPath, use = node, seen = new Set(), all = false) {
let sources = [{ expression: node, use }]
let known = true
for (const propertyName of propertyPath) {
const next = []
for (const source of sources) {
const values = propertyValues(
source.expression,
propertyName,
source.use,
new Set(seen),
all
)
if (values === undefined) {
known = false
continue
}
next.push(...values.map((expression) => ({ expression, use: expression })))
}
sources = next
}
const values = sources.map((source) => source.expression)
return known || (all && values.length > 0) ? values : undefined
}
function propertyValues(node, propertyName, use = node, seen = new Set(), all = false) {
const expression = unwrapExpression(node)
if (ts.isIdentifier(expression)) {
const binding = bindings.resolveBinding(expression)
if (!binding || seen.has(binding)) {
return undefined
}
const nextSeen = new Set(seen).add(binding)
const sources = valueSources(expression, use, all)
const resolved = []
let known = true
for (const source of sources) {
const values = source.propertyPath
? propertyPathValues(source.expression, source.propertyPath, source.node, nextSeen, all)
: [source.expression]
if (values === undefined) {
known = false
continue
}
for (const value of values) {
const matches = propertyValues(value, propertyName, source.node, nextSeen, all)
if (matches === undefined) {
known = false
} else {
resolved.push(...matches)
}
}
}
known = applyPropertyWrites(resolved, known, expression, propertyName, use, all)
return known || (all && resolved.length > 0) ? resolved : undefined
}
const member = memberParts(expression)
if (member) {
const objectValues = propertyValues(
member.object,
member.propertyName,
use,
new Set(seen),
all
)
const resolved = []
let known = objectValues !== undefined
for (const value of objectValues ?? []) {
const matches = propertyValues(value, propertyName, value, new Set(seen), all)
if (matches === undefined) {
known = false
} else {
resolved.push(...matches)
}
}
known = applyPropertyWrites(resolved, known, expression, propertyName, use, all)
return known || (all && resolved.length > 0) ? resolved : undefined
}
if (ts.isArrayLiteralExpression(expression)) {
const index = Number(propertyName)
if (!Number.isInteger(index) || index < 0 || index >= expression.elements.length) {
return []
}
const element = expression.elements[index]
return ts.isOmittedExpression(element) || ts.isSpreadElement(element) ? [] : [element]
}
if (!ts.isObjectLiteralExpression(expression)) {
return undefined
}
for (const property of expression.properties.toReversed()) {
if (ts.isSpreadAssignment(property)) {
const spreadValues = propertyValues(
property.expression,
propertyName,
property.expression,
new Set(seen),
all
)
if (spreadValues === undefined || spreadValues.length > 0) {
return spreadValues
}
continue
}
const name = propertyNameText(property.name)
if (name === undefined) {
return undefined
}
if (name !== propertyName) {
continue
}
if (ts.isPropertyAssignment(property)) {
return [property.initializer]
}
if (ts.isShorthandPropertyAssignment(property)) {
return [property.name]
}
if (ts.isMethodDeclaration(property) || ts.isGetAccessorDeclaration(property)) {
return [property]
}
return undefined
}
return []
}
function materializeWrites(entries, use, seen) {
const values = []
for (const entry of entries) {
if (!entry.propertyPath) {
values.push(entry.expression)
continue
}
const matches = propertyPathValues(
entry.expression,
entry.propertyPath,
entry.node,
new Set(seen)
)
if (matches === undefined) {
return undefined
}
values.push(
...(matches.length === 0 && entry.defaultExpression ? [entry.defaultExpression] : matches)
)
}
return values
}
function valueExpressions(identifier, use = identifier, seen = new Set()) {
return materializeWrites(selectedWrites(identifier, use, false), use, seen)
}
function allValueExpressions(identifier, use = identifier, seen = new Set()) {
return materializeWrites(selectedWrites(identifier, use, true), use, seen)
}
function valueSources(identifier, use = identifier, all = false) {
return selectedWrites(identifier, use, all)
}
function stableBindingWrite(binding) {
let source
for (const entry of writes.get(binding) ?? []) {
if (!entry.conditional) {
if (source) {
return undefined
}
source = entry
continue
}
if (
!source ||
source.propertyPath ||
logicalWriteAction(entry.operator, [source.expression]) !== 'keep'
) {
return undefined
}
}
return source
}
function bindingWrites() {
return writes.entries()
}
function writeReachesUse(write, use) {
return isDefiniteBefore(write, use)
}
return {
allValueExpressions,
bindingWrites,
isImmutableBinding: isConstBinding,
propertyPathValues,
propertyValues,
stableBindingWrite,
unwrapExpression,
valueExpressions,
valueSources,
writeReachesUse
}
}