diff --git a/CHANGELOG.md b/CHANGELOG.md index 96a40cfc..72c171fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Token-Goat are documented in this file. Format follows Ke ## [Unreleased] +### Added + +- **Layered Salesforce DX indexing for metadata and frontend bundles.** Every `*-meta.xml` file now receives a stable top-level symbol, while Apex, Flow, object-member metadata, Custom Labels, LWC, Aura, and Visualforce receive detailed symbols and selected cross-file references. LWC indexing understands public `@api` members, Salesforce imports, template handlers/child components, bundle targets, and target properties; Aura/Visualforce markup now has a dedicated `salesforce_markup` adapter. The real default worker path and built bundle are covered by a Salesforce DX fixture so these adapters cannot silently disappear from the shipped artifact. + ### Fixed - **macOS `/var` and `/private/var` aliases could produce different cache and index keys for the same file.** Path canonicalization now normalizes the Darwin system alias consistently, restoring relative `skeleton`/`outline`, trace frame detection, project discovery, settings paths, and doc-compact reuse for temporary paths. diff --git a/src/hooks_bash.ts b/src/hooks_bash.ts index c5359769..e1b8afcf 100644 --- a/src/hooks_bash.ts +++ b/src/hooks_bash.ts @@ -439,7 +439,7 @@ function extractSedRange(cmd: string): { filePath: string; ranges: Array = new Set([ - 'python', 'typescript', 'javascript', 'rust', 'go', 'c', 'cpp', 'ruby', 'java', 'csharp', 'php', 'kotlin', 'sql', 'graphql', 'proto', 'bash', 'powershell', + 'python', 'typescript', 'javascript', 'rust', 'go', 'c', 'cpp', 'ruby', 'java', 'csharp', 'php', 'kotlin', 'sql', 'graphql', 'proto', 'bash', 'powershell', 'apex', 'salesforce_metadata', 'salesforce_markup', ]) // Builds the recall hint for a `sed -n 'N,Mp' file` read, tailored to the file's language: Markdown -> section by heading; structured config -> config-get/section; source code -> symbol read (robust to line shifts); everything else -> the exact line range. diff --git a/src/hooks_read.ts b/src/hooks_read.ts index d1d74a2f..ccb42ef4 100644 --- a/src/hooks_read.ts +++ b/src/hooks_read.ts @@ -43,6 +43,7 @@ import { isImagePath } from './image_shrink.js' import { compactPathFor, isCompactFresh, readCompactBody } from './doc_compact.js' import { getOrCreateSidecar, NB_STRIP_MIN_SAVINGS } from './notebook_compact.js' import { dataDir } from './constants.js' +import { detectLanguage } from './parser_types.js' /** True when `basename` is a tsconfig or jsconfig file. */ function isTsConfigFile(basename: string): boolean { @@ -286,7 +287,9 @@ const SOURCE_EXT_RE = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|py|pyi|go|rs|java|rb|php|kt|kts|cpp|cc|cxx|hpp|hxx|c|h|cs)$/i function isSourceExtension(basename: string): boolean { - return SOURCE_EXT_RE.test(basename) + if (SOURCE_EXT_RE.test(basename)) return true + const language = detectLanguage(basename) + return language === 'apex' || language === 'salesforce_metadata' || language === 'salesforce_markup' } // Extensions dispatchFileTypeHandler() (hints/file_type_handler.ts) recognizes and gives diff --git a/src/languages/common.ts b/src/languages/common.ts index f0760896..9296ef3c 100644 --- a/src/languages/common.ts +++ b/src/languages/common.ts @@ -101,6 +101,74 @@ export function stripCstyleComments( return out } +/** + * Strip XML/HTML ```` block comments. Comment content is blanked with spaces (not + * removed), and newlines inside a multi-line comment are preserved as-is, so line/column offsets + * are unaffected downstream — mirrors `stripCstyleComments`'s span-blanking approach for `/* ... *\/` + * comments, just with the `` delimiters instead of `/*`/`*\/`. + */ +export function stripXmlComments(text: string): string { + const lines = text.split('\n') + let inComment = false + const outLines: string[] = [] + for (const line of lines) { + let result = '' + let j = 0 + while (j < line.length) { + if (!inComment) { + const open = line.indexOf('', open + 4) + if (close === -1) { + result += ' '.repeat(line.length - open) + inComment = true + break + } + result += ' '.repeat(close + 3 - open) + j = close + 3 + inComment = false + } else { + const close = line.indexOf('-->', j) + if (close === -1) { + result += ' '.repeat(line.length - j) + break + } + result += ' '.repeat(close + 3 - j) + j = close + 3 + inComment = false + } + } + outLines.push(result) + } + return outLines.join('\n') +} + +/** + * Strip `//` line comments, quote-aware (like `stripSqlLineComments`'s `--` handling below) so a + * `//` inside an open string literal (e.g. a URL like `'https://example.com'`) is not mistaken for + * a real comment starter. Blank-fills (rather than deletes) the comment span so line/column offsets + * are preserved for downstream line-based symbol extraction. Unlike `stripCstyleComments`'s + * `lineCommentRe` parameter, this is quote-aware on its own and does not require callers to blank + * string literals first — needed by callers (like the Salesforce LWC JS adapter) that still need + * string-literal content intact after stripping comments, e.g. to read an import path. + */ +export function stripSlashLineComments(text: string): string { + return text + .split('\n') + .map((line) => { + let idx = line.indexOf('//') + while (idx !== -1 && isInsideStringLiteral(line, idx)) { + idx = line.indexOf('//', idx + 1) + } + return idx === -1 ? line : line.slice(0, idx) + ' '.repeat(line.length - idx) + }) + .join('\n') +} + /** * Strip GraphQL / shell / Python style ``# …`` line comments. Quote-aware: a `#` inside an * open single- or double-quoted string literal on the same line is not treated as a comment diff --git a/src/languages/index.ts b/src/languages/index.ts index 16591742..c1de3696 100644 --- a/src/languages/index.ts +++ b/src/languages/index.ts @@ -22,3 +22,4 @@ export * from './powershell_idx.js' export * from './env_idx.js' export * from './apex.js' export * from './salesforce_metadata.js' +export * from './salesforce_frontend.js' diff --git a/src/languages/salesforce_frontend.ts b/src/languages/salesforce_frontend.ts new file mode 100644 index 00000000..483d78ea --- /dev/null +++ b/src/languages/salesforce_frontend.ts @@ -0,0 +1,220 @@ +import * as path from 'node:path' + +import type { RefEntry, SymbolEntry } from '../parser_types.js' +import { stripCstyleComments, stripSlashLineComments, stripXmlComments } from './common.js' + +export interface SalesforceFrontendResult { + readonly symbols: SymbolEntry[] + readonly refs: RefEntry[] +} + +function lines(content: string): string[] { + return content.split('\n') +} + +function bundleName(filePath: string): string { + const normalized = filePath.replaceAll('\\', '/') + const parent = path.posix.basename(path.posix.dirname(normalized)) + const base = path.posix.basename(normalized).replace(/\.[^.]+$/, '') + return parent === 'lwc' || parent === 'aura' ? base : parent +} + +function lwcTagAlias(name: string): string { + const kebab = name + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') + .replace(/([a-z\d])([A-Z])/g, '$1-$2') + .toLowerCase() + return `c-${kebab}` +} + +function symbol(filePath: string, name: string, kind: string, lineStart: number, lineEnd = lineStart): SymbolEntry { + return { filePath, name, kind, lineStart, lineEnd, body: '', docstring: '' } +} + +function ref(filePath: string, name: string, line: number, col: number, context: string): RefEntry { + return { filePath, name, line, col, context } +} + +function dedupe(values: T[], key: (value: T) => string): T[] { + const seen = new Set() + return values.filter((value) => { + const id = key(value) + if (seen.has(id)) return false + seen.add(id) + return true + }) +} + +export function extractLwcJavaScript(content: string, filePath: string): SalesforceFrontendResult { + const sourceLines = lines(content) + const bundle = bundleName(filePath) + const symbols: SymbolEntry[] = [ + symbol(filePath, bundle, 'lwc_bundle', 1, sourceLines.length), + symbol(filePath, lwcTagAlias(bundle), 'lwc_component_alias', 1, sourceLines.length), + ] + const refs: RefEntry[] = [] + + // Blank comments (block first, then quote-aware `//`) so commented-out `@api` declarations and Salesforce imports aren't indexed as live code; string-literal content is untouched. + const commentFree = stripSlashLineComments(stripCstyleComments(content)) + + const apiRe = /@api\s*(?:\r?\n\s*)?(?:(get|set)\s+)?(?:async\s+)?([A-Za-z_$][\w$]*)\s*(\()?/g + for (const match of commentFree.matchAll(apiRe)) { + const before = commentFree.slice(0, match.index ?? 0) + const line = before.split('\n').length + const kind = match[3] && !match[1] ? 'lwc_api_method' : 'lwc_api_property' + symbols.push(symbol(filePath, match[2] ?? '', kind, line)) + } + + const importRe = /from\s+['"]@salesforce\/(apex|schema|label|resourceUrl|messageChannel|customPermission|userPermission)\/([^'"]+)['"]/g + for (const match of commentFree.matchAll(importRe)) { + const offset = match.index ?? 0 + const line = commentFree.slice(0, offset).split('\n').length + const context = sourceLines[line - 1]?.trim() ?? '' + const target = match[2] ?? '' + const targetOffset = offset + (match[0]?.indexOf(target) ?? 0) + const col = targetOffset - (commentFree.lastIndexOf('\n', targetOffset) + 1) + if (match[1] === 'apex') { + const className = target.split('.')[0] ?? target + refs.push(ref(filePath, className, line, col, context)) + } + refs.push(ref(filePath, target, line, col, context)) + } + + return { + symbols: dedupe(symbols, (entry) => `${entry.name}\0${entry.kind}\0${entry.lineStart}`), + refs: dedupe(refs, (entry) => `${entry.filePath}\0${entry.name}\0${entry.line}\0${entry.col}`), + } +} + +function matchLine(content: string, offset: number): number { + return content.slice(0, offset).split('\n').length +} + +function lineContext(content: string, line: number): string { + return lines(content)[line - 1]?.trim() ?? '' +} + +export function extractLwcTemplate(content: string, filePath: string): SalesforceFrontendResult { + const symbols: SymbolEntry[] = [] + const refs: RefEntry[] = [] + + for (const match of content.matchAll(/\blwc:ref\s*=\s*["']([^"']+)["']/gi)) { + const line = matchLine(content, match.index ?? 0) + symbols.push(symbol(filePath, match[1] ?? '', 'lwc_ref', line)) + } + for (const match of content.matchAll(/\bid\s*=\s*["']([^"'{}:]+)["']/gi)) { + const line = matchLine(content, match.index ?? 0) + symbols.push(symbol(filePath, match[1] ?? '', 'lwc_id', line)) + } + // Blank `` spans before matching event-handler bindings so a commented-out element (e.g. ``) isn't indexed as a live ref. + const markupNoComments = stripXmlComments(content) + for (const match of markupNoComments.matchAll(/\bon[a-z][\w-]*\s*=\s*\{\s*([A-Za-z_$][\w$]*)\s*\}/gi)) { + const offset = match.index ?? 0 + const line = matchLine(markupNoComments, offset) + refs.push(ref(filePath, match[1] ?? '', line, 0, lineContext(content, line))) + } + for (const match of content.matchAll(/<\s*(c-[a-z][\w-]*)\b/gi)) { + const offset = match.index ?? 0 + const line = matchLine(content, offset) + refs.push(ref(filePath, (match[1] ?? '').toLowerCase(), line, 0, lineContext(content, line))) + } + + return { + symbols: dedupe(symbols, (entry) => `${entry.name}\0${entry.kind}\0${entry.lineStart}`), + refs: dedupe(refs, (entry) => `${entry.filePath}\0${entry.name}\0${entry.line}\0${entry.col}`), + } +} + +export const SALESFORCE_MARKUP_EXTENSIONS = new Set([ + '.cmp', '.app', '.evt', '.intf', '.design', '.auradoc', '.tokens', '.page', '.component', '.email', +]) + +const MARKUP_KIND: Readonly> = { + '.cmp': 'aura_bundle', + '.app': 'aura_application', + '.evt': 'aura_event_bundle', + '.intf': 'aura_interface', + '.design': 'aura_design', + '.auradoc': 'aura_documentation', + '.tokens': 'aura_tokens', + '.page': 'visualforce_page', + '.component': 'visualforce_component', + '.email': 'visualforce_email_template', +} + +function markupArtifactName(filePath: string, extension: string): string { + if (extension === '.page' || extension === '.component' || extension === '.email') { + return path.posix.basename(filePath.replaceAll('\\', '/')).replace(new RegExp(`${extension.replace('.', '\\.')}$`, 'i'), '') + } + return bundleName(filePath) +} + +function addAttributeSymbols( + symbols: SymbolEntry[], + content: string, + filePath: string, + tag: string, + kind: string, +): void { + const tagRe = new RegExp(`<\\s*${tag}\\b[^>]*\\bname\\s*=\\s*["']([^"']+)["'][^>]*>`, 'gi') + for (const match of content.matchAll(tagRe)) { + symbols.push(symbol(filePath, match[1] ?? '', kind, matchLine(content, match.index ?? 0))) + } +} + +function attributeRefs( + refs: RefEntry[], + content: string, + filePath: string, + attribute: string, + split = false, +): void { + const attributeRe = new RegExp(`\\b${attribute}\\s*=\\s*["']([^"']+)["']`, 'gi') + for (const match of content.matchAll(attributeRe)) { + const line = matchLine(content, match.index ?? 0) + const values = split ? (match[1] ?? '').split(',').map((value) => value.trim()).filter(Boolean) : [match[1] ?? ''] + for (const value of values) refs.push(ref(filePath, value, line, 0, lineContext(content, line))) + } +} + +export function extractSalesforceMarkup(content: string, filePath: string): SalesforceFrontendResult { + const normalized = filePath.replaceAll('\\', '/') + const extension = path.posix.extname(normalized).toLowerCase() + const sourceLines = lines(content) + const kind = MARKUP_KIND[extension] ?? 'salesforce_markup' + const symbols: SymbolEntry[] = [ + symbol(filePath, markupArtifactName(normalized, extension), kind, 1, sourceLines.length), + ] + const refs: RefEntry[] = [] + const isAura = ['.cmp', '.app', '.evt', '.intf', '.design', '.auradoc', '.tokens'].includes(extension) + + if (isAura) { + addAttributeSymbols(symbols, content, filePath, 'aura:attribute', 'aura_attribute') + addAttributeSymbols(symbols, content, filePath, 'aura:handler', 'aura_handler') + addAttributeSymbols(symbols, content, filePath, 'aura:registerEvent', 'aura_event') + addAttributeSymbols(symbols, content, filePath, 'design:attribute', 'aura_design_attribute') + } + + attributeRefs(refs, content, filePath, 'controller') + attributeRefs(refs, content, filePath, 'extensions', true) + + // Blank `` spans before matching `{!c.action}` / `action="{!...}"` bindings so a commented-out element isn't indexed as a live controller-action ref. + const markupNoComments = stripXmlComments(content) + const actionRe = isAura + ? /\{!\s*c\.([A-Za-z_$][\w$]*)[^}]*\}/gi + : /\baction\s*=\s*["']\{!\s*(?:c\.)?([A-Za-z_$][\w$]*)[^}]*\}["']/gi + for (const match of markupNoComments.matchAll(actionRe)) { + const line = matchLine(markupNoComments, match.index ?? 0) + refs.push(ref(filePath, match[1] ?? '', line, 0, lineContext(content, line))) + } + + for (const match of content.matchAll(/\bc:[A-Za-z_$][\w$]*/g)) { + const line = matchLine(content, match.index ?? 0) + refs.push(ref(filePath, match[0], line, 0, lineContext(content, line))) + } + + return { + symbols: dedupe(symbols, (entry) => `${entry.name}\0${entry.kind}\0${entry.lineStart}`), + refs: dedupe(refs, (entry) => `${entry.filePath}\0${entry.name}\0${entry.line}\0${entry.col}`), + } +} diff --git a/src/languages/salesforce_metadata.ts b/src/languages/salesforce_metadata.ts index 7d97ac4f..fe073060 100644 --- a/src/languages/salesforce_metadata.ts +++ b/src/languages/salesforce_metadata.ts @@ -1,9 +1,10 @@ import * as path from 'node:path' -import type { SymbolEntry } from '../parser_types.js' -import { buildLineIndex, offsetToLine } from './common.js' +import type { RefEntry, SymbolEntry } from '../parser_types.js' +import { buildLineIndex, offsetToLine, stripXmlComments } from './common.js' const MAX_SYMBOLS = 1000 +const MAX_REFS = 1000 const FLOW_TAG_KIND: Readonly> = { actionCalls: 'sf_flow_action', @@ -33,7 +34,10 @@ interface Span { } function xmlText(content: string, tag: string): string | null { - const re = new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)`, 'i') + const re = new RegExp( + `<(?:[A-Za-z_][\\w.-]*:)?${tag}(?:\\s[^>]*)?>([\\s\\S]*?)`, + 'i', + ) const match = re.exec(content) if (match?.[1] === undefined) return null return decodeXml(match[1].trim()) @@ -109,8 +113,94 @@ function makeSymbol( } function rootElement(content: string): string | null { - const match = /<([A-Za-z][A-Za-z0-9_]*)\b/.exec(content) - return match?.[1] ?? null + const match = /<(?!\?|!)(?:[A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)\b[^>]*>/.exec(content) + if (match === null) return null + const root = match[1] + if (root === undefined) return null + // A self-closing root (e.g. ``) has no separate close tag to find. + if (match[0].endsWith('/>')) return root + const close = new RegExp(``, 'i') + return close.test(content) ? root : null +} + +function snakeCase(value: string): string { + return value + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2') + .replace(/[^A-Za-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .toLowerCase() +} + +function companionName(filePath: string): string | null { + const base = path.basename(filePath) + const match = /^(.+)\.(?:cls|trigger|page|component|cmp|app|evt|intf|design|auradoc|tokens|js)-meta\.xml$/i.exec(base) + return match?.[1] === undefined ? null : `${match[1]}.metadata` +} + +function metadataArtifactName(filePath: string): string { + const base = path.basename(filePath) + const match = /^(.+)\.[^.]+-meta\.xml$/i.exec(base) + return match?.[1] ?? basenameWithout(filePath, '-meta.xml') +} + +function elementBlocks(content: string, tag: string): Array<{ inner: string; offset: number; text: string }> { + const re = new RegExp( + `<(?:[A-Za-z_][\\w.-]*:)?${tag}(?:\\s[^>]*)?>([\\s\\S]*?)`, + 'gi', + ) + return [...content.matchAll(re)].map((match) => ({ + inner: match[1] ?? '', + offset: match.index ?? 0, + text: match[0], + })) +} + +function attributeValue(attributes: string, name: string): string | null { + const match = new RegExp(`\\b${name}\\s*=\\s*(["'])(.*?)\\1`, 'i').exec(attributes) + return match?.[2] === undefined ? null : decodeXml(match[2]) +} + +function propertyElements(content: string): Array<{ name: string; offset: number; text: string }> { + const re = + /<(?:[A-Za-z_][\w.-]*:)?property\b([^>]*?)(?:\/>|>([\s\S]*?)<\/(?:[A-Za-z_][\w.-]*:)?property\s*>)/gi + const out: Array<{ name: string; offset: number; text: string }> = [] + for (const match of content.matchAll(re)) { + const name = attributeValue(match[1] ?? '', 'name') + if (name !== null && name !== '') out.push({ name, offset: match.index ?? 0, text: match[0] }) + } + return out +} + +function makeRef(content: string, filePath: string, name: string, offset: number): RefEntry { + const before = content.slice(0, offset) + const line = before.split(/\r?\n/).length + const lineStart = Math.max(before.lastIndexOf('\n'), before.lastIndexOf('\r')) + 1 + const sourceLine = content.slice(lineStart).split(/\r?\n/, 1)[0] ?? '' + return { filePath, name, line, col: offset - lineStart, context: sourceLine.trim() } +} + +function emitRef(refs: RefEntry[], seen: Set, ref: RefEntry): void { + if (!ref.name || refs.length >= MAX_REFS) return + const key = `${ref.filePath}\0${ref.name}\0${ref.line}\0${ref.col}` + if (seen.has(key)) return + seen.add(key) + refs.push(ref) +} + +function addTagRefs( + refs: RefEntry[], + seen: Set, + content: string, + filePath: string, + tags: readonly string[], +): void { + for (const tag of tags) { + for (const block of elementBlocks(content, tag)) { + const name = decodeXml(block.inner.trim()) + if (name !== '') emitRef(refs, seen, makeRef(content, filePath, name, block.offset)) + } + } } function metadataName(filePath: string, content: string, suffix: string): string { @@ -151,19 +241,26 @@ function emit(symbols: SymbolEntry[], seen: Set, symbol: SymbolEntry): v } export function extractSalesforceMetadata( - content: string, + rawContent: string, filePath: string, -): { symbols: SymbolEntry[] } { +): { symbols: SymbolEntry[]; refs: RefEntry[] } { + // Blank `` spans up front so every regex-based extractor below scans only live XML and never mistakes commented-out metadata for the real thing; blanking (not deleting) preserves line/column offsets. + const content = stripXmlComments(rawContent) const symbols: SymbolEntry[] = [] const seen = new Set() + const refs: RefEntry[] = [] + const seenRefs = new Set() const base = path.basename(filePath).toLowerCase() const whole = wholeFileSpan(content) + const root = rootElement(content) + + if (root === null) return { symbols, refs } if (base.endsWith('.object-meta.xml')) { const name = metadataName(filePath, content, '.object-meta.xml') const isPlatformEvent = name.endsWith('__e') || xmlText(content, 'eventType') !== null emit(symbols, seen, makeSymbol(filePath, name, isPlatformEvent ? 'sf_platform_event' : 'sf_object', whole)) - return { symbols } + return { symbols, refs } } if (base.endsWith('.field-meta.xml')) { @@ -173,7 +270,7 @@ export function extractSalesforceMetadata( if (objectName !== '') { emit(symbols, seen, makeSymbol(filePath, `${objectName}.${name}`, 'sf_custom_field', whole, objectName)) } - return { symbols } + return { symbols, refs } } if (base.endsWith('.validationrule-meta.xml')) { @@ -183,38 +280,137 @@ export function extractSalesforceMetadata( if (objectName !== '') { emit(symbols, seen, makeSymbol(filePath, `${objectName}.${name}`, 'sf_validation_rule', whole, objectName)) } - return { symbols } + return { symbols, refs } } if (base.endsWith('.flow-meta.xml')) { const name = basenameWithout(filePath, '.flow-meta.xml') emit(symbols, seen, makeSymbol(filePath, name, 'sf_flow', whole)) addFlowElements(symbols, seen, content, filePath, name) - return { symbols } + addTagRefs(refs, seenRefs, content, filePath, ['actionName', 'flowName']) + for (const tag of ['recordLookups', 'recordCreates', 'recordUpdates', 'recordDeletes']) { + for (const block of elementBlocks(content, tag)) { + const objectName = xmlText(block.inner, 'object') + if (objectName === null) continue + const objectOffset = block.offset + block.text.indexOf(objectName) + emitRef(refs, seenRefs, makeRef(content, filePath, objectName, objectOffset)) + for (const field of elementBlocks(block.inner, 'field')) { + const fieldName = decodeXml(field.inner.trim()) + if (fieldName === '') continue + const offset = block.offset + block.text.indexOf(field.text) + emitRef(refs, seenRefs, makeRef(content, filePath, `${objectName}.${fieldName}`, offset)) + } + } + } + return { symbols, refs } } if (base.endsWith('.permissionset-meta.xml')) { const name = basenameWithout(filePath, '.permissionset-meta.xml') emit(symbols, seen, makeSymbol(filePath, name, 'sf_permission_set', whole)) - return { symbols } + return { symbols, refs } } if (base.endsWith('.profile-meta.xml')) { const name = basenameWithout(filePath, '.profile-meta.xml') emit(symbols, seen, makeSymbol(filePath, name, 'sf_profile', whole)) - return { symbols } + return { symbols, refs } } if (base.endsWith('.md-meta.xml')) { const name = basenameWithout(filePath, '.md-meta.xml') emit(symbols, seen, makeSymbol(filePath, name, 'sf_custom_metadata_record', whole)) - return { symbols } + return { symbols, refs } } - const root = rootElement(content) - if (root !== null) { - const name = xmlText(content, 'fullName') ?? basenameWithout(filePath, '-meta.xml') - emit(symbols, seen, makeSymbol(filePath, name, `sf_${root}`, whole)) + const objectMemberKinds: Readonly> = { + 'recordtype-meta.xml': 'sf_record_type', + 'fieldset-meta.xml': 'sf_field_set', + 'compactlayout-meta.xml': 'sf_compact_layout', + 'businessprocess-meta.xml': 'sf_business_process', + 'weblink-meta.xml': 'sf_web_link', + 'sharingreason-meta.xml': 'sf_sharing_reason', } - return { symbols } + const memberEntry = Object.entries(objectMemberKinds).find(([suffix]) => base.endsWith(suffix)) + if (memberEntry !== undefined) { + const member = xmlText(content, 'fullName') ?? basenameWithout(filePath, `.${memberEntry[0]}`) + const objectName = objectNameFromPath(filePath) + const name = objectName === null ? member : `${objectName}.${member}` + emit(symbols, seen, makeSymbol(filePath, name, memberEntry[1], whole, objectName ?? '')) + return { symbols, refs } + } + + const companion = companionName(filePath) + const name = + companion ?? + (base.endsWith('.labels-meta.xml') ? null : xmlText(content, 'fullName')) ?? + metadataArtifactName(filePath) + emit(symbols, seen, makeSymbol(filePath, name, `sf_${snakeCase(root)}`, whole)) + + if (base.endsWith('.labels-meta.xml')) { + const lineIndex = buildLineIndex(content) + for (const block of elementBlocks(content, 'labels')) { + const labelName = xmlText(block.inner, 'fullName') + if (labelName === null) continue + emit( + symbols, + seen, + makeSymbol( + filePath, + labelName, + 'sf_custom_label', + spanFromOffsets(content, lineIndex, block.offset, block.offset + block.text.length), + ), + ) + } + } + + if (base.endsWith('.js-meta.xml')) { + const lineIndex = buildLineIndex(content) + const lwcSeen = new Set() + for (const target of elementBlocks(content, 'target')) { + const targetName = decodeXml(target.inner.trim()) + const key = `target\0${targetName}` + if (targetName === '' || lwcSeen.has(key)) continue + lwcSeen.add(key) + emit( + symbols, + seen, + makeSymbol( + filePath, + targetName, + 'sf_lwc_target', + spanFromOffsets(content, lineIndex, target.offset, target.offset + target.text.length), + ), + ) + } + for (const configs of elementBlocks(content, 'targetConfigs')) { + for (const property of propertyElements(configs.inner)) { + const key = `property\0${property.name}` + if (lwcSeen.has(key)) continue + lwcSeen.add(key) + const offset = configs.offset + configs.text.indexOf(property.text) + emit( + symbols, + seen, + makeSymbol( + filePath, + property.name, + 'sf_lwc_property', + spanFromOffsets(content, lineIndex, offset, offset + property.text.length), + ), + ) + } + } + } + + if (base.endsWith('.flexipage-meta.xml')) { + addTagRefs(refs, seenRefs, content, filePath, ['sobjectType', 'componentName']) + } else if (base.endsWith('.quickaction-meta.xml')) { + addTagRefs(refs, seenRefs, content, filePath, ['targetObject', 'lightningComponent']) + } else if (base.endsWith('.messagechannel-meta.xml')) { + addTagRefs(refs, seenRefs, content, filePath, ['fieldName']) + } + + return { symbols, refs } } diff --git a/src/parser.ts b/src/parser.ts index 6bcb8648..867027b0 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -45,6 +45,11 @@ import { extractProto } from './languages/proto_idx.js' import { extractPowershell } from './languages/powershell_idx.js' import { extractApex } from './languages/apex.js' import { extractSalesforceMetadata } from './languages/salesforce_metadata.js' +import { + extractLwcJavaScript, + extractLwcTemplate, + extractSalesforceMarkup, +} from './languages/salesforce_frontend.js' import { foldPath } from './util.js' const _require = createRequire(import.meta.url) @@ -1211,6 +1216,33 @@ interface ParseContentResult { readonly refs: RefEntry[] } +function isLwcFile(filePath: string, extension: '.js' | '.html'): boolean { + const normalized = filePath.replace(/\\/g, '/') + return /\/lwc\/[^/]+\/[^/]+$/i.test(normalized) && normalized.toLowerCase().endsWith(extension) +} + +function mergeParseResults(...results: readonly ParseContentResult[]): ParseContentResult { + const symbols: SymbolEntry[] = [] + const refs: RefEntry[] = [] + const seenSymbols = new Set() + const seenRefs = new Set() + for (const result of results) { + for (const entry of result.symbols) { + const key = `${entry.filePath}\0${entry.name}\0${entry.kind}\0${entry.lineStart}` + if (seenSymbols.has(key)) continue + seenSymbols.add(key) + symbols.push(entry) + } + for (const entry of result.refs) { + const key = `${entry.filePath}\0${entry.name}\0${entry.line}\0${entry.col}` + if (seenRefs.has(key)) continue + seenRefs.add(key) + refs.push(entry) + } + } + return { symbols, refs } +} + /** Shared sync core: pick an extractor for `language` and run it on `content`. */ function parseContent(content: string, filePath: string, language: Language): ParseContentResult { // Strip UTF-8 BOM if present (U+FEFF); some editors save files with this prefix. @@ -1247,7 +1279,10 @@ function parseContent(content: string, filePath: string, language: Language): Pa symbols = extractTsJsSymbols(root, filePath) } const refs = REF_LANGUAGES.has(language) ? extractRefs(root, filePath, language) : [] - return { symbols, refs } + const parsed = { symbols, refs } + return language === 'javascript' && isLwcFile(filePath, '.js') + ? mergeParseResults(parsed, extractLwcJavaScript(content, filePath)) + : parsed } } catch { // Parser threw on this input — fall through to the regex pass below. @@ -1255,7 +1290,7 @@ function parseContent(content: string, filePath: string, language: Language): Pa } // Regex-based extractors for languages without tree-sitter - return { symbols: extractSymbolsNoTreeSitter(content, filePath, language), refs: [] } + return extractNoTreeSitter(content, filePath, language) } /** @@ -1317,6 +1352,24 @@ const NO_TREE_SITTER_EXTRACTORS: Partial> = { env_file: extractEnv, } +function extractNoTreeSitter( + content: string, + filePath: string, + language: Language, +): ParseContentResult { + if (language === 'salesforce_metadata') return extractSalesforceMetadata(content, filePath) + if (language === 'salesforce_markup') return extractSalesforceMarkup(content, filePath) + if (language === 'html' && isLwcFile(filePath, '.html')) return extractLwcTemplate(content, filePath) + + const parsed: ParseContentResult = { + symbols: extractSymbolsNoTreeSitter(content, filePath, language), + refs: [], + } + return language === 'javascript' && isLwcFile(filePath, '.js') + ? mergeParseResults(parsed, extractLwcJavaScript(content, filePath)) + : parsed +} + function extractSymbolsNoTreeSitter( content: string, filePath: string, diff --git a/src/parser_types.ts b/src/parser_types.ts index 1ff0f2f1..f08f48f6 100644 --- a/src/parser_types.ts +++ b/src/parser_types.ts @@ -78,6 +78,7 @@ export type Language = | 'powershell' | 'apex' | 'salesforce_metadata' + | 'salesforce_markup' | 'unknown' /** @@ -140,31 +141,18 @@ const EXTENSION_LANGUAGE: ReadonlyMap = new Map([ ['.env', 'env_file'], ['.cls', 'apex'], ['.trigger', 'apex'], + ['.cmp', 'salesforce_markup'], + ['.app', 'salesforce_markup'], + ['.evt', 'salesforce_markup'], + ['.intf', 'salesforce_markup'], + ['.design', 'salesforce_markup'], + ['.auradoc', 'salesforce_markup'], + ['.tokens', 'salesforce_markup'], + ['.page', 'salesforce_markup'], + ['.component', 'salesforce_markup'], + ['.email', 'salesforce_markup'], ]) -const SALESFORCE_METADATA_SUFFIXES = [ - '.object-meta.xml', - '.field-meta.xml', - '.validationrule-meta.xml', - '.flow-meta.xml', - '.permissionset-meta.xml', - '.permissionsetgroup-meta.xml', - '.profile-meta.xml', - '.md-meta.xml', - '.layout-meta.xml', - '.flexipage-meta.xml', - '.app-meta.xml', - '.tab-meta.xml', - '.labels-meta.xml', - '.globalvalueset-meta.xml', - '.standardvalueset-meta.xml', - '.custompermission-meta.xml', - '.recordtype-meta.xml', - '.sharingrules-meta.xml', - '.workflow-meta.xml', - '.duplicaterule-meta.xml', -] - /** * Filenames (no extension or special name) that map directly to a language. * @@ -201,7 +189,7 @@ export function detectLanguage(filePath: string): Language { const byName = FILENAME_LANGUAGE.get(base) if (byName !== undefined) return byName - if (SALESFORCE_METADATA_SUFFIXES.some((suffix) => base.endsWith(suffix))) { + if (base.endsWith('-meta.xml')) { return 'salesforce_metadata' } diff --git a/tests/fixtures/salesforce-dx/force-app/main/default/classes/AccountController.cls b/tests/fixtures/salesforce-dx/force-app/main/default/classes/AccountController.cls new file mode 100644 index 00000000..c425a0c2 --- /dev/null +++ b/tests/fixtures/salesforce-dx/force-app/main/default/classes/AccountController.cls @@ -0,0 +1,6 @@ +public with sharing class AccountController { + @AuraEnabled(cacheable=true) + public static Account loadAccount(Id recordId) { + return [SELECT Id, Name FROM Account WHERE Id = :recordId]; + } +} diff --git a/tests/fixtures/salesforce-dx/force-app/main/default/classes/AccountController.cls-meta.xml b/tests/fixtures/salesforce-dx/force-app/main/default/classes/AccountController.cls-meta.xml new file mode 100644 index 00000000..7e18afc4 --- /dev/null +++ b/tests/fixtures/salesforce-dx/force-app/main/default/classes/AccountController.cls-meta.xml @@ -0,0 +1,5 @@ + + + 65.0 + Active + diff --git a/tests/fixtures/salesforce-dx/force-app/main/default/flows/Account_Orchestrator.flow-meta.xml b/tests/fixtures/salesforce-dx/force-app/main/default/flows/Account_Orchestrator.flow-meta.xml new file mode 100644 index 00000000..ae4dbccf --- /dev/null +++ b/tests/fixtures/salesforce-dx/force-app/main/default/flows/Account_Orchestrator.flow-meta.xml @@ -0,0 +1,22 @@ + + + + Load_Account + AccountController.loadAccount + apex + + + Run_Child + Child_Account_Flow + + + Find_Account + Account + + Name + EqualTo + Acme + + + Active + diff --git a/tests/fixtures/salesforce-dx/force-app/main/default/lwc/accountCard/accountCard.html b/tests/fixtures/salesforce-dx/force-app/main/default/lwc/accountCard/accountCard.html new file mode 100644 index 00000000..6095d3a2 --- /dev/null +++ b/tests/fixtures/salesforce-dx/force-app/main/default/lwc/accountCard/accountCard.html @@ -0,0 +1,4 @@ + diff --git a/tests/fixtures/salesforce-dx/force-app/main/default/lwc/accountCard/accountCard.js b/tests/fixtures/salesforce-dx/force-app/main/default/lwc/accountCard/accountCard.js new file mode 100644 index 00000000..84659bd5 --- /dev/null +++ b/tests/fixtures/salesforce-dx/force-app/main/default/lwc/accountCard/accountCard.js @@ -0,0 +1,12 @@ +import { LightningElement, api } from 'lwc'; +import loadAccount from '@salesforce/apex/AccountController.loadAccount'; +import NAME_FIELD from '@salesforce/schema/Account.Name'; + +export default class AccountCard extends LightningElement { + @api recordId; + + @api + refresh() { + return loadAccount({ recordId: this.recordId, fields: [NAME_FIELD] }); + } +} diff --git a/tests/fixtures/salesforce-dx/force-app/main/default/lwc/accountCard/accountCard.js-meta.xml b/tests/fixtures/salesforce-dx/force-app/main/default/lwc/accountCard/accountCard.js-meta.xml new file mode 100644 index 00000000..591fae46 --- /dev/null +++ b/tests/fixtures/salesforce-dx/force-app/main/default/lwc/accountCard/accountCard.js-meta.xml @@ -0,0 +1,13 @@ + + + 65.0 + true + + lightning__RecordPage + + + + + + + diff --git a/tests/fixtures/salesforce-dx/force-app/main/default/mysteryTypes/OddThing.mysteryType-meta.xml b/tests/fixtures/salesforce-dx/force-app/main/default/mysteryTypes/OddThing.mysteryType-meta.xml new file mode 100644 index 00000000..9a9a020e --- /dev/null +++ b/tests/fixtures/salesforce-dx/force-app/main/default/mysteryTypes/OddThing.mysteryType-meta.xml @@ -0,0 +1,5 @@ + + + OddThing + + diff --git a/tests/fixtures/salesforce-dx/force-app/main/default/objects/Account/recordTypes/Business.recordType-meta.xml b/tests/fixtures/salesforce-dx/force-app/main/default/objects/Account/recordTypes/Business.recordType-meta.xml new file mode 100644 index 00000000..c9caa148 --- /dev/null +++ b/tests/fixtures/salesforce-dx/force-app/main/default/objects/Account/recordTypes/Business.recordType-meta.xml @@ -0,0 +1,6 @@ + + + Business + + true + diff --git a/tests/hooks_bash.test.ts b/tests/hooks_bash.test.ts index 78b0c39c..731167e4 100644 --- a/tests/hooks_bash.test.ts +++ b/tests/hooks_bash.test.ts @@ -1266,6 +1266,20 @@ describe('preBashHandler — sed line-range interception', () => { expect(result.context).toContain('docs/report.md@13-31') } }) + + it('treats Salesforce Apex, metadata, and markup as symbol-bearing surgical reads', () => { + for (const file of [ + 'force-app/main/default/classes/ExampleController.cls', + 'force-app/main/default/flows/Example.flow-meta.xml', + 'force-app/main/default/aura/example/example.cmp', + ]) { + const result = preBashHandler(makeBashEvent(`sed -n '10,40p' ${file}`)) + expect(result.hookType).toBe('context') + if (result.hookType === 'context') { + expect(result.context).toContain('token-goat symbol') + } + } + }) }) describe('preBashHandler — rg indented def patterns', () => { diff --git a/tests/hooks_read.test.ts b/tests/hooks_read.test.ts index 7723a518..56547e5f 100644 --- a/tests/hooks_read.test.ts +++ b/tests/hooks_read.test.ts @@ -987,6 +987,25 @@ Some content that makes the file large enough` } }) + it('recognizes Salesforce source and metadata files for symbol-aware re-read guidance', () => { + for (const suffix of ['.cls', '.cmp', '.flow-meta.xml']) { + const p = path.join( + os.tmpdir(), + `tg-read-${process.pid}-${Math.random().toString(36).slice(2)}${suffix}`, + ) + fs.writeFileSync(p, suffix === '.cls' ? 'public class Example {}' : '') + tmpFiles.push(p) + + expect(preReadHandler(readEvent(p)).hookType).toBe('pass') + expect(preReadHandler(readEvent(p)).hookType).toBe('context') + const third = preReadHandler(readEvent(p)) + expect(third.hookType).toBe('deny') + if (third.hookType === 'deny') { + expect(third.message).toContain('token-goat skeleton') + } + } + }) + it('does not recognize .swift as a source extension for the count-based re-read deny (regression: .swift was hardcoded into SOURCE_EXT_RE despite parser_types.ts having no adapter for it, so a 3rd read produced the skeleton/outline-pointing deny message even though those commands would return nothing for a .swift file)', () => { const p = path.join( os.tmpdir(), diff --git a/tests/parser_types.test.ts b/tests/parser_types.test.ts index 171a5b79..543f6193 100644 --- a/tests/parser_types.test.ts +++ b/tests/parser_types.test.ts @@ -38,12 +38,39 @@ describe('detectLanguage', () => { it('classifies Salesforce Apex and source-format metadata', () => { expect(detectLanguage('force-app/main/default/classes/ExampleController.cls')).toBe('apex') expect(detectLanguage('force-app/main/default/triggers/ExampleTrigger.trigger')).toBe('apex') - expect(detectLanguage('force-app/main/default/classes/ExampleController.cls-meta.xml')).toBe('unknown') + expect(detectLanguage('force-app/main/default/classes/ExampleController.cls-meta.xml')).toBe( + 'salesforce_metadata', + ) expect(detectLanguage('force-app/main/default/objects/Example__c/Example__c.object-meta.xml')).toBe( 'salesforce_metadata', ) expect(detectLanguage('force-app/main/default/flows/Example_Flow.flow-meta.xml')).toBe( 'salesforce_metadata', ) + expect(detectLanguage('force-app\\main\\default\\permissionsetgroups\\Sales.PERMISSIONS ETGROUP-META.XML')).toBe( + 'salesforce_metadata', + ) + expect(detectLanguage('force-app/main/default/unknown/Future.futureType-meta.xml')).toBe( + 'salesforce_metadata', + ) + }) + + it('classifies Aura and Visualforce markup', () => { + for (const extension of [ + 'cmp', + 'app', + 'evt', + 'intf', + 'design', + 'auradoc', + 'tokens', + 'page', + 'component', + 'email', + ]) { + expect(detectLanguage(`force-app\\main\\default\\ui\\Example.${extension.toUpperCase()}`)).toBe( + 'salesforce_markup', + ) + } }) }) diff --git a/tests/salesforce_frontend.test.ts b/tests/salesforce_frontend.test.ts new file mode 100644 index 00000000..330e5232 --- /dev/null +++ b/tests/salesforce_frontend.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from 'vitest' + +import { + extractLwcJavaScript, + extractLwcTemplate, + extractSalesforceMarkup, +} from '../src/languages/salesforce_frontend.js' + +describe('Salesforce frontend adapters', () => { + it('indexes an LWC bundle, public API, and Salesforce imports', () => { + const source = `import { LightningElement, api } from 'lwc'; +import getRows from '@salesforce/apex/OrderController.getRows'; +import ACCOUNT_NAME from '@salesforce/schema/Account.Name'; +import TITLE from '@salesforce/label/c.Order_Title'; +import LOGO from '@salesforce/resourceUrl/companyLogo'; +import EVENTS from '@salesforce/messageChannel/OrderEvents__c'; +import CAN_EDIT from '@salesforce/customPermission/Can_Edit_Order'; +import VIEW_SETUP from '@salesforce/userPermission/ViewSetup'; + +export default class OrderList extends LightningElement { + @api recordId; + + @api + refresh() {} +} +` + + const result = extractLwcJavaScript(source, 'force-app/main/default/lwc/orderList/orderList.js') + + expect(result.symbols.map(({ name, kind }) => ({ name, kind }))).toEqual([ + { name: 'orderList', kind: 'lwc_bundle' }, + { name: 'c-order-list', kind: 'lwc_component_alias' }, + { name: 'recordId', kind: 'lwc_api_property' }, + { name: 'refresh', kind: 'lwc_api_method' }, + ]) + expect(result.refs.map(({ name, line }) => ({ name, line }))).toEqual([ + { name: 'OrderController', line: 2 }, + { name: 'OrderController.getRows', line: 2 }, + { name: 'Account.Name', line: 3 }, + { name: 'c.Order_Title', line: 4 }, + { name: 'companyLogo', line: 5 }, + { name: 'OrderEvents__c', line: 6 }, + { name: 'Can_Edit_Order', line: 7 }, + { name: 'ViewSetup', line: 8 }, + ]) + }) + + it('ignores @api declarations and Salesforce imports inside JS comments', () => { + const source = `import { LightningElement, api } from 'lwc'; +// import disabledImport from '@salesforce/apex/OldController.getRows'; +/* + @api disabledProp; +*/ +import getRows from '@salesforce/apex/OrderController.getRows'; + +export default class OrderList extends LightningElement { + @api recordId; +} +` + + const result = extractLwcJavaScript(source, 'force-app/main/default/lwc/orderList/orderList.js') + + expect(result.symbols.map(({ name }) => name)).toEqual(['orderList', 'c-order-list', 'recordId']) + expect(result.refs.map(({ name, line }) => ({ name, line }))).toEqual([ + { name: 'OrderController', line: 6 }, + { name: 'OrderController.getRows', line: 6 }, + ]) + }) + + it('classifies public LWC accessors as properties and async functions as methods', () => { + const source = `export default class StatusPanel { + @api get status() { return 'ready'; } + @api async reload() {} +}` + const result = extractLwcJavaScript(source, 'force-app/main/default/lwc/statusPanel/statusPanel.js') + expect(result.symbols.map(({ name, kind }) => ({ name, kind }))).toEqual([ + { name: 'statusPanel', kind: 'lwc_bundle' }, + { name: 'c-status-panel', kind: 'lwc_component_alias' }, + { name: 'status', kind: 'lwc_api_property' }, + { name: 'reload', kind: 'lwc_api_method' }, + ]) + }) + + it('indexes LWC template refs, event handlers, child components, and useful ids without SLDS classes', () => { + const source = `` + + const result = extractLwcTemplate(source, 'force-app\\main\\default\\lwc\\checkoutPanel\\checkoutPanel.html') + + expect(result.symbols.map(({ name, kind }) => ({ name, kind }))).toEqual([ + { name: 'panel', kind: 'lwc_ref' }, + { name: 'checkout-panel', kind: 'lwc_id' }, + ]) + expect(result.refs.map(({ name, line }) => ({ name, line }))).toEqual([ + { name: 'handleCheckout', line: 3 }, + { name: 'handleRemove', line: 4 }, + { name: 'c-order-row', line: 4 }, + { name: 'c-order-row', line: 5 }, + ]) + }) + + it('ignores event-handler bindings inside HTML comments', () => { + const source = `` + + const result = extractLwcTemplate(source, 'force-app/main/default/lwc/checkoutPanel/checkoutPanel.html') + + expect(result.refs.map(({ name, line }) => ({ name, line }))).toEqual([ + { name: 'handleCheckout', line: 3 }, + ]) + }) + + it('ignores {!c.action} bindings inside HTML comments', () => { + const source = ` + + +` + + const result = extractSalesforceMarkup(source, 'force-app/main/default/aura/orderPanel/orderPanel.cmp') + + expect(result.refs.map(({ name }) => name)).not.toContain('disabledAction') + expect(result.refs.map(({ name }) => name)).toContain('select') + }) + + it('indexes Aura bundles, members, controller actions, and component references', () => { + const source = ` + + + + +` + + const result = extractSalesforceMarkup(source, 'force-app/main/default/aura/orderPanel/orderPanel.cmp') + + expect(result.symbols.map(({ name, kind }) => ({ name, kind }))).toEqual([ + { name: 'orderPanel', kind: 'aura_bundle' }, + { name: 'recordId', kind: 'aura_attribute' }, + { name: 'init', kind: 'aura_handler' }, + { name: 'changed', kind: 'aura_event' }, + ]) + expect(result.refs.map(({ name, line }) => ({ name, line }))).toEqual([ + { name: 'OrderController', line: 1 }, + { name: 'load', line: 3 }, + { name: 'select', line: 5 }, + { name: 'c:OrderChanged', line: 4 }, + { name: 'c:OrderRow', line: 5 }, + ]) + }) + + it('indexes Aura design attributes and Visualforce controller, extension, action, and component refs', () => { + const design = extractSalesforceMarkup( + '', + 'force-app/main/default/aura/orderPanel/orderPanel.design', + ) + expect(design.symbols.map(({ name, kind }) => ({ name, kind }))).toEqual([ + { name: 'orderPanel', kind: 'aura_design' }, + { name: 'title', kind: 'aura_design_attribute' }, + ]) + + const source = ` + +` + const page = extractSalesforceMarkup(source, 'force-app/main/default/pages/Order.page') + expect(page.symbols.map(({ name, kind }) => ({ name, kind }))).toEqual([ + { name: 'Order', kind: 'visualforce_page' }, + ]) + expect(page.refs.map(({ name, line }) => ({ name, line }))).toEqual([ + { name: 'OrderPageController', line: 1 }, + { name: 'AuditExtension', line: 1 }, + { name: 'SharingExtension', line: 1 }, + { name: 'prepare', line: 1 }, + { name: 'c:OrderSummary', line: 2 }, + ]) + }) + + it.each([ + ['cmp', 'aura_bundle'], + ['app', 'aura_application'], + ['evt', 'aura_event_bundle'], + ['intf', 'aura_interface'], + ['design', 'aura_design'], + ['auradoc', 'aura_documentation'], + ['tokens', 'aura_tokens'], + ['page', 'visualforce_page'], + ['component', 'visualforce_component'], + ['email', 'visualforce_email_template'], + ])('recognizes Salesforce markup extension .%s', (extension, kind) => { + const isAura = ['cmp', 'app', 'evt', 'intf', 'design', 'auradoc', 'tokens'].includes(extension) + const fileName = extension === 'page' ? 'Invoice.PAGE' : `sample.${extension}` + const filePath = isAura + ? `force-app/main/default/aura/sample/${fileName}` + : `force-app/main/default/pages/${fileName}` + const result = extractSalesforceMarkup('', filePath) + expect(result.symbols[0]).toMatchObject({ + name: extension === 'page' ? 'Invoice' : 'sample', + kind, + }) + }) +}) diff --git a/tests/salesforce_metadata_adapter.test.ts b/tests/salesforce_metadata_adapter.test.ts new file mode 100644 index 00000000..53cda700 --- /dev/null +++ b/tests/salesforce_metadata_adapter.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from 'vitest' + +import { extractSalesforceMetadata } from '../src/languages/salesforce_metadata.js' + +describe('Salesforce metadata XML adapter', () => { + it('indexes companion descriptors and unknown metadata with stable snake-case kinds', () => { + const companion = extractSalesforceMetadata( + '64.0', + 'force-app/main/default/classes/Checkout.cls-meta.xml', + ) + const future = extractSalesforceMetadata( + 'Sales & Service', + 'force-app/main/default/future/Fallback.future-meta.xml', + ) + + expect(companion.symbols).toEqual([ + expect.objectContaining({ name: 'Checkout.metadata', kind: 'sf_apex_class', body: '' }), + ]) + expect(future.symbols).toEqual([ + expect.objectContaining({ name: 'Sales & Service', kind: 'sf_permission_set_group' }), + ]) + expect(companion.refs).toEqual([]) + }) + + it.each([ + ['recordTypes/Partner.recordType-meta.xml', 'RecordType', 'Partner', 'sf_record_type'], + ['fieldSets/Summary.fieldSet-meta.xml', 'FieldSet', 'Summary', 'sf_field_set'], + ['compactLayouts/Phone.compactLayout-meta.xml', 'CompactLayout', 'Phone', 'sf_compact_layout'], + ['businessProcesses/Sales.businessProcess-meta.xml', 'BusinessProcess', 'Sales', 'sf_business_process'], + ['webLinks/Portal.webLink-meta.xml', 'WebLink', 'Portal', 'sf_web_link'], + ['sharingReasons/Support.sharingReason-meta.xml', 'SharingReason', 'Support', 'sf_sharing_reason'], + ])('qualifies object member %s', (tail, root, member, kind) => { + const result = extractSalesforceMetadata( + `<${root} xmlns="urn:test">${member}`, + `force-app/main/default/objects/Account/${tail}`, + ) + + expect(result.symbols.map(({ name, kind: actualKind }) => [name, actualKind])).toContainEqual([ + `Account.${member}`, + kind, + ]) + }) + + it('indexes individual custom labels and decodes XML entities', () => { + const result = extractSalesforceMetadata( + ` + First_LabelOne + Second_LabelFish & Chips +`, + 'force-app/main/default/labels/CustomLabels.labels-meta.xml', + ) + + expect(result.symbols.map((symbol) => [symbol.name, symbol.kind])).toEqual([ + ['CustomLabels', 'sf_custom_labels'], + ['First_Label', 'sf_custom_label'], + ['Second_Label', 'sf_custom_label'], + ]) + }) + + it('extracts and deduplicates Flow references', () => { + const result = extractSalesforceMetadata( + ` + InvokeInvoiceService.create + ChildShared_Subflow + FindAccountExternal_Id__c + UpdateAccountName +`, + 'force-app/main/default/flows/Checkout.flow-meta.xml', + ) + + expect(result.refs.map((ref) => ref.name)).toEqual([ + 'InvoiceService.create', + 'Shared_Subflow', + 'Account', + 'Account.External_Id__c', + 'Account', + 'Account.Name', + ]) + expect(result.refs.every((ref) => ref.line > 0 && ref.col >= 0 && ref.context !== '')).toBe(true) + }) + + it('indexes deduplicated LWC targets and target-config properties with surgical spans', () => { + const result = extractSalesforceMetadata( + ` + + lightning__RecordPage + lightning__AppPage + lightning__RecordPage + + + + + + + + +`, + 'force-app/main/default/lwc/orderCard/orderCard.js-meta.xml', + ) + + expect(result.symbols.map(({ name, kind }) => [name, kind])).toEqual([ + ['orderCard.metadata', 'sf_lightning_component_bundle'], + ['lightning__RecordPage', 'sf_lwc_target'], + ['lightning__AppPage', 'sf_lwc_target'], + ['recordId', 'sf_lwc_property'], + ['mode', 'sf_lwc_property'], + ]) + for (const symbol of result.symbols.slice(1)) { + expect(symbol.body).not.toBe('') + expect(symbol.lineEnd).toBeGreaterThanOrEqual(symbol.lineStart) + } + }) + + it.each([ + [ + 'pages/Home.flexiPage-meta.xml', + 'Accountc:AccountHero', + ['Account', 'c:AccountHero'], + ], + [ + 'quickActions/Account.Start.quickAction-meta.xml', + 'Accountc:StartWizard', + ['Account', 'c:StartWizard'], + ], + [ + 'messageChannels/Order.messageChannel-meta.xml', + 'OrderrecordId', + ['recordId'], + ], + ])('extracts UI metadata references from %s', (tail, content, expected) => { + const result = extractSalesforceMetadata(content, `force-app/main/default/${tail}`) + expect(result.refs.map((ref) => ref.name)).toEqual(expected) + }) + + it('ignores metadata names, flow refs, and target-config properties inside XML comments', () => { + const commentedName = extractSalesforceMetadata( + ` + + Real_Object__c +`, + 'force-app/main/default/objects/Real_Object__c/Real_Object__c.object-meta.xml', + ) + expect(commentedName.symbols).toEqual([ + expect.objectContaining({ name: 'Real_Object__c', kind: 'sf_object' }), + ]) + + const commentedFlow = extractSalesforceMetadata( + ` + + InvokeInvoiceService.create +`, + 'force-app/main/default/flows/Checkout.flow-meta.xml', + ) + expect(commentedFlow.symbols.map((symbol) => symbol.name)).not.toContain('Disabled') + expect(commentedFlow.symbols.map((symbol) => symbol.name)).toContain('Invoke') + expect(commentedFlow.refs.map((ref) => ref.name)).toEqual(['InvoiceService.create']) + + const commentedProperty = extractSalesforceMetadata( + ` + + + + + + +`, + 'force-app/main/default/lwc/orderCard/orderCard.js-meta.xml', + ) + expect(commentedProperty.symbols.map((symbol) => symbol.name)).not.toContain('disabledProp') + expect(commentedProperty.symbols.map((symbol) => symbol.name)).toContain('recordId') + }) + + it('returns no entries for malformed metadata and caps duplicate child symbols', () => { + expect( + extractSalesforceMetadata('Broken', 'Broken.labels-meta.xml'), + ).toEqual({ symbols: [], refs: [] }) + + const repeated = Array.from( + { length: 1100 }, + (_, index) => `Label_${index}`, + ).join('') + const result = extractSalesforceMetadata( + `${repeated}Label_0`, + 'CustomLabels.labels-meta.xml', + ) + expect(result.symbols).toHaveLength(1000) + expect(new Set(result.symbols.map((symbol) => symbol.name)).size).toBe(1000) + }) + + it('indexes a metadata file whose root element is self-closing (regression: rootElement required a separate close tag, so a self-closing root indexed as zero symbols)', () => { + const result = extractSalesforceMetadata( + '', + 'Account.objectTranslation-meta.xml', + ) + expect(result.symbols).toEqual([ + expect.objectContaining({ name: 'Account', kind: 'sf_custom_object_translation' }), + ]) + }) + + it('caps ref emission at MAX_REFS instead of growing unbounded (regression: emitRef had no cap, unlike emit’s MAX_SYMBOLS)', () => { + const repeated = Array.from( + { length: 1100 }, + (_, index) => `cmp_${index}`, + ).join('') + const result = extractSalesforceMetadata( + `${repeated}`, + 'Test.flexipage-meta.xml', + ) + expect(result.refs.length).toBeLessThanOrEqual(1000) + }) +}) diff --git a/tests/salesforce_worker_e2e.test.ts b/tests/salesforce_worker_e2e.test.ts new file mode 100644 index 00000000..0c7a59cc --- /dev/null +++ b/tests/salesforce_worker_e2e.test.ts @@ -0,0 +1,166 @@ +/** + * Salesforce DX regression coverage for the two shipping seams: + * + * 1. the worker drains dirty.txt with its real default indexer (no injected + * callback), and + * 2. the built CLI indexes and surgically reads the same fixture. + * + * Keep this fixture deliberately small but cross-layer: Apex, an LWC bundle, + * detailed metadata, Flow references, and an otherwise unknown *-meta.xml. + */ + +import { execFileSync, spawnSync } from 'node:child_process' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { afterEach, describe, expect, it } from 'vitest' + +import { closeDb } from '../src/db.js' +import { queryRefs, querySymbols } from '../src/index_reader.js' +import { normalizePath } from '../src/paths.js' +import { drainOnce } from '../src/worker.js' + +import { BUNDLE } from './helpers/bundle.js' + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const FIXTURE = path.join(HERE, 'fixtures', 'salesforce-dx') + +const tempDirs = new Set() + +function tempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)) + tempDirs.add(dir) + return dir +} + +function copyFixture(): string { + const repo = tempDir('tg-salesforce-repo-') + fs.cpSync(FIXTURE, repo, { recursive: true }) + return repo +} + +function fixtureFiles(repo: string): string[] { + const files: string[] = [] + const visit = (dir: string): void => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const absolute = path.join(dir, entry.name) + if (entry.isDirectory()) visit(absolute) + else if (entry.name !== 'sfdx-project.json') files.push(normalizePath(absolute)) + } + } + visit(repo) + return files.sort() +} + +function writeDirtyQueue(dataDir: string, files: readonly string[]): void { + const queue = path.join(dataDir, 'queue', 'dirty.txt') + fs.mkdirSync(path.dirname(queue), { recursive: true }) + fs.writeFileSync(queue, `${files.join('\n')}\n`) +} + +function runBundle( + repo: string, + dataBase: string, + args: readonly string[], +): { status: number | null; stdout: string; stderr: string } { + const result = spawnSync(process.execPath, [BUNDLE, ...args], { + cwd: repo, + // token-goat follows platformdirs semantics: macOS derives its data dir from HOME, + // Linux from XDG_DATA_HOME, and Windows from LOCALAPPDATA/USERPROFILE. + env: { + ...process.env, + HOME: dataBase, + USERPROFILE: dataBase, + LOCALAPPDATA: dataBase, + XDG_DATA_HOME: dataBase, + }, + encoding: 'utf8', + }) + return { + status: result.status, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + } +} + +afterEach(() => { + for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }) + tempDirs.clear() +}) + +describe('Salesforce DX worker default path', () => { + it('drains Apex, LWC, Flow, and metadata into searchable symbols and refs', () => { + const repo = copyFixture() + const dataDir = tempDir('tg-salesforce-worker-data-') + const files = fixtureFiles(repo) + writeDirtyQueue(dataDir, files) + + expect(drainOnce(dataDir)).toBe(files.length) + + const dbPath = path.join(dataDir, 'global.db') + try { + expect(querySymbols({ name: 'AccountController' }, dbPath)[0]).toMatchObject({ + kind: 'apex_class', + }) + expect(querySymbols({ name: 'loadAccount' }, dbPath)[0]).toMatchObject({ + kind: 'apex_method', + }) + + // The LWC bundle and its public API are addressable across JS/template/metadata files. + expect(querySymbols({ name: 'accountCard' }, dbPath).length).toBeGreaterThan(0) + expect(querySymbols({ name: 'recordId' }, dbPath).length).toBeGreaterThan(0) + expect(querySymbols({ name: 'refresh' }, dbPath).length).toBeGreaterThan(0) + expect(querySymbols({ name: 'refreshButton' }, dbPath).length).toBeGreaterThan(0) + expect(querySymbols({ name: 'variant' }, dbPath).length).toBeGreaterThan(0) + + // Selected metadata receives qualified symbols, while an unknown metadata type + // still gets a stable top-level symbol and never stores the whole XML body. + expect(querySymbols({ name: 'Account.Business' }, dbPath)[0]).toMatchObject({ + kind: 'sf_record_type', + }) + expect(querySymbols({ name: 'OddThing' }, dbPath)[0]).toMatchObject({ + kind: 'sf_mystery_type', + body: '', + }) + + // Canonical cross-file names make navigation independent of import aliases. + expect(queryRefs({ name: 'AccountController.loadAccount' }, dbPath).length).toBeGreaterThan(0) + expect(queryRefs({ name: 'Account.Name' }, dbPath).length).toBeGreaterThan(0) + expect(queryRefs({ name: 'refresh' }, dbPath).length).toBeGreaterThan(0) + expect(queryRefs({ name: 'c-child-card' }, dbPath).length).toBeGreaterThan(0) + expect(queryRefs({ name: 'Child_Account_Flow' }, dbPath).length).toBeGreaterThan(0) + } finally { + closeDb(dbPath) + } + }) +}) + +describe('Salesforce DX built bundle smoke', () => { + it('indexes the fixture and serves symbol, read, and refs from dist', () => { + const repo = copyFixture() + const dataBase = tempDir('tg-salesforce-bundle-data-') + execFileSync('git', ['init'], { cwd: repo, stdio: 'ignore' }) + execFileSync('git', ['add', '.'], { cwd: repo, stdio: 'ignore' }) + + const indexed = runBundle(repo, dataBase, ['index', repo]) + expect(indexed.status, indexed.stderr).toBe(0) + expect(indexed.stdout).toMatch(/Indexed \d+ files/) + + const symbol = runBundle(repo, dataBase, ['symbol', 'accountCard']) + expect(symbol.status, symbol.stderr).toBe(0) + expect(symbol.stdout).toContain('accountCard') + + const read = runBundle(repo, dataBase, [ + 'read', + 'force-app/main/default/classes/AccountController.cls::loadAccount', + ]) + expect(read.status, read.stderr).toBe(0) + expect(read.stdout).toContain('public static Account loadAccount') + + const refs = runBundle(repo, dataBase, ['refs', 'AccountController.loadAccount']) + expect(refs.status, refs.stderr).toBe(0) + expect(refs.stdout).toContain('accountCard.js') + }, 60_000) +}) diff --git a/tests/worker_index_e2e.test.ts b/tests/worker_index_e2e.test.ts index d5e71f18..1fefb9d5 100644 --- a/tests/worker_index_e2e.test.ts +++ b/tests/worker_index_e2e.test.ts @@ -43,7 +43,13 @@ let dataBase: string /** Redirect the data dir into a temp base so the e2e never touches the real index. */ function tgEnv(): NodeJS.ProcessEnv { - return { ...process.env, LOCALAPPDATA: dataBase, XDG_DATA_HOME: dataBase } + return { + ...process.env, + HOME: dataBase, + USERPROFILE: dataBase, + LOCALAPPDATA: dataBase, + XDG_DATA_HOME: dataBase, + } } function runBundle(args: string[]): { status: number | null; stdout: string; stderr: string } { @@ -363,7 +369,13 @@ describe('built bundle exposes exports / imports / find / web-output', () => { function run(args: string[]): { status: number | null; stdout: string; stderr: string } { const res = spawnSync(process.execPath, [BUNDLE, ...args], { cwd: cmdRepo, - env: { ...process.env, LOCALAPPDATA: cmdData, XDG_DATA_HOME: cmdData }, + env: { + ...process.env, + HOME: cmdData, + USERPROFILE: cmdData, + LOCALAPPDATA: cmdData, + XDG_DATA_HOME: cmdData, + }, encoding: 'utf8', }) return { status: res.status, stdout: res.stdout ?? '', stderr: res.stderr ?? '' }