Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### New Features

- CodeGraph now indexes **Fortran** (`.f90`, `.f95`, `.f03`, `.f08`, `.f18`, and legacy fixed-form `.f`, `.for`, `.f77`, `.ftn`). It extracts modules and programs, subroutines and functions (including `CONTAINS`-block procedures), derived types with their components, generic interfaces, `PARAMETER` constants, and `use` imports — wired with `calls`, `imports`, `contains`, and `extends` edges so `callers`, `callees`, `impact`, and `explore` work across Fortran codebases. Modern free-form is the primary target; legacy fixed-form is recognized but parses less robustly.
- Fortran type-bound procedures are first-class: `PROCEDURE :: Integrate => CpgIntegrate` bindings (including `GENERIC` aliases and `DEFERRED` bindings) become real methods on their derived type, `CALL obj%method()` call sites link to the right binding via the receiver's declared `CLASS(...)`/`TYPE(...)`, and polymorphic dispatch through an abstract base is bridged to every extending type's override — so `callers`, `callees`, and `explore` follow Fortran's object-oriented call chains end-to-end instead of stopping at the base type.
- Fortran name matching is case-insensitive, matching the language: a routine declared as `subroutine foo` and called as `CALL FOO()` resolves to the same symbol, and array accesses are no longer mistaken for function calls when they share a name with a variable.


## [1.2.0] - 2026-07-02

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ The reliable, universal payoff is **surgical context and speed**: CodeGraph coll
| **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 |
| **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes |
| **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config |
| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Fortran, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
| **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 17 frameworks |
| **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules |
| **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only |
Expand Down Expand Up @@ -714,6 +714,7 @@ is written):
| Lua | `.lua` | Full support (functions, methods with receivers, local variables, `require` imports, call edges) |
| R | `.R` `.r` | Full support (functions in every assignment form, S4/R5/R6 classes with methods, `library`/`require` imports, `source()` file references, call edges) |
| Luau | `.luau` | Full support (everything in Lua, plus `type`/`export type` aliases, typed signatures, and Roblox instance-path `require`) |
| Fortran | `.f90`, `.f95`, `.f03`, `.f08`, `.f18`, `.f`, `.for`, `.f77`, `.ftn` | Full support (modules/programs, subroutines & functions, derived types with components, type-bound procedures, generic interfaces, `PARAMETER` constants, `use` imports, `call`/function-reference edges, `extends`). Modern free-form is primary; legacy fixed-form is mapped but parses less robustly |

## Measured cross-file coverage

Expand Down
230 changes: 230 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7901,3 +7901,233 @@ GeomPoint <- ggproto("GeomPoint", Geom,
});
});
});

