| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259 |
- /**
- * Java Framework Resolver
- *
- * Handles Spring Boot and general Java patterns.
- */
- import { Node } from '../../types';
- import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
- import { stripCommentsForRegex } from '../strip-comments';
- export const springResolver: FrameworkResolver = {
- name: 'spring',
- languages: ['java'],
- detect(context: ResolutionContext): boolean {
- // Check for pom.xml with Spring
- const pomXml = context.readFile('pom.xml');
- if (pomXml && (pomXml.includes('spring-boot') || pomXml.includes('springframework'))) {
- return true;
- }
- // Check for build.gradle with Spring
- const buildGradle = context.readFile('build.gradle');
- if (buildGradle && (buildGradle.includes('spring-boot') || buildGradle.includes('springframework'))) {
- return true;
- }
- const buildGradleKts = context.readFile('build.gradle.kts');
- if (buildGradleKts && (buildGradleKts.includes('spring-boot') || buildGradleKts.includes('springframework'))) {
- return true;
- }
- // Check for Spring annotations in Java files
- const allFiles = context.getAllFiles();
- for (const file of allFiles) {
- if (file.endsWith('.java')) {
- const content = context.readFile(file);
- if (content && (
- content.includes('@SpringBootApplication') ||
- content.includes('@RestController') ||
- content.includes('@Service') ||
- content.includes('@Repository')
- )) {
- return true;
- }
- }
- }
- return false;
- },
- resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
- // Pattern 1: Service references (dependency injection)
- if (ref.referenceName.endsWith('Service')) {
- const result = resolveByNameAndKind(ref.referenceName, SERVICE_KINDS, SERVICE_DIRS, context);
- if (result) {
- return {
- original: ref,
- targetNodeId: result,
- confidence: 0.85,
- resolvedBy: 'framework',
- };
- }
- }
- // Pattern 2: Repository references
- if (ref.referenceName.endsWith('Repository')) {
- const result = resolveByNameAndKind(ref.referenceName, SERVICE_KINDS, REPO_DIRS, context);
- if (result) {
- return {
- original: ref,
- targetNodeId: result,
- confidence: 0.85,
- resolvedBy: 'framework',
- };
- }
- }
- // Pattern 3: Controller references
- if (ref.referenceName.endsWith('Controller')) {
- const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, CONTROLLER_DIRS, context);
- if (result) {
- return {
- original: ref,
- targetNodeId: result,
- confidence: 0.85,
- resolvedBy: 'framework',
- };
- }
- }
- // Pattern 4: Entity/Model references
- if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) {
- const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, ENTITY_DIRS, context);
- if (result) {
- return {
- original: ref,
- targetNodeId: result,
- confidence: 0.7,
- resolvedBy: 'framework',
- };
- }
- }
- // Pattern 5: Component references
- if (ref.referenceName.endsWith('Component') || ref.referenceName.endsWith('Config')) {
- const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, COMPONENT_DIRS, context);
- if (result) {
- return {
- original: ref,
- targetNodeId: result,
- confidence: 0.8,
- resolvedBy: 'framework',
- };
- }
- }
- return null;
- },
- extract(filePath, content) {
- if (!filePath.endsWith('.java')) return { nodes: [], references: [] };
- const nodes: Node[] = [];
- const references: UnresolvedRef[] = [];
- const now = Date.now();
- const safe = stripCommentsForRegex(content, 'java');
- // Class-level @RequestMapping prefix (an @RequestMapping whose tail leads to a
- // `class`). Joined onto each method's path — and, crucially, NOT treated as a
- // route itself (the old regex did, creating one bogus class route and missing
- // every BARE method mapping like `@PostMapping` with the path on the class).
- let classPrefix = '';
- const cls = /@RequestMapping\s*\(([^)]*)\)\s*(?:@[\w.]+(?:\([^)]*\))?\s*)*(?:public\s+|final\s+|abstract\s+)*class\b/.exec(safe);
- if (cls) classPrefix = parseMappingPath(cls[1]!);
- const VERB: Record<string, string> = {
- GetMapping: 'GET', PostMapping: 'POST', PutMapping: 'PUT', PatchMapping: 'PATCH', DeleteMapping: 'DELETE',
- };
- // Verb-specific method mappings — always method-level, BARE or with a path.
- const mappingRegex = /@(GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping)\b\s*(\([^)]*\))?/g;
- let match: RegExpExecArray | null;
- while ((match = mappingRegex.exec(safe)) !== null) {
- const method = VERB[match[1]!]!;
- const sub = parseMappingPath((match[2] || '').replace(/^\(|\)$/g, ''));
- const routePath = joinPath(classPrefix, sub);
- const line = safe.slice(0, match.index).split('\n').length;
- const routeNode: Node = {
- id: `route:${filePath}:${line}:${method}:${routePath}`,
- kind: 'route',
- name: `${method} ${routePath}`,
- qualifiedName: `${filePath}::route:${routePath}`,
- filePath,
- startLine: line,
- endLine: line,
- startColumn: 0,
- endColumn: match[0].length,
- language: 'java',
- updatedAt: now,
- };
- nodes.push(routeNode);
- // Method it decorates: first declared method after (skip stacked annotations;
- // Java puts the return type before the name). Bounded so we don't grab a far one.
- const tail = safe.slice(match.index + match[0].length, match.index + match[0].length + 600);
- const methodMatch = tail.match(/\b(?:public|private|protected)\s+[^;{=]*?\s+(\w+)\s*\(/);
- if (methodMatch) {
- references.push({
- fromNodeId: routeNode.id,
- referenceName: methodMatch[1]!,
- referenceKind: 'references',
- line,
- column: 0,
- filePath,
- language: 'java',
- });
- }
- }
- // Method-level @RequestMapping (older style: `@RequestMapping(value="/x",
- // method=RequestMethod.GET)` on a method). The class-level @RequestMapping is
- // the prefix (handled above) — skip it here so it isn't double-counted.
- const reqRe = /@RequestMapping\b\s*(\([^)]*\))?/g;
- while ((match = reqRe.exec(safe)) !== null) {
- const args = (match[1] || '').replace(/^\(|\)$/g, '');
- const after = safe.slice(match.index + match[0].length, match.index + match[0].length + 600);
- if (/^\s*(?:@[\w.]+(?:\([^)]*\))?\s*)*(?:public\s+|final\s+|abstract\s+)*class\b/.test(after)) continue; // class-level prefix
- const methodMatch = after.match(/\b(?:public|private|protected)\s+[^;{=]*?\s+(\w+)\s*\(/);
- if (!methodMatch) continue;
- const verbM = args.match(/method\s*=\s*(?:RequestMethod\.)?(\w+)/);
- const method = verbM ? verbM[1]!.toUpperCase() : 'ANY';
- const routePath = joinPath(classPrefix, parseMappingPath(args));
- const line = safe.slice(0, match.index).split('\n').length;
- const routeNode: Node = {
- id: `route:${filePath}:${line}:${method}:${routePath}`,
- kind: 'route',
- name: `${method} ${routePath}`,
- qualifiedName: `${filePath}::route:${routePath}`,
- filePath, startLine: line, endLine: line, startColumn: 0, endColumn: match[0].length, language: 'java', updatedAt: now,
- };
- nodes.push(routeNode);
- references.push({
- fromNodeId: routeNode.id,
- referenceName: methodMatch[1]!,
- referenceKind: 'references',
- line, column: 0, filePath, language: 'java',
- });
- }
- return { nodes, references };
- },
- };
- // Directory patterns
- const SERVICE_DIRS = ['/service/', '/services/'];
- const REPO_DIRS = ['/repository/', '/repositories/'];
- const CONTROLLER_DIRS = ['/controller/', '/controllers/'];
- const ENTITY_DIRS = ['/entity/', '/entities/', '/model/', '/models/', '/domain/'];
- const COMPONENT_DIRS = ['/component/', '/components/', '/config/'];
- const CLASS_KINDS = new Set(['class']);
- const SERVICE_KINDS = new Set(['class', 'interface']);
- /** Path string from a mapping's args (`"/x"`, `value = "/x"`, `path = "/x"`); '' if bare. */
- function parseMappingPath(args: string): string {
- const m = args.match(/["']([^"']*)["']/);
- return m ? m[1]! : '';
- }
- /** Join a class-level prefix and a method sub-path into one normalized `/path`. */
- function joinPath(prefix: string, sub: string): string {
- const parts = [prefix, sub].map((p) => p.replace(/^\/+|\/+$/g, '')).filter(Boolean);
- return '/' + parts.join('/');
- }
- /**
- * Resolve a symbol by name using indexed queries instead of scanning all files.
- */
- function resolveByNameAndKind(
- name: string,
- kinds: Set<string>,
- preferredDirPatterns: string[],
- context: ResolutionContext,
- ): string | null {
- const candidates = context.getNodesByName(name);
- if (candidates.length === 0) return null;
- const kindFiltered = candidates.filter((n) => kinds.has(n.kind));
- if (kindFiltered.length === 0) return null;
- // Prefer candidates in framework-conventional directories
- const preferred = kindFiltered.filter((n) =>
- preferredDirPatterns.some((d) => n.filePath.includes(d))
- );
- if (preferred.length > 0) return preferred[0]!.id;
- // Fall back to any match
- return kindFiltered[0]!.id;
- }
|