diff --git a/src/languages/apex.ts b/src/languages/apex.ts new file mode 100644 index 00000000..b8c9fc90 --- /dev/null +++ b/src/languages/apex.ts @@ -0,0 +1,165 @@ +import type { SymbolEntry } from '../parser_types.js' +import { buildLineIndex, offsetToLine, stripCstyleComments, stripStringLiterals } from './common.js' + +const MAX_SYMBOLS = 500 +const IDENT = '[A-Za-z_][A-Za-z0-9_]*' +const MODIFIER = + '(?:public|private|protected|global|static|final|override|virtual|abstract|webservice|testMethod|transient|with|without|inherited|sharing)' + +const TYPE_DECL_RE = new RegExp( + `^[ \\t]*(?:${MODIFIER}[ \\t]+)*(class|interface|enum)[ \\t]+(${IDENT})\\b[^\\n{;]*`, + 'gm', +) +const TRIGGER_RE = new RegExp( + `^[ \\t]*trigger[ \\t]+(${IDENT})[ \\t]+on[ \\t]+([A-Za-z_][A-Za-z0-9_.]*)[ \\t]*\\([^\\n)]*\\)`, + 'gm', +) +const METHOD_RE = new RegExp( + `^[ \\t]*(?:@[A-Za-z_][A-Za-z0-9_]*(?:\\([^\\n)]*\\))?[ \\t]+)*(?:${MODIFIER}[ \\t]+)+(?:[A-Za-z_][A-Za-z0-9_.<>?,\\[\\] ]*[ \\t]+)?(${IDENT})[ \\t]*\\([\\s\\S]*?\\)[ \\t]*(?:\\{|;)`, + 'gm', +) + +const CONTROL_NAMES = new Set([ + 'if', + 'for', + 'while', + 'switch', + 'catch', + 'return', + 'throw', + 'new', +]) + +interface Span { + readonly startLine: number + readonly endLine: number + readonly body: string +} + +function makeSymbol( + filePath: string, + name: string, + kind: string, + span: Span, + docstring = '', +): SymbolEntry { + return { + filePath, + name, + kind, + lineStart: span.startLine, + lineEnd: span.endLine, + body: span.body, + docstring, + } +} + +function lineStartOffset(lineIndex: readonly number[], line: number): number { + return lineIndex[Math.max(0, line - 1)] ?? 0 +} + +function lineEndOffset(content: string, lineIndex: readonly number[], line: number): number { + return line < lineIndex.length ? (lineIndex[line] ?? content.length) : content.length +} + +function annotationStartLine(lines: readonly string[], line: number): number { + let start = line + for (let i = line - 2; i >= 0; i--) { + const trimmed = lines[i]?.trim() ?? '' + if (trimmed.startsWith('@')) { + start = i + 1 + continue + } + if (trimmed === '') continue + break + } + return start +} + +function findBlockEndLine(code: string, lineIndex: readonly number[], fromOffset: number): number | null { + const open = code.indexOf('{', fromOffset) + if (open === -1) return null + + let depth = 0 + for (let i = open; i < code.length; i++) { + const ch = code[i] + if (ch === '{') { + depth += 1 + } else if (ch === '}') { + depth -= 1 + if (depth === 0) return offsetToLine([...lineIndex], i) + } + } + return null +} + +function spanForMatch( + content: string, + code: string, + lineIndex: readonly number[], + startOffset: number, + bodyStartLine: number, +): Span { + const blockEndLine = findBlockEndLine(code, lineIndex, startOffset) + const startOffsetForBody = lineStartOffset(lineIndex, bodyStartLine) + const endLine = blockEndLine ?? offsetToLine([...lineIndex], startOffset) + const endOffset = lineEndOffset(content, lineIndex, endLine) + return { + startLine: bodyStartLine, + endLine, + body: content.slice(startOffsetForBody, endOffset).trimEnd(), + } +} + +function overlapsExisting(symbols: readonly SymbolEntry[], line: number): boolean { + return symbols.some((s) => line >= s.lineStart && line <= s.lineEnd && s.kind !== 'apex_class') +} + +export function extractApex(content: string, filePath: string): { symbols: SymbolEntry[] } { + const symbols: SymbolEntry[] = [] + const seen = new Set() + const lineIndex = buildLineIndex(content) + const rawLines = content.split(/\r?\n/) + const commentFree = stripCstyleComments(content, /\/\/.*$/gm) + const code = stripStringLiterals(commentFree) + + const emit = (name: string, kind: string, span: Span, docstring = ''): void => { + if (!name || symbols.length >= MAX_SYMBOLS) return + const key = `${name}\0${kind}\0${span.startLine}` + if (seen.has(key)) return + seen.add(key) + symbols.push(makeSymbol(filePath, name, kind, span, docstring)) + } + + for (const match of code.matchAll(TRIGGER_RE)) { + const name = match[1] ?? '' + const objectName = match[2] ?? '' + const startOffset = match.index ?? 0 + const line = offsetToLine([...lineIndex], startOffset) + emit(name, 'apex_trigger', spanForMatch(content, code, lineIndex, startOffset, line), objectName) + } + + const typeNames = new Set() + for (const match of code.matchAll(TYPE_DECL_RE)) { + const typeKind = match[1] ?? '' + const name = match[2] ?? '' + const startOffset = match.index ?? 0 + const line = offsetToLine([...lineIndex], startOffset) + const kind = typeKind === 'class' ? 'apex_class' : `apex_${typeKind}` + typeNames.add(name) + emit(name, kind, spanForMatch(content, code, lineIndex, startOffset, line)) + } + + for (const match of code.matchAll(METHOD_RE)) { + const name = match[1] ?? '' + if (CONTROL_NAMES.has(name)) continue + const startOffset = match.index ?? 0 + const line = offsetToLine([...lineIndex], startOffset) + if (overlapsExisting(symbols, line)) continue + const bodyStartLine = annotationStartLine(rawLines, line) + const kind = typeNames.has(name) ? 'apex_constructor' : 'apex_method' + emit(name, kind, spanForMatch(content, code, lineIndex, startOffset, bodyStartLine)) + } + + return { symbols } +} diff --git a/src/languages/index.ts b/src/languages/index.ts index ec7879b7..16591742 100644 --- a/src/languages/index.ts +++ b/src/languages/index.ts @@ -4,7 +4,7 @@ * Adapters for TypeScript/JS, Python, Go, Rust, Ruby, Java, C++, Markdown, * JSON, YAML, TOML, CSS, and Dockerfile remain inlined in `parser.ts`. * This barrel exports the newer adapters: C#, PHP, HTML, Liquid, Kotlin, - * GraphQL, SQL, INI, Makefile, Proto, and .env. + * GraphQL, SQL, INI, Makefile, Proto, .env, Apex, and Salesforce metadata. */ export * from './common.js' @@ -20,3 +20,5 @@ export * from './makefile_idx.js' export * from './proto_idx.js' export * from './powershell_idx.js' export * from './env_idx.js' +export * from './apex.js' +export * from './salesforce_metadata.js' diff --git a/src/languages/salesforce_metadata.ts b/src/languages/salesforce_metadata.ts new file mode 100644 index 00000000..7d97ac4f --- /dev/null +++ b/src/languages/salesforce_metadata.ts @@ -0,0 +1,220 @@ +import * as path from 'node:path' + +import type { SymbolEntry } from '../parser_types.js' +import { buildLineIndex, offsetToLine } from './common.js' + +const MAX_SYMBOLS = 1000 + +const FLOW_TAG_KIND: Readonly> = { + actionCalls: 'sf_flow_action', + assignments: 'sf_flow_assignment', + choices: 'sf_flow_choice', + collectionProcessors: 'sf_flow_collection_processor', + constants: 'sf_flow_constant', + decisions: 'sf_flow_decision', + dynamicChoiceSets: 'sf_flow_dynamic_choice_set', + formulas: 'sf_flow_formula', + loops: 'sf_flow_loop', + recordCreates: 'sf_flow_record_create', + recordDeletes: 'sf_flow_record_delete', + recordLookups: 'sf_flow_record_lookup', + recordUpdates: 'sf_flow_record_update', + screens: 'sf_flow_screen', + subflows: 'sf_flow_subflow', + textTemplates: 'sf_flow_text_template', + transforms: 'sf_flow_transform', + variables: 'sf_flow_variable', +} + +interface Span { + readonly startLine: number + readonly endLine: number + readonly body: string +} + +function xmlText(content: string, tag: string): string | null { + const re = new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)`, 'i') + const match = re.exec(content) + if (match?.[1] === undefined) return null + return decodeXml(match[1].trim()) +} + +function decodeXml(value: string): string { + return value + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/>/g, '>') + .replace(/</g, '<') + .replace(/&/g, '&') +} + +function normalizedPath(filePath: string): string { + return filePath.replace(/\\/g, '/') +} + +function basenameWithout(filePath: string, suffix: string): string { + const base = path.basename(filePath) + return base.toLowerCase().endsWith(suffix.toLowerCase()) + ? base.slice(0, base.length - suffix.length) + : base +} + +function objectNameFromPath(filePath: string): string | null { + const match = /\/objects\/([^/]+)\//.exec(normalizedPath(filePath)) + return match?.[1] ?? null +} + +function wholeFileSpan(content: string): Span { + const lines = content.split(/\r?\n/) + return { + startLine: 1, + endLine: lines.length > 1 && lines[lines.length - 1] === '' ? lines.length - 1 : lines.length, + // File-level metadata symbols can be several megabytes (profiles are a common case). + // Keep only the source span in the index; `read` reconstructs empty bodies from disk. + body: '', + } +} + +function spanFromOffsets( + content: string, + lineIndex: readonly number[], + startOffset: number, + endOffset: number, +): Span { + const startLine = offsetToLine([...lineIndex], startOffset) + const endLine = offsetToLine([...lineIndex], Math.max(startOffset, endOffset - 1)) + return { + startLine, + endLine, + body: content.slice(startOffset, endOffset).trimEnd(), + } +} + +function makeSymbol( + filePath: string, + name: string, + kind: string, + span: Span, + docstring = '', +): SymbolEntry { + return { + filePath, + name, + kind, + lineStart: span.startLine, + lineEnd: span.endLine, + body: span.body, + docstring, + } +} + +function rootElement(content: string): string | null { + const match = /<([A-Za-z][A-Za-z0-9_]*)\b/.exec(content) + return match?.[1] ?? null +} + +function metadataName(filePath: string, content: string, suffix: string): string { + return xmlText(content, 'fullName') ?? basenameWithout(filePath, suffix) +} + +function addFlowElements( + symbols: SymbolEntry[], + seen: Set, + content: string, + filePath: string, + flowName: string, +): void { + const lineIndex = buildLineIndex(content) + const tagAlternation = Object.keys(FLOW_TAG_KIND).join('|') + const re = new RegExp(`<(${tagAlternation})>\\s*([\\s\\S]*?)\\s*`, 'g') + + for (const match of content.matchAll(re)) { + if (symbols.length >= MAX_SYMBOLS) return + const tag = match[1] ?? '' + const inner = match[2] ?? '' + const name = xmlText(inner, 'name') + if (name === null || name === '') continue + const startOffset = match.index ?? 0 + const endOffset = startOffset + match[0].length + const span = spanFromOffsets(content, lineIndex, startOffset, endOffset) + const kind = FLOW_TAG_KIND[tag] ?? 'sf_flow_element' + emit(symbols, seen, makeSymbol(filePath, name, kind, span, flowName)) + } +} + +function emit(symbols: SymbolEntry[], seen: Set, symbol: SymbolEntry): void { + if (!symbol.name || symbols.length >= MAX_SYMBOLS) return + const key = `${symbol.name}\0${symbol.kind}\0${symbol.lineStart}` + if (seen.has(key)) return + seen.add(key) + symbols.push(symbol) +} + +export function extractSalesforceMetadata( + content: string, + filePath: string, +): { symbols: SymbolEntry[] } { + const symbols: SymbolEntry[] = [] + const seen = new Set() + const base = path.basename(filePath).toLowerCase() + const whole = wholeFileSpan(content) + + 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 } + } + + if (base.endsWith('.field-meta.xml')) { + const name = metadataName(filePath, content, '.field-meta.xml') + const objectName = objectNameFromPath(filePath) ?? '' + emit(symbols, seen, makeSymbol(filePath, name, 'sf_custom_field', whole, objectName)) + if (objectName !== '') { + emit(symbols, seen, makeSymbol(filePath, `${objectName}.${name}`, 'sf_custom_field', whole, objectName)) + } + return { symbols } + } + + if (base.endsWith('.validationrule-meta.xml')) { + const name = metadataName(filePath, content, '.validationRule-meta.xml') + const objectName = objectNameFromPath(filePath) ?? '' + emit(symbols, seen, makeSymbol(filePath, name, 'sf_validation_rule', whole, objectName)) + if (objectName !== '') { + emit(symbols, seen, makeSymbol(filePath, `${objectName}.${name}`, 'sf_validation_rule', whole, objectName)) + } + return { symbols } + } + + 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 } + } + + 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 } + } + + if (base.endsWith('.profile-meta.xml')) { + const name = basenameWithout(filePath, '.profile-meta.xml') + emit(symbols, seen, makeSymbol(filePath, name, 'sf_profile', whole)) + return { symbols } + } + + 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 } + } + + 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)) + } + return { symbols } +} diff --git a/src/parser.ts b/src/parser.ts index 4e24eca5..247fe41b 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -21,7 +21,7 @@ import * as path from 'node:path' import { globalDbPath } from './constants.js' import { getDb } from './db.js' import { loadConfig } from './config.js' -import { indexFile as embedIndexFile } from './embeddings.js' +import { deleteFileEmbeddings, indexFile as embedIndexFile } from './embeddings.js' import type { ChunkBoundary } from './embeddings.js' import { fingerprintContent, fingerprintFile } from './fingerprint.js' import { pathEqClause } from './sql_path.js' @@ -43,6 +43,8 @@ import { extractMakefile } from './languages/makefile_idx.js' 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 { foldPath } from './util.js' const _require = createRequire(import.meta.url) @@ -1309,6 +1311,8 @@ function extractSymbolsNoTreeSitter( if (language === 'makefile') return extractMakefile(content, filePath) if (language === 'proto') return extractProto(content, filePath).symbols if (language === 'powershell') return extractPowershell(content, filePath).symbols + if (language === 'apex') return extractApex(content, filePath).symbols + if (language === 'salesforce_metadata') return extractSalesforceMetadata(content, filePath).symbols if (language === 'env_file') return extractEnv(content, filePath) if (language === 'unknown') return [] @@ -1468,12 +1472,23 @@ function buildEmbeddingBoundaries(filePath: string, content: string, dbPath: str export async function indexFileEmbeddings(filePath: string, dbPath: string = globalDbPath()): Promise { if (!loadConfig().indexing.embeddings_enabled) return + if (filePath.toLowerCase().endsWith('.profile-meta.xml')) { + // Profiles are frequently multi-megabyte, highly repetitive permission dumps. Embedding + // them creates thousands of low-signal vectors; exact symbol/read/grep access remains. + deleteFileEmbeddings(getDb(dbPath), filePath) + return + } let content: string try { content = await fs.promises.readFile(filePath, 'utf8') } catch { return } + if (detectLanguage(filePath) === 'salesforce_metadata' && content.length > 512 * 1024) { + // Keep unusually large generated metadata from producing thousands of low-signal chunks. + deleteFileEmbeddings(getDb(dbPath), filePath) + return + } try { const db = getDb(dbPath) const boundaries = buildEmbeddingBoundaries(filePath, content, dbPath) diff --git a/src/parser_types.ts b/src/parser_types.ts index c17ac7c3..0ae07486 100644 --- a/src/parser_types.ts +++ b/src/parser_types.ts @@ -71,6 +71,8 @@ export type Language = | 'proto' | 'env_file' | 'powershell' + | 'apex' + | 'salesforce_metadata' | 'unknown' /** @@ -131,8 +133,33 @@ const EXTENSION_LANGUAGE: ReadonlyMap = new Map([ ['.ps1', 'powershell'], ['.psm1', 'powershell'], ['.env', 'env_file'], + ['.cls', 'apex'], + ['.trigger', 'apex'], ]) +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. * @@ -169,6 +196,10 @@ 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))) { + return 'salesforce_metadata' + } + const ext = path.extname(base).toLowerCase() return EXTENSION_LANGUAGE.get(ext) ?? 'unknown' } diff --git a/src/read_commands.ts b/src/read_commands.ts index 0820d6aa..4b65a0dc 100644 --- a/src/read_commands.ts +++ b/src/read_commands.ts @@ -445,10 +445,21 @@ export function runRead(opts: ReadOptions): { text: string; code: number } { return { text: JSON.stringify(match, null, 2), code: 0 } } + let body = match.body + if (body === '') { + const source = readFileText(match.filePath) + if (source !== null) { + body = source + .split(/\r?\n/) + .slice(Math.max(0, match.lineStart - 1), match.lineEnd) + .join('\n') + } + } + const bodyLen = match.lineEnd - match.lineStart + 1 const lines: string[] = [ - `# ${bodyLen} lines (~${Math.ceil(match.body.length / 4)} tok)`, - match.body, + `# ${bodyLen} lines (~${Math.ceil(body.length / 4)} tok)`, + body, ] return { text: guardText(trimBlankLines(lines).join('\n'), 'symbol'), code: 0 } } diff --git a/tests/embeddings_index_wiring.test.ts b/tests/embeddings_index_wiring.test.ts index f8baf7c7..82358cc9 100644 --- a/tests/embeddings_index_wiring.test.ts +++ b/tests/embeddings_index_wiring.test.ts @@ -137,6 +137,56 @@ describe('indexFileEmbeddings wires the real embeddings pipeline into indexing', expect(chunkRow.c).toBe(0) }) + it('keeps Salesforce profiles out of the semantic index and removes stale chunks', async () => { + process.env['TOKEN_GOAT_EMBEDDINGS_ENABLED'] = 'true' + const dbPath = path.join(TMP, 'index.db') + const filePath = path.join(TMP, 'Example.profile-meta.xml') + fs.writeFileSync( + filePath, + '\n ExamplePermission\n\n', + ) + + indexFileSync(filePath, dbPath) + const db = getDb(dbPath) + db.prepare( + "INSERT INTO chunks(file_path, start_line, end_line, text, kind) VALUES (?, 1, 1, 'stale', 'symbol')", + ).run(filePath) + + await indexFileEmbeddings(filePath, dbPath) + + const symbolRow = db + .prepare("SELECT COUNT(*) c FROM symbols WHERE file_path = ? AND kind = 'sf_profile'") + .get(filePath) as { c: number } + expect(symbolRow.c).toBe(1) + const chunkRow = db.prepare('SELECT COUNT(*) c FROM chunks WHERE file_path = ?').get(filePath) as { + c: number + } + expect(chunkRow.c).toBe(0) + }) + + it('keeps oversized Salesforce metadata out of the semantic index', async () => { + process.env['TOKEN_GOAT_EMBEDDINGS_ENABLED'] = 'true' + const dbPath = path.join(TMP, 'index.db') + const filePath = path.join(TMP, 'Example.permissionset-meta.xml') + fs.writeFileSync( + filePath, + `${'x'.repeat(600 * 1024)}`, + ) + + indexFileSync(filePath, dbPath) + await indexFileEmbeddings(filePath, dbPath) + + const db = getDb(dbPath) + const symbolRow = db + .prepare("SELECT COUNT(*) c FROM symbols WHERE file_path = ? AND kind = 'sf_permission_set'") + .get(filePath) as { c: number } + expect(symbolRow.c).toBe(1) + const chunkRow = db.prepare('SELECT COUNT(*) c FROM chunks WHERE file_path = ?').get(filePath) as { + c: number + } + expect(chunkRow.c).toBe(0) + }) + it('degrades gracefully - symbols still index - when chunk_vectors is unavailable (sqlite-vec-absent simulation)', async () => { // Drop the real table rather than mocking isAvailable(): this is exactly what // chunkVectorsTableExists() sees on an install where sqlite-vec never loaded, matching diff --git a/tests/parser_types.test.ts b/tests/parser_types.test.ts index 86d35688..171a5b79 100644 --- a/tests/parser_types.test.ts +++ b/tests/parser_types.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' import { detectLanguage } from '../src/parser_types.js' -import type { Language } from '../src/parser_types.js' describe('detectLanguage', () => { it('returns typescript for .ts files', () => { @@ -36,24 +35,15 @@ describe('detectLanguage', () => { expect(detectLanguage('Bar.TS')).toBe('typescript') }) - it('covers every Language value via some input', () => { - // One representative input per non-unknown Language; unknown covered above. - const cases: Record, string> = { - python: 'a.py', - typescript: 'a.ts', - javascript: 'a.js', - rust: 'a.rs', - go: 'a.go', - c: 'a.c', - cpp: 'a.cpp', - bash: 'a.sh', - markdown: 'a.md', - toml: 'a.toml', - json: 'a.json', - yaml: 'a.yaml', - } - for (const [lang, file] of Object.entries(cases)) { - expect(detectLanguage(file)).toBe(lang) - } + 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/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', + ) }) }) diff --git a/tests/read_commands.test.ts b/tests/read_commands.test.ts index f5cf19ba..ee73f745 100644 --- a/tests/read_commands.test.ts +++ b/tests/read_commands.test.ts @@ -224,6 +224,28 @@ describe('read_commands', () => { expect(stdout).toContain('myFn') }) + it('reconstructs an empty indexed body from its source span', () => { + const filePath = path.join(tempDir, 'Example.profile-meta.xml') + fs.writeFileSync(filePath, '\n \n\n') + const sym: MockSymbol = { + name: 'Example', + kind: 'sf_profile', + filePath, + lineStart: 1, + lineEnd: 3, + body: '', + docstring: '', + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockQuerySymbols.mockReturnValue([sym as any]) + + const { text: stdout } = runRead({ spec: `${filePath}::Example` }) + + expect(stdout).toContain('') + expect(stdout).toContain('') + expect(stdout).toContain('') + }) + it('caps an oversized symbol body and tags the truncation hint for "symbol" (#52)', () => { mockLoadConfig.mockReturnValue({ overflow_guard: { enabled: true, max_tokens: 50 }, diff --git a/tests/salesforce_languages.test.ts b/tests/salesforce_languages.test.ts new file mode 100644 index 00000000..8de42864 --- /dev/null +++ b/tests/salesforce_languages.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' + +import { parseFile } from '../src/parser.js' +import { extractApex } from '../src/languages/apex.js' +import { extractSalesforceMetadata } from '../src/languages/salesforce_metadata.js' + +function tmp(name: string, content: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tg-salesforce-test-')) + const file = path.join(dir, name) + fs.writeFileSync(file, content) + return file +} + +describe('apex adapter', () => { + it('extracts Apex classes, constructors, methods, and inner classes', () => { + const content = `public with sharing class ExampleController { + public static final String DEFAULT_VALUE = 'x'; + + public ExampleController() {} + + @AuraEnabled(cacheable=true) + public static String getValue(Id recordId) { + return DEFAULT_VALUE; + } + + global static Result getURL(Context ctx, + String returnUrl) { + return null; + } + + public class InnerDto { + public String name; + } +} +` + + const { symbols } = extractApex(content, 'ExampleController.cls') + const find = (name: string, kind: string) => + symbols.find((s) => s.name === name && s.kind === kind) + + expect(find('ExampleController', 'apex_class')?.lineEnd).toBe(19) + expect(find('ExampleController', 'apex_constructor')?.lineStart).toBe(4) + expect(find('getValue', 'apex_method')?.lineStart).toBe(6) + expect(find('getValue', 'apex_method')?.body).toContain('@AuraEnabled') + expect(find('getURL', 'apex_method')?.body).toContain('String returnUrl') + expect(find('InnerDto', 'apex_class')).toBeDefined() + }) + + it('extracts Apex triggers with their target object as context', () => { + const content = `trigger ExampleTrigger on Example_Object__c (before insert, after update) { + ExampleHandler.run(Trigger.new); +} +` + + const { symbols } = extractApex(content, 'ExampleTrigger.trigger') + expect(symbols).toHaveLength(1) + expect(symbols[0]).toMatchObject({ + name: 'ExampleTrigger', + kind: 'apex_trigger', + lineStart: 1, + lineEnd: 3, + docstring: 'Example_Object__c', + }) + }) + + it('is used by parseFile for .cls files', async () => { + const file = tmp( + 'ExampleService.cls', + `public class ExampleService { + public static void run() {} +} +`, + ) + + const result = await parseFile(file) + expect(result.language).toBe('apex') + expect(result.symbols.map((s) => s.name)).toEqual(expect.arrayContaining(['ExampleService', 'run'])) + }) +}) + +describe('salesforce metadata adapter', () => { + it('extracts custom fields with local and object-qualified names', () => { + const file = 'force-app/main/default/objects/Account/fields/Example_Field__c.field-meta.xml' + const content = ` + + Example_Field__c + + Text + +` + + const names = extractSalesforceMetadata(content, file).symbols.map((s) => s.name) + expect(names).toEqual(['Example_Field__c', 'Account.Example_Field__c']) + }) + + it('extracts platform events from CustomObject metadata', () => { + const file = 'force-app/main/default/objects/Example_Event__e/Example_Event__e.object-meta.xml' + const content = ` + HighVolume + + +` + + const symbols = extractSalesforceMetadata(content, file).symbols + expect(symbols).toHaveLength(1) + expect(symbols[0]).toMatchObject({ name: 'Example_Event__e', kind: 'sf_platform_event' }) + }) + + it('extracts validation rules with object context and qualified alias', () => { + const file = + 'force-app/main/default/objects/Example_Object__c/validationRules/Example_Rule.validationRule-meta.xml' + const content = ` + Example_Rule + true + AND(ISCHANGED(Name), NOT($Permission.Example_Bypass)) + +` + + const symbols = extractSalesforceMetadata(content, file).symbols + expect(symbols.map((s) => [s.name, s.kind, s.docstring])).toEqual([ + ['Example_Rule', 'sf_validation_rule', 'Example_Object__c'], + ['Example_Object__c.Example_Rule', 'sf_validation_rule', 'Example_Object__c'], + ]) + }) + + it('extracts flow top-level and element names', () => { + const file = 'force-app/main/default/flows/Example_Flow.flow-meta.xml' + const content = ` + + Do_Action + + + + Set_Value + + + + Check_Value + + + +` + + const symbols = extractSalesforceMetadata(content, file).symbols + expect(symbols.map((s) => [s.name, s.kind])).toEqual([ + ['Example_Flow', 'sf_flow'], + ['Do_Action', 'sf_flow_action'], + ['Set_Value', 'sf_flow_assignment'], + ['Check_Value', 'sf_flow_decision'], + ]) + expect(symbols.find((s) => s.name === 'Do_Action')?.docstring).toBe('Example_Flow') + }) + + it('extracts permission sets and custom metadata record full names', () => { + const permissionSet = extractSalesforceMetadata( + ` + + +`, + 'force-app/main/default/permissionsets/Example_Permission_Set.permissionset-meta.xml', + ).symbols + const customMetadata = extractSalesforceMetadata( + ` + + +`, + 'force-app/main/default/customMetadata/Example_Type.Example_Record.md-meta.xml', + ).symbols + + expect(permissionSet[0]).toMatchObject({ + name: 'Example_Permission_Set', + kind: 'sf_permission_set', + }) + expect(customMetadata[0]).toMatchObject({ + name: 'Example_Type.Example_Record', + kind: 'sf_custom_metadata_record', + }) + }) + + it('does not duplicate whole metadata files in symbol bodies', () => { + const content = ` + trueExamplePermission + +` + const [symbol] = extractSalesforceMetadata( + content, + 'force-app/main/default/profiles/Example.profile-meta.xml', + ).symbols + + expect(symbol).toMatchObject({ + name: 'Example', + kind: 'sf_profile', + lineStart: 1, + lineEnd: 3, + body: '', + }) + }) +})