describe('Fortran Extraction', () => {
const SAMPLE = `
module geometry_mod
implicit none
real, parameter :: PI = 3.14159_8

type :: point
real :: x
real :: y
end type point

type, extends(point) :: point3d
real :: z
end type point3d

interface area
module procedure circle_area
end interface area

contains

function circle_area(r) result(a)
real, intent(in) :: r
real :: a
a = PI * r * r
end function circle_area

subroutine make_point(p, x, y)
type(point), intent(out) :: p
real, intent(in) :: x, y
p%x = x
p%y = y
call log_point(p)
end subroutine make_point

subroutine log_point(p)
type(point), intent(in) :: p
print *, p%x, p%y
end subroutine log_point
end module geometry_mod

program main
use geometry_mod
implicit none
type(point) :: p
call make_point(p, 1.0, 2.0)
end program main
`;

describe('Language detection', () => {
it('should detect Fortran files (free-form and legacy fixed-form)', () => {
expect(detectLanguage('solver.f90')).toBe('fortran');
expect(detectLanguage('mod_geometry.F90')).toBe('fortran');
expect(detectLanguage('legacy.f')).toBe('fortran');
expect(detectLanguage('legacy.for')).toBe('fortran');
});

it('should report Fortran as supported', () => {
expect(isLanguageSupported('fortran')).toBe(true);
expect(getSupportedLanguages()).toContain('fortran');
});
});

describe('Symbol extraction', () => {
let result: ReturnType<typeof extractFromSource>;
beforeAll(() => {
result = extractFromSource('geometry.f90', SAMPLE);
});
const byKind = (kind: string) => result.nodes.filter((n) => n.kind === kind).map((n) => n.name);

it('should extract modules and programs as module nodes', () => {
const modules = byKind('module');
expect(modules).toContain('geometry_mod');
expect(modules).toContain('main');
expect(result.nodes.find((n) => n.name === 'geometry_mod')?.language).toBe('fortran');
});

it('should extract subroutines and functions as functions', () => {
const funcs = byKind('function');
expect(funcs).toContain('circle_area');
expect(funcs).toContain('make_point');
expect(funcs).toContain('log_point');
const circle = result.nodes.find((n) => n.name === 'circle_area' && n.kind === 'function');
expect(circle?.qualifiedName).toBe('geometry_mod::circle_area');
expect(circle?.signature).toContain('circle_area(r)');
});

it('should extract derived types as structs with fields', () => {
expect(byKind('struct')).toEqual(expect.arrayContaining(['point', 'point3d']));
const fields = byKind('field');
expect(fields).toEqual(expect.arrayContaining(['x', 'y', 'z']));
const x = result.nodes.find((n) => n.name === 'x' && n.kind === 'field');
expect(x?.qualifiedName).toBe('geometry_mod::point::x');
});

it('should extract PARAMETER declarations as constants', () => {
expect(byKind('constant')).toContain('PI');
});

it('should extract named (generic) interfaces', () => {
expect(byKind('interface')).toContain('area');
});
});

describe('Reference extraction', () => {
let refs: NonNullable<ReturnType<typeof extractFromSource>['unresolvedReferences']>;
beforeAll(() => {
refs = extractFromSource('geometry.f90', SAMPLE).unresolvedReferences ?? [];
});

it('should record `use` statements as import references', () => {
const imports = refs.filter((r) => r.referenceKind === 'imports').map((r) => r.referenceName);
expect(imports).toContain('geometry_mod');
});

it('should record CALL statements as call references', () => {
const calls = refs.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
expect(calls).toContain('make_point');
expect(calls).toContain('log_point');
});

it('should record type EXTENDS as an extends reference', () => {
const ext = refs.filter((r) => r.referenceKind === 'extends').map((r) => r.referenceName);
expect(ext).toContain('point');
});
});

describe('Type-bound procedures and member calls', () => {
const TBP_SAMPLE = `
module engine_mod
implicit none

type, abstract :: base_engine_t
contains
procedure, pass :: Integrate => BaseIntegrate
procedure :: GetName
procedure(step_iface), deferred :: Step
generic :: Run => Integrate
end type base_engine_t

type, extends(base_engine_t) :: cpg_engine_t
contains
procedure :: Integrate => CpgIntegrate
end type cpg_engine_t

contains

subroutine BaseIntegrate(this)
class(base_engine_t), intent(inout) :: this
call this%GetName()
end subroutine BaseIntegrate

subroutine CpgIntegrate(this)
class(cpg_engine_t), intent(inout) :: this
end subroutine CpgIntegrate

function GetName(this) result(name)
class(base_engine_t), intent(in) :: this
character(32) :: name
name = "base"
end function GetName

subroutine driver(eng, holder)
class(base_engine_t), intent(inout) :: eng
type(cpg_engine_t) :: holder
real :: y
call eng%Integrate()
call holder%sub%Execute(1)
call eng%GetName()
y = eng%fn(2.0)
end subroutine driver
end module engine_mod
`;
let result: ReturnType<typeof extractFromSource>;
beforeAll(() => {
result = extractFromSource('engine_mod.f90', TBP_SAMPLE);
});

it('should extract bindings as method nodes scoped under the derived type', () => {
const methods = result.nodes.filter((n) => n.kind === 'method');
const names = methods.map((n) => n.name);
expect(names).toEqual(
expect.arrayContaining(['Integrate', 'GetName', 'Step', 'Run'])
);
const integrate = methods.find(
(n) => n.name === 'Integrate' && n.qualifiedName.includes('base_engine_t')
);
expect(integrate?.qualifiedName).toBe('engine_mod::base_engine_t::Integrate');
// The override on the extending type is a distinct method node
expect(
methods.some(
(n) => n.name === 'Integrate' && n.qualifiedName.includes('cpg_engine_t')
)
).toBe(true);
});

it('should link each binding to its implementation via a calls reference', () => {
const refs = result.unresolvedReferences ?? [];
const methodIds = new Map(
result.nodes.filter((n) => n.kind === 'method').map((n) => [n.id, n])
);
const bindingRefs = refs.filter(
(r) => r.referenceKind === 'calls' && methodIds.has(r.fromNodeId)
);
const byName = bindingRefs.map((r) => r.referenceName);
expect(byName).toContain('BaseIntegrate'); // explicit => target
expect(byName).toContain('CpgIntegrate');
expect(byName).toContain('GetName'); // bare binding: impl shares the name
expect(byName).toContain('Integrate'); // GENERIC :: Run => Integrate
// DEFERRED bindings have no implementation to reference
const step = result.nodes.find((n) => n.kind === 'method' && n.name === 'Step');
expect(bindingRefs.some((r) => r.fromNodeId === step?.id)).toBe(false);
});

it('should normalize %-member calls to receiver.method references', () => {
const calls = (result.unresolvedReferences ?? [])
.filter((r) => r.referenceKind === 'calls')
.map((r) => r.referenceName);
expect(calls).toContain('eng.Integrate'); // CALL eng%Integrate()
expect(calls).toContain('eng.GetName');
expect(calls).toContain('sub.Execute'); // chained holder%sub%Execute → component receiver
expect(calls).toContain('eng.fn'); // function-form member call in expression
// this/self receivers are kept (declared dummies → typed resolution)
expect(calls).toContain('this.GetName');
// No raw '%' name should survive extraction
expect(calls.some((c) => c.includes('%'))).toBe(false);
});
});
});
Loading