diff --git a/README.md b/README.md index dbaa2ad69..97944e005 100644 --- a/README.md +++ b/README.md @@ -823,6 +823,7 @@ is written): | Swift | `.swift` | Full support | | Kotlin | `.kt`, `.kts` | Full support | | Scala | `.scala`, `.sc` | Full support (classes, traits, methods, type aliases, Scala 3 enums) | +| Haskell | `.hs` | Partial support (module export lists, qualified/unqualified imports, `Type(..)`/`hiding`, re-exports, grouped functions and signatures, data/newtype/GADT constructors, record fields, type families, typeclasses/instances, direct/infix/HOF calls; Cabal component boundaries, CPP/Template Haskell, semantic typeclass dispatch, and `.lhs`/`.hsc` are not yet modeled) | | Dart | `.dart` | Full support | | Svelte | `.svelte` | Full support (script extraction, Svelte 5 runes, SvelteKit routes) | | Vue | `.vue` | Full support (script + script-setup extraction, Nuxt page/API/middleware routes) | diff --git a/__tests__/db-perf.test.ts b/__tests__/db-perf.test.ts index 9be0803b2..c1ac572a1 100644 --- a/__tests__/db-perf.test.ts +++ b/__tests__/db-perf.test.ts @@ -344,7 +344,7 @@ describe('migration v6: dedup edges + add identity index on upgrade (#1034)', () runMigrations(raw, 5); expect(count()).toBe(2); // duplicate collapsed, the distinct `calls` edge kept - expect(getCurrentVersion(raw)).toBe(8); + expect(getCurrentVersion(raw)).toBe(9); const idx = raw .prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_edges_identity'") .get(); diff --git a/__tests__/foundation.test.ts b/__tests__/foundation.test.ts index 8ed73123e..08b9f222f 100644 --- a/__tests__/foundation.test.ts +++ b/__tests__/foundation.test.ts @@ -370,7 +370,7 @@ describe('Database Connection', () => { const version = db.getSchemaVersion(); expect(version).not.toBeNull(); - expect(version?.version).toBe(8); + expect(version?.version).toBe(9); db.close(); }); diff --git a/__tests__/haskell-extractor-round2.test.ts b/__tests__/haskell-extractor-round2.test.ts new file mode 100644 index 000000000..d893e8182 --- /dev/null +++ b/__tests__/haskell-extractor-round2.test.ts @@ -0,0 +1,686 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +function refsFrom(source: string, ownerName: string) { + const result = extractFromSource('Round2.hs', source); + const owner = result.nodes.find((node) => node.name === ownerName); + expect(owner, `missing owner ${ownerName}`).toBeDefined(); + return { + result, + refs: result.unresolvedReferences.filter((ref) => ref.fromNodeId === owner!.id), + }; +} + +describe('Haskell extractor round 2', () => { + it('normalizes qualified and left-spine class references', () => { + const source = ` +module Round2 where +import qualified A +class (Parent a b, A.Eq a) => Child a where + child :: a -> a +instance A.Show Thing where + show = A.render +data Thing = Thing deriving (A.Read) +`; + const result = extractFromSource('Round2.hs', source); + const child = result.nodes.find((node) => node.kind === 'trait' && node.name === 'Child')!; + const instance = result.nodes.find((node) => node.kind === 'class' && node.name === 'A.Show Thing')!; + const thing = result.nodes.find((node) => node.kind === 'enum' && node.name === 'Thing')!; + + expect(result.unresolvedReferences.filter((ref) => ref.fromNodeId === child.id) + .map((ref) => [ref.referenceKind, ref.referenceName])) + .toEqual(expect.arrayContaining([['extends', 'Parent'], ['extends', 'A::Eq']])); + expect(result.unresolvedReferences).toContainEqual(expect.objectContaining({ + fromNodeId: instance.id, referenceKind: 'implements', referenceName: 'A::Show', + })); + expect(result.unresolvedReferences).toContainEqual(expect.objectContaining({ + fromNodeId: thing.id, referenceKind: 'implements', referenceName: 'A::Read', + })); + }); + + it('extracts infix type, data, class, instance, and constructor declarations from their LHS', () => { + const source = ` +{-# LANGUAGE FlexibleInstances, MultiParamTypeClasses, TypeFamilies, TypeOperators #-} +module Round2 where +type a :+: b = Either a b +type family a + b +type instance Int + b = b +data family a :*: b +data instance Int :*: b = D b +class a :=: b where + convert :: a -> b +instance Int :=: Bool where + convert = check +data Pair a b = a :**: b +`; + const result = extractFromSource('Round2.hs', source); + const names = result.nodes.map((node) => [node.kind, node.name]); + + expect(names).toEqual(expect.arrayContaining([ + ['type_alias', '(:+:)'], + ['type_alias', '(+)'], + ['type_alias', 'Int + b'], + ['enum', '(:*:)'], + ['enum', 'Int :*: b'], + ['trait', '(:=:)'], + ['class', 'Int :=: Bool'], + ['method', 'convert'], + ['enum_member', '(:**:)'], + ])); + expect(result.nodes.some((node) => node.kind === 'type_alias' && node.name === 'Either')).toBe(false); + const operatorInstance = result.nodes.find((node) => node.kind === 'class' && node.name === 'Int :=: Bool')!; + expect(result.unresolvedReferences).toContainEqual(expect.objectContaining({ + fromNodeId: operatorInstance.id, referenceKind: 'implements', referenceName: ':=:', + })); + }); + + it('keeps infix operands lexical and executes view-pattern expressions', () => { + const source = ` +{-# LANGUAGE ViewPatterns #-} +module Round2 where +handler value = value +handler .@. x = handler x +view value = Just value +use value = value +f (view -> Just x) = use x +g (view config -> Nothing) = config +qualifiedView (Views.view -> Just x) = use x +h value = case value of + Nothing -> use value + Just y -> use y +`; + const result = extractFromSource('Round2.hs', source); + const operator = result.nodes.find((node) => node.name === '(.@.)')!; + expect(result.unresolvedReferences.some((ref) => ref.fromNodeId === operator.id + && ['handler', 'x'].includes(ref.referenceName))).toBe(false); + + const f = result.nodes.find((node) => node.name === 'f')!; + expect(result.unresolvedReferences.filter((ref) => ref.fromNodeId === f.id) + .map((ref) => [ref.referenceKind, ref.referenceName])) + .toEqual(expect.arrayContaining([ + ['calls', 'view'], ['references', 'Just'], ['calls', 'use'], + ])); + const g = result.nodes.find((node) => node.name === 'g')!; + expect(result.unresolvedReferences.filter((ref) => ref.fromNodeId === g.id) + .map((ref) => [ref.referenceKind, ref.referenceName])) + .toEqual(expect.arrayContaining([['calls', 'view'], ['references', 'Nothing']])); + const qualifiedView = result.nodes.find((node) => node.name === 'qualifiedView')!; + expect(result.unresolvedReferences).toContainEqual(expect.objectContaining({ + fromNodeId: qualifiedView.id, referenceKind: 'calls', referenceName: 'Views::view', + })); + const h = result.nodes.find((node) => node.name === 'h')!; + expect(result.unresolvedReferences.filter((ref) => ref.fromNodeId === h.id + && ref.referenceKind === 'references').map((ref) => ref.referenceName)) + .toEqual(expect.arrayContaining(['Nothing', 'Just'])); + }); + + it('distinguishes record labels, explicit binders, and field puns', () => { + const source = ` +{-# LANGUAGE NamedFieldPuns #-} +module Round2 where +data Record = Record { field :: Int -> Int } +explicit (Record { field = local }) = field (local 1) +punned Record { field } = field 1 +`; + const result = extractFromSource('Round2.hs', source); + const explicit = result.nodes.find((node) => node.name === 'explicit')!; + const punned = result.nodes.find((node) => node.name === 'punned')!; + expect(result.unresolvedReferences).toContainEqual(expect.objectContaining({ + fromNodeId: explicit.id, referenceKind: 'calls', referenceName: 'field', + })); + expect(result.unresolvedReferences.some((ref) => ref.fromNodeId === explicit.id + && ref.referenceName === 'local')).toBe(false); + expect(result.unresolvedReferences.some((ref) => ref.fromNodeId === punned.id + && ref.referenceName === 'field' && ref.referenceKind === 'calls')).toBe(false); + }); + + it('extracts symbolic and record pattern synonyms and their constructor dependencies', () => { + const source = ` +{-# LANGUAGE PatternSynonyms #-} +module Round2 where +pattern x :++: xs = x : xs +pattern Present { presentValue } = Just presentValue +`; + const result = extractFromSource('Round2.hs', source); + const symbolic = result.nodes.find((node) => node.kind === 'enum_member' && node.name === '(:++:)')!; + const record = result.nodes.find((node) => node.kind === 'enum_member' && node.name === 'Present')!; + expect(symbolic).toBeDefined(); + expect(record).toBeDefined(); + expect(result.unresolvedReferences).toContainEqual(expect.objectContaining({ + fromNodeId: symbolic.id, referenceKind: 'references', referenceName: ':', + })); + expect(result.unresolvedReferences).toContainEqual(expect.objectContaining({ + fromNodeId: record.id, referenceKind: 'references', referenceName: 'Just', + })); + }); + + it('keeps signatures across pragmas and captures sections and qualified prefix operators', () => { + const source = ` +module Round2 where +f :: Int -> Int +{-# INLINE f #-} +f = id +increment = (+ 1) +mapped xs = map (\`op\` 2) xs +qualified = (L.<+>) +qualifiedSection = (1 L.<+>) +`; + const result = extractFromSource('Round2.hs', source); + expect(result.nodes.find((node) => node.name === 'f')).toEqual(expect.objectContaining({ + kind: 'function', signature: 'f :: Int -> Int', + })); + expect(result.nodes.find((node) => node.name === 'increment')?.kind).toBe('function'); + expect(result.unresolvedReferences).toEqual(expect.arrayContaining([ + expect.objectContaining({ referenceKind: 'function_ref', referenceName: '+' }), + expect.objectContaining({ referenceKind: 'function_ref', referenceName: 'op' }), + expect.objectContaining({ referenceKind: 'function_ref', referenceName: 'L::<+>' }), + ])); + const qualifiedSection = result.nodes.find((node) => node.name === 'qualifiedSection')!; + expect(result.unresolvedReferences).toContainEqual(expect.objectContaining({ + fromNodeId: qualifiedSection.id, referenceKind: 'function_ref', referenceName: 'L::<+>', + })); + }); + + it('applies class-child export semantics to associated data families only', () => { + const source = ` +{-# LANGUAGE TypeFamilies #-} +module Round2 (C(..), FamilyInt) where +class C a where + data Family a +instance C Int where + data Family Int = FamilyInt +`; + const result = extractFromSource('Round2.hs', source); + expect(result.nodes.find((node) => node.kind === 'enum' && node.name === 'Family')) + .toEqual(expect.objectContaining({ isExported: true })); + expect(result.nodes.find((node) => node.kind === 'enum' && node.name === 'Family' + && node.qualifiedName.includes('C Int'))) + .toEqual(expect.objectContaining({ isExported: false })); + expect(result.nodes.find((node) => node.kind === 'enum_member' && node.name === 'FamilyInt')) + .toEqual(expect.objectContaining({ isExported: true })); + }); + + it('indexes pragma-separated signatures in linear time', () => { + const declarations = Array.from({ length: 400 }, (_, index) => [ + `f${index} :: Int -> Int`, + `{-# INLINE f${index} #-}`, + `f${index} = id`, + ].join('\n')).join('\n'); + const started = performance.now(); + const result = extractFromSource('ManySignatures.hs', `module ManySignatures where\n${declarations}\n`); + const durationMs = performance.now() - started; + expect(result.nodes.filter((node) => node.kind === 'function' && /^f\d+$/.test(node.name))) + .toHaveLength(400); + expect(durationMs).toBeLessThan(4_000); + }); + + it('extracts foreign imports and links foreign exports without duplicating bindings', () => { + const source = ` +{-# LANGUAGE ForeignFunctionInterface #-} +module Round2 (c_sin, run) where +foreign import ccall unsafe "sin" c_sin :: Double -> IO Double +foreign export ccall "hs_run" run :: Int -> IO () +run _ = pure () +`; + const result = extractFromSource('Round2.hs', source); + expect(result.nodes.find((node) => node.name === 'c_sin')).toEqual(expect.objectContaining({ + kind: 'function', + isExported: true, + decorators: expect.arrayContaining(['haskell-foreign-import']), + })); + expect(result.nodes.filter((node) => node.name === 'run')).toHaveLength(1); + expect(result.unresolvedReferences).toContainEqual(expect.objectContaining({ + referenceKind: 'function_ref', referenceName: 'run', + })); + }); + + it('records the explicit owner of bundled pattern-synonym exports', () => { + const source = ` +{-# LANGUAGE PatternSynonyms #-} +module Round2 (T(P)) where +data T = MkT +pattern P = MkT +`; + const result = extractFromSource('Round2.hs', source); + expect(result.nodes.find((node) => node.name === 'P')).toEqual(expect.objectContaining({ + kind: 'enum_member', + isExported: true, + decorators: expect.arrayContaining([ + 'haskell-pattern-synonym', 'haskell-export-parent:T', + ]), + })); + }); + + it('records bare unqualified and qualified actions on the RHS of monadic binds', () => { + const source = ` +module Round2 where +run = do + first <- action + second <- Actions.next + pure (first, second) +`; + const { refs } = refsFrom(source, 'run'); + expect(refs).toEqual(expect.arrayContaining([ + expect.objectContaining({ referenceKind: 'calls', referenceName: 'action' }), + expect.objectContaining({ referenceKind: 'calls', referenceName: 'Actions::next' }), + ])); + }); + + it('attaches standalone prefix and symbolic pattern-synonym signatures', () => { + const source = ` +{-# LANGUAGE PatternSynonyms, TypeOperators #-} +module Round2 where +-- | Present documentation. +pattern Present :: a -> Maybe a +pattern Present x = Just x +pattern (:++:) :: a -> [a] -> [a] +pattern x :++: xs = x : xs +`; + const result = extractFromSource('Round2.hs', source); + expect(result.nodes.find((node) => node.name === 'Present')).toEqual(expect.objectContaining({ + signature: 'pattern Present :: a -> Maybe a', + docstring: expect.stringContaining('Present documentation'), + startLine: 5, + })); + expect(result.nodes.find((node) => node.name === '(:++:)')).toEqual(expect.objectContaining({ + signature: 'pattern (:++:) :: a -> [a] -> [a]', + startLine: 7, + })); + }); + + it('treats future mdo binders as recursive without changing ordinary do scope', () => { + const source = ` +{-# LANGUAGE RecursiveDo #-} +module Round2 where +f x = x +value = 0 +getFunction = pure id +getValue = pure 1 +recursive = mdo + result <- f value + f <- getFunction + value <- getValue + pure result +sequential = do + result <- f value + f <- getFunction + pure result +recursiveBlock = do + rec + result <- f value + f <- getFunction + value <- getValue + pure result +qualifiedRecursive = M.mdo + result <- use result + M.return result +`; + const recursive = refsFrom(source, 'recursive').refs; + expect(recursive.some((ref) => ['f', 'value'].includes(ref.referenceName))).toBe(false); + const sequential = refsFrom(source, 'sequential').refs; + expect(sequential).toEqual(expect.arrayContaining([ + expect.objectContaining({ referenceKind: 'calls', referenceName: 'f' }), + expect.objectContaining({ referenceName: 'value' }), + ])); + const recursiveBlock = refsFrom(source, 'recursiveBlock').refs; + expect(recursiveBlock.some((ref) => ['f', 'value'].includes(ref.referenceName))).toBe(false); + const qualifiedRecursive = refsFrom(source, 'qualifiedRecursive').refs; + expect(qualifiedRecursive.some((ref) => ref.referenceName === 'result')).toBe(false); + }); + + it('links statically named actions executed by monadic and applicative sequence operators', () => { + const source = ` +module Round2 where +load = pure 1 +next x = pure x +finish = pure () +bind = load >>= next +flipped = next =<< load +sequenceBoth = load >> finish +applicativeBoth = load *> finish +applied = wrapped <*> load +mapped = next <$> load +flippedMap = load <&> next +replacedRight = load $> 1 +replacedLeft = 1 <$ load +prefixApplied = (<*>) wrapped load +prefixSequence = (>>) load finish +prefixBind = (>>=) load next +prefixFlipped = (=<<) next load +qualifiedPrefix = (Custom.<*>) wrapped load +alternative = load <|> finish +reverseApply = load <**> wrapped +prefixAlternative = (<|>) load finish +prefixReverseApply = (<**>) load wrapped +parameter load finish = load >> finish +`; + for (const [owner, names] of [ + ['bind', ['load']], + ['flipped', ['load']], + ['sequenceBoth', ['load', 'finish']], + ['applicativeBoth', ['load', 'finish']], + ['applied', ['wrapped', 'load']], + ['mapped', ['load']], + ['flippedMap', ['load']], + ['replacedRight', ['load']], + ['replacedLeft', ['load']], + ['prefixApplied', ['wrapped', 'load']], + ['prefixSequence', ['load', 'finish']], + ['prefixBind', ['load']], + ['prefixFlipped', ['load']], + ['qualifiedPrefix', ['wrapped', 'load']], + ['alternative', ['load', 'finish']], + ['reverseApply', ['load', 'wrapped']], + ['prefixAlternative', ['load', 'finish']], + ['prefixReverseApply', ['load', 'wrapped']], + ] as const) { + const refs = refsFrom(source, owner).refs; + for (const name of names) { + expect(refs).toContainEqual(expect.objectContaining({ + referenceKind: 'calls', referenceName: name, + })); + } + } + const parameter = refsFrom(source, 'parameter').refs; + expect(parameter.some((ref) => ['load', 'finish'].includes(ref.referenceName))).toBe(false); + }); + + it('captures every nested constructor used by bidirectional pattern synonyms', () => { + const source = ` +{-# LANGUAGE PatternSynonyms #-} +module Round2 where +pattern Nested x = Outer (Inner x) +pattern Match x <- Outer (Inner x) where + Match x = Build (Wrap x) +`; + const result = extractFromSource('Round2.hs', source); + const nested = result.nodes.find((node) => node.name === 'Nested')!; + const match = result.nodes.find((node) => node.name === 'Match')!; + expect(result.unresolvedReferences.filter((ref) => ref.fromNodeId === nested.id) + .map((ref) => ref.referenceName)).toEqual(expect.arrayContaining(['Outer', 'Inner'])); + expect(result.unresolvedReferences.filter((ref) => ref.fromNodeId === match.id) + .map((ref) => ref.referenceName)) + .toEqual(expect.arrayContaining(['Outer', 'Inner', 'Build', 'Wrap'])); + }); + + it('attaches grouped standalone pattern-synonym signatures to every binding', () => { + const source = ` +{-# LANGUAGE PatternSynonyms, TypeOperators #-} +module Round2 where +-- | Group docs. +pattern P, Q :: a -> Maybe a +pattern P x = Just x +pattern Q x = Just x +pattern (:++:), (:--:) :: a -> a -> (a, a) +pattern x :++: y = (x, y) +pattern x :--: y = (x, y) +`; + const result = extractFromSource('Round2.hs', source); + for (const name of ['P', 'Q']) { + expect(result.nodes.find((node) => node.name === name)).toEqual(expect.objectContaining({ + signature: 'pattern P, Q :: a -> Maybe a', + startLine: 5, + })); + } + expect(result.nodes.find((node) => node.name === 'P')?.docstring).toContain('Group docs'); + for (const name of ['(:++:)', '(:--:)']) { + expect(result.nodes.find((node) => node.name === name)?.signature) + .toBe('pattern (:++:), (:--:) :: a -> a -> (a, a)'); + } + }); + + it('emits one semantic edge for constructor expressions', () => { + const source = ` +{-# LANGUAGE TypeOperators #-} +module Round2 where +data Zero = Zero +data Pair a b = a :*: b +a = Zero +b = Round2.Zero +c = 1 :*: 2 +d = consume Nothing +e = (:*:) 1 True +mapped = map Just [1] +leftSection = (1 :*:) +rightSection = (:*: 2) +`; + const result = extractFromSource('Round2.hs', source); + for (const [owner, reference] of [['a', 'Zero'], ['b', 'Round2::Zero']] as const) { + const refs = refsFrom(source, owner).refs.filter((ref) => ref.referenceName === reference); + expect(refs).toEqual([expect.objectContaining({ referenceKind: 'references' })]); + } + const infixRefs = result.unresolvedReferences.filter((ref) => { + const owner = result.nodes.find((node) => node.id === ref.fromNodeId); + return owner?.name === 'c' && ref.referenceName === ':*:'; + }); + expect(infixRefs).toEqual([expect.objectContaining({ referenceKind: 'calls' })]); + expect(refsFrom(source, 'd').refs.filter((ref) => ref.referenceName === 'Nothing')) + .toEqual([expect.objectContaining({ referenceKind: 'references' })]); + expect(refsFrom(source, 'e').refs.filter((ref) => ref.referenceName === 'True')) + .toEqual([expect.objectContaining({ referenceKind: 'references' })]); + expect(refsFrom(source, 'mapped').refs.filter((ref) => ref.referenceName === 'Just')) + .toEqual([expect.objectContaining({ referenceKind: 'calls' })]); + for (const owner of ['leftSection', 'rightSection']) { + expect(refsFrom(source, owner).refs.filter((ref) => ref.referenceName === ':*:')) + .toEqual([expect.objectContaining({ referenceKind: 'function_ref' })]); + } + }); + + it('extracts every constructor in grouped GADT signatures', () => { + const source = ` +{-# LANGUAGE GADTs, TypeOperators #-} +module Round2 where +data U a where + U1, U2 :: U Int +data T a where + (:++:), (:--:) :: a -> a -> T a +`; + const result = extractFromSource('Round2.hs', source); + expect(result.nodes.filter((node) => node.kind === 'enum_member').map((node) => node.name)) + .toEqual(expect.arrayContaining(['U1', 'U2', '(:++:)', '(:--:)'])); + }); + + it('keeps positional newtype payloads out of fields and expands grouped selectors', () => { + const source = ` +module Round2 where +newtype Wrap a = Wrap a +newtype Pair a = Pair (a, a) +data Record = Record { x, y :: Int } +newtype NewRecord = NewRecord { left, right :: Int } +`; + const fields = extractFromSource('Round2.hs', source).nodes + .filter((node) => node.kind === 'field').map((node) => node.name); + expect(fields).toEqual(expect.arrayContaining(['x', 'y', 'left', 'right'])); + expect(fields).not.toEqual(expect.arrayContaining(['a'])); + }); + + it('materializes record pattern-synonym selectors as module bindings', () => { + const source = ` +{-# LANGUAGE PatternSynonyms #-} +module Round2 (pattern Present, presentValue, use) where +pattern Present { presentValue } = Just presentValue +use x = presentValue x +`; + const result = extractFromSource('Round2.hs', source); + expect(result.nodes.find((node) => node.name === 'presentValue')).toEqual(expect.objectContaining({ + kind: 'field', + qualifiedName: 'Round2::presentValue', + isExported: true, + decorators: expect.arrayContaining(['haskell-pattern-selector']), + })); + }); + + it('extracts and deduplicates quantified superclass dependencies', () => { + const source = ` +{-# LANGUAGE ConstraintKinds, QuantifiedConstraints #-} +module Round2 where +class (Table t, forall f. SimpleKey' t f) => SimpleKey t where +class (forall x. A.Eq x => A.Eq (f x)) => Qualified f where +class c a => ParamSuper c a where +class (forall x. c x => d x) => QuantifiedVars c d where +class (F a ~ b) => Equality a b where +class (forall x. F x ~ G x) => QuantifiedEquality f where +`; + const result = extractFromSource('Round2.hs', source); + const refsFor = (owner: string) => { + const node = result.nodes.find((candidate) => candidate.name === owner)!; + return result.unresolvedReferences.filter((ref) => ref.fromNodeId === node.id + && ref.referenceKind === 'extends').map((ref) => ref.referenceName); + }; + expect(refsFor('SimpleKey')).toEqual(expect.arrayContaining(['Table', "SimpleKey'"])); + expect(refsFor('Qualified')).toEqual(['A::Eq']); + expect(refsFor('ParamSuper')).toEqual([]); + expect(refsFor('QuantifiedVars')).toEqual([]); + expect(refsFor('Equality')).toEqual([]); + expect(refsFor('QuantifiedEquality')).toEqual([]); + }); + + it('captures infix patterns and bare constructors in ordinary expressions', () => { + const source = ` +{-# LANGUAGE PatternSynonyms #-} +module Round2 where +pattern x :++: xs = x : xs +match (x :++: xs) = NothingX +listed = [NothingX] +chosen flag = if flag then NothingX else OtherX +guarded x | x == NothingX = OtherX | otherwise = NothingX +multi x = if | x == NothingX -> OtherX +caseGuard x = case x of y | y == NothingX -> OtherX +`; + const result = extractFromSource('Round2.hs', source); + const refsFor = (owner: string) => { + const node = result.nodes.find((candidate) => candidate.name === owner)!; + return result.unresolvedReferences.filter((ref) => ref.fromNodeId === node.id) + .map((ref) => ref.referenceName); + }; + expect(refsFor('match')).toEqual(expect.arrayContaining([':++:', 'NothingX'])); + expect(refsFor('listed')).toContain('NothingX'); + expect(refsFor('chosen')).toEqual(expect.arrayContaining(['NothingX', 'OtherX'])); + for (const owner of ['guarded', 'multi', 'caseGuard']) { + expect(refsFor(owner)).toEqual(expect.arrayContaining(['NothingX', 'OtherX'])); + } + }); + + it('captures each selector in OverloadedRecordDot projections', () => { + const source = ` +{-# LANGUAGE OverloadedRecordDot #-} +module Round2 where +run payload = payload.event +nested value = value.userInfo.email +selector = (.event) +nestedSelector = (.userInfo.email) +`; + expect(refsFrom(source, 'run').refs).toContainEqual(expect.objectContaining({ + referenceKind: 'references', referenceName: 'event', + })); + expect(refsFrom(source, 'nested').refs.filter((ref) => ref.referenceKind === 'references') + .map((ref) => ref.referenceName)).toEqual(expect.arrayContaining(['userInfo', 'email'])); + expect(refsFrom(source, 'selector').refs.map((ref) => ref.referenceName)).toEqual(['event']); + expect(refsFrom(source, 'nestedSelector').refs.map((ref) => ref.referenceName)) + .toEqual(['userInfo', 'email']); + }); + + it('captures ordinary and overloaded record-update field paths', () => { + const source = ` +{-# LANGUAGE OverloadedRecordUpdate #-} +module Round2 where +plain r = r { field = 1 } +nested r = r { user.name = "x" } +`; + expect(refsFrom(source, 'plain').refs.map((ref) => ref.referenceName)).toContain('field'); + expect(refsFrom(source, 'nested').refs.map((ref) => ref.referenceName)) + .toEqual(expect.arrayContaining(['user', 'name'])); + }); + + it('walks matcher and builder expressions owned by pattern synonyms', () => { + const source = ` +{-# LANGUAGE PatternSynonyms, ViewPatterns #-} +module Round2 where +pattern P x <- (view -> Just x) +pattern Q x <- (Views.view config -> Outer (Inner x)) where + Q x = Build (make x) +`; + expect(refsFrom(source, 'P').refs).toEqual(expect.arrayContaining([ + expect.objectContaining({ referenceKind: 'calls', referenceName: 'view' }), + expect.objectContaining({ referenceKind: 'references', referenceName: 'Just' }), + ])); + const qRefs = refsFrom(source, 'Q').refs; + expect(qRefs).toEqual(expect.arrayContaining([ + expect.objectContaining({ referenceKind: 'calls', referenceName: 'Views::view' }), + expect.objectContaining({ referenceKind: 'references', referenceName: 'Outer' }), + expect.objectContaining({ referenceKind: 'references', referenceName: 'Inner' }), + expect.objectContaining({ referenceKind: 'calls', referenceName: 'Build' }), + expect.objectContaining({ referenceKind: 'calls', referenceName: 'make' }), + ])); + expect(qRefs.some((ref) => ref.referenceName === 'x')).toBe(false); + }); + + it('preserves dots that belong to symbolic operator names', () => { + const source = ` +{-# LANGUAGE TypeOperators #-} +module Round2 where +(<.>) x y = x +(.+.) x y = y +f x y = x <.> y +g = (<.>) +h = (1 .+.) +i = (L.<.>) +`; + expect(refsFrom(source, 'f').refs).toContainEqual(expect.objectContaining({ + referenceKind: 'calls', referenceName: '<.>', + })); + expect(refsFrom(source, 'g').refs).toContainEqual(expect.objectContaining({ + referenceKind: 'function_ref', referenceName: '(<.>)', + })); + expect(refsFrom(source, 'h').refs).toContainEqual(expect.objectContaining({ + referenceKind: 'function_ref', referenceName: '.+.', + })); + expect(refsFrom(source, 'i').refs).toContainEqual(expect.objectContaining({ + referenceKind: 'function_ref', referenceName: 'L::<.>', + })); + }); + + it('materializes every variable from top-level pattern bindings', () => { + const source = ` +module Round2 where +(x, y) = pair +Just z = maybeZ +Record { field = selected } = record +[a, b] = items +clientA :: Int -> Int +clientB :: Int -> Int +(clientA, clientB) = clients +use = consume x y z selected a b +`; + const result = extractFromSource('Round2.hs', source); + for (const name of ['x', 'y', 'z', 'selected', 'a', 'b']) { + expect(result.nodes).toContainEqual(expect.objectContaining({ kind: 'constant', name })); + } + for (const name of ['clientA', 'clientB']) { + expect(result.nodes).toContainEqual(expect.objectContaining({ + kind: 'function', name, signature: `${name} :: Int -> Int`, + })); + } + const z = result.nodes.find((node) => node.name === 'z')!; + expect(result.unresolvedReferences).toContainEqual(expect.objectContaining({ + fromNodeId: z.id, referenceKind: 'references', referenceName: 'Just', + })); + }); + + it('keeps where-bound constants lexical under point bindings', () => { + const source = ` +module Round2 where +f = consume x where x = Zero +g = consume x where (x, y) = pair +`; + for (const owner of ['f', 'g']) { + expect(refsFrom(source, owner).refs.some((ref) => ref.referenceName === 'x')).toBe(false); + } + }); +}); diff --git a/__tests__/haskell-resolution-round2.test.ts b/__tests__/haskell-resolution-round2.test.ts new file mode 100644 index 000000000..65c032802 --- /dev/null +++ b/__tests__/haskell-resolution-round2.test.ts @@ -0,0 +1,687 @@ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +describe('Haskell resolution round 2', () => { + let tmpDir: string | undefined; + + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + const createGraph = async (files: Record): Promise => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-haskell-r2-')); + for (const [filePath, content] of Object.entries(files)) { + const fullPath = path.join(tmpDir, filePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); + } + const graph = CodeGraph.initSync(tmpDir); + await graph.indexAll(); + return graph; + }; + + const outgoingTargets = (graph: CodeGraph, owner: string, filePath: string) => { + const node = graph.getNodesByName(owner).find((candidate) => candidate.filePath === filePath)!; + return graph.getOutgoingEdges(node.id).map((edge) => ({ + edge, + target: graph.getNode(edge.target)!, + })); + }; + + it('resolves wildcard, qualified, prefix, and dash-prefixed symbolic operators', async () => { + const graph = await createGraph({ + 'Ops.hs': [ + 'module Ops ((<+>), (-->) ) where', + '(<+>) x _ = x', + '(-->) x _ = x', + ].join('\n'), + 'Wildcard.hs': [ + 'module Wildcard where', + 'import Ops', + 'wild x y = x <+> y', + ].join('\n'), + 'Qualified.hs': [ + 'module Qualified where', + 'import qualified Ops as O', + 'qualified x y = x O.<+> y', + 'prefix x y = (O.<+>) x y', + ].join('\n'), + 'Explicit.hs': [ + 'module Explicit where', + 'import Ops (', + ' (<+>), --- an ordinary three-dash comment, not an operator', + ' (-->)', + ')', + 'arrow x y = x --> y', + ].join('\n'), + }); + try { + const operator = graph.getNodesByName('(<+>)').find((node) => node.filePath === 'Ops.hs')!; + for (const [owner, filePath] of [ + ['wild', 'Wildcard.hs'], + ['qualified', 'Qualified.hs'], + ['prefix', 'Qualified.hs'], + ] as const) { + expect(outgoingTargets(graph, owner, filePath) + .some(({ edge, target }) => edge.kind === 'calls' && target.id === operator.id)).toBe(true); + } + const arrow = graph.getNodesByName('(-->)').find((node) => node.filePath === 'Ops.hs')!; + expect(outgoingTargets(graph, 'arrow', 'Explicit.hs') + .some(({ edge, target }) => edge.kind === 'calls' && target.id === arrow.id)).toBe(true); + } finally { + graph.destroy(); + } + }); + + it('keeps instance implementations lexical only inside their own equation', async () => { + const graph = await createGraph({ + 'Class.hs': [ + 'module Class (C (..)) where', + 'class C a where', + ' action :: a -> a', + ].join('\n'), + 'Consumer.hs': [ + 'module Consumer where', + 'import Class (C (..))', + 'instance C Int where', + ' action 0 = 0', + ' action x = action (x - 1)', + 'use x = action x', + ].join('\n'), + }); + try { + const selector = graph.getNodesByName('action') + .find((node) => node.filePath === 'Class.hs')!; + const implementation = graph.getNodesByName('action') + .find((node) => node.filePath === 'Consumer.hs')!; + expect(outgoingTargets(graph, 'use', 'Consumer.hs') + .some(({ edge, target }) => edge.kind === 'calls' && target.id === selector.id)).toBe(true); + expect(outgoingTargets(graph, 'use', 'Consumer.hs') + .some(({ target }) => target.id === implementation.id)).toBe(false); + expect(graph.getOutgoingEdges(implementation.id) + .some((edge) => edge.kind === 'calls' && edge.target === implementation.id)).toBe(true); + } finally { + graph.destroy(); + } + }); + + it('resolves local, imported, and self-qualified record selectors and symbols', async () => { + const graph = await createGraph({ + 'Person.hs': [ + 'module Person where', + 'data Person = Person { personName :: String }', + 'helper x = x', + 'local p = personName p', + 'selfField p = Person.personName p', + 'selfFunction = Person.helper 1', + 'selfConstructor = Person.Person "Ada"', + ].join('\n'), + 'Consumer.hs': [ + 'module Consumer where', + 'import Person (Person (..))', + 'mapped xs = map personName xs', + ].join('\n'), + }); + try { + const field = graph.getNodesByName('personName').find((node) => node.filePath === 'Person.hs')!; + const helper = graph.getNodesByName('helper').find((node) => node.filePath === 'Person.hs')!; + const constructor = graph.getNodesByName('Person') + .find((node) => node.filePath === 'Person.hs' && node.kind === 'enum_member')!; + for (const owner of ['local', 'selfField']) { + expect(outgoingTargets(graph, owner, 'Person.hs') + .some(({ target }) => target.id === field.id)).toBe(true); + } + expect(outgoingTargets(graph, 'selfFunction', 'Person.hs') + .some(({ target }) => target.id === helper.id)).toBe(true); + expect(outgoingTargets(graph, 'selfConstructor', 'Person.hs') + .some(({ target }) => target.id === constructor.id)).toBe(true); + expect(outgoingTargets(graph, 'mapped', 'Consumer.hs') + .some(({ edge, target }) => edge.kind === 'references' && target.id === field.id)).toBe(true); + } finally { + graph.destroy(); + } + }); + + it('uses source position to distinguish repeated local helper scopes', async () => { + const graph = await createGraph({ + 'Main.hs': [ + 'module Main where', + 'run b = if b', + ' then let helper x = x', + ' in helper 1', + ' else let helper x = x + 1', + ' in helper 2', + 'equations True = helper 1 where helper x = x', + 'equations False = helper 2 where helper x = x + 1', + ].join('\n'), + }); + try { + const assertPositionedCalls = (owner: string, expected: Array<[number, number]>) => { + const calls = outgoingTargets(graph, owner, 'Main.hs') + .filter(({ edge, target }) => edge.kind === 'calls' && target.name === 'helper') + .map(({ edge, target }) => [edge.line, target.startLine] as [number | undefined, number]); + expect(calls).toEqual(expect.arrayContaining(expected)); + }; + assertPositionedCalls('run', [[4, 3], [6, 5]]); + assertPositionedCalls('equations', [[7, 7], [8, 8]]); + } finally { + graph.destroy(); + } + }); + + it('resolves data-family instance constructors through Family(..)', async () => { + const graph = await createGraph({ + 'Family.hs': [ + '{-# LANGUAGE TypeFamilies #-}', + 'module Family (Family (..)) where', + 'data family Family a', + 'data instance Family Int = FamilyInt Int', + ].join('\n'), + 'Consumer.hs': [ + 'module Consumer where', + 'import Family (Family (..))', + 'run = FamilyInt 1', + ].join('\n'), + }); + try { + const constructor = graph.getNodesByName('FamilyInt') + .find((node) => node.filePath === 'Family.hs')!; + expect(outgoingTargets(graph, 'run', 'Consumer.hs') + .some(({ edge, target }) => edge.kind === 'calls' && target.id === constructor.id)).toBe(true); + } finally { + graph.destroy(); + } + }); + + it('keeps re-export cycle detection local to each alternative branch', async () => { + const graph = await createGraph({ + 'Origin.hs': 'module Origin (foo) where\nfoo x = x\n', + 'A.hs': 'module A (module Origin) where\nimport Origin\n', + 'B.hs': 'module B (module Origin) where\nimport Origin\n', + 'Facade.hs': [ + 'module Facade (module A, module B) where', + 'import A hiding (foo)', + 'import B', + ].join('\n'), + 'Consumer.hs': 'module Consumer where\nimport Facade (foo)\nrun = foo 1\n', + }); + try { + const foo = graph.getNodesByName('foo').find((node) => node.filePath === 'Origin.hs')!; + expect(outgoingTargets(graph, 'run', 'Consumer.hs') + .some(({ target }) => target.id === foo.id)).toBe(true); + } finally { + graph.destroy(); + } + }); + + it('follows Haskell re-export chains deeper than eight modules', async () => { + const files: Record = { + 'M0.hs': 'module M0 (foo) where\nfoo x = x\n', + }; + for (let index = 1; index <= 12; index++) { + files[`M${index}.hs`] = [ + `module M${index} (module M${index - 1}) where`, + `import M${index - 1}`, + ].join('\n'); + } + files['Consumer.hs'] = 'module Consumer where\nimport M12 (foo)\nrun = foo 1\n'; + const graph = await createGraph(files); + try { + const foo = graph.getNodesByName('foo').find((node) => node.filePath === 'M0.hs')!; + expect(outgoingTargets(graph, 'run', 'Consumer.hs') + .some(({ target }) => target.id === foo.id)).toBe(true); + } finally { + graph.destroy(); + } + }); + + it('invalidates unchanged consumers when an intermediate re-export changes or disappears', async () => { + const graph = await createGraph({ + 'A.hs': 'module A (foo) where\nfoo x = x\n', + 'B.hs': 'module B (foo) where\nfoo x = x + 1\n', + 'Facade.hs': 'module Facade (foo) where\nimport A (foo)\n', + 'Consumer.hs': 'module Consumer where\nimport Facade (foo)\nrun = foo 1\n', + }); + const targetFile = () => outgoingTargets(graph, 'run', 'Consumer.hs') + .find(({ edge, target }) => edge.kind === 'calls' && target.name === 'foo')?.target.filePath; + try { + expect(targetFile()).toBe('A.hs'); + + fs.writeFileSync(path.join(tmpDir!, 'Facade.hs'), [ + 'module Facade (foo) where', + 'import B (foo)', + '-- switched source', + ].join('\n')); + await graph.sync(); + expect(targetFile()).toBe('B.hs'); + + fs.writeFileSync(path.join(tmpDir!, 'Facade.hs'), 'module Facade () where\nimport B (foo)\n'); + await graph.sync(); + expect(targetFile()).toBeUndefined(); + + fs.unlinkSync(path.join(tmpDir!, 'Facade.hs')); + await graph.sync(); + expect(targetFile()).toBeUndefined(); + + fs.writeFileSync(path.join(tmpDir!, 'Facade.hs'), [ + 'module Facade (foo) where', + 'import B (foo)', + '-- restored facade', + ].join('\n')); + await graph.sync(); + expect(targetFile()).toBe('B.hs'); + } finally { + graph.destroy(); + } + }); + + it('re-resolves imports when a newly added exact module path beats an old candidate', async () => { + const graph = await createGraph({ + 'pkg/src/Wrong.hs': 'module Foo.Bar where\ntarget x = x\n', + 'pkg/app/Consumer.hs': 'module Consumer where\nimport Foo.Bar (target)\nrun = target 1\n', + }); + const targetFile = () => outgoingTargets(graph, 'run', 'pkg/app/Consumer.hs') + .find(({ edge, target }) => edge.kind === 'calls' && target.name === 'target')?.target.filePath; + try { + expect(targetFile()).toBe('pkg/src/Wrong.hs'); + const exactPath = path.join(tmpDir!, 'pkg/src/Foo/Bar.hs'); + fs.mkdirSync(path.dirname(exactPath), { recursive: true }); + fs.writeFileSync(exactPath, 'module Foo.Bar where\ntarget x = x + 1\n'); + await graph.sync(); + expect(targetFile()).toBe('pkg/src/Foo/Bar.hs'); + } finally { + graph.destroy(); + } + }); + + it('resolves pattern synonyms bundled under a type export', async () => { + const graph = await createGraph({ + 'Patterns.hs': [ + '{-# LANGUAGE PatternSynonyms #-}', + 'module Patterns (T(P)) where', + 'data T = MkT', + 'pattern P = MkT', + ].join('\n'), + 'Consumer.hs': [ + '{-# LANGUAGE PatternSynonyms #-}', + 'module Consumer where', + 'import Patterns (T(P))', + 'run = P', + ].join('\n'), + }); + try { + const pattern = graph.getNodesByName('P').find((node) => node.filePath === 'Patterns.hs')!; + expect(outgoingTargets(graph, 'run', 'Consumer.hs') + .some(({ target }) => target.id === pattern.id)).toBe(true); + } finally { + graph.destroy(); + } + }); + + it('resolves record pattern-synonym selectors locally and through explicit imports', async () => { + const graph = await createGraph({ + 'Patterns.hs': [ + '{-# LANGUAGE PatternSynonyms #-}', + 'module Patterns (pattern Present, presentValue) where', + 'pattern Present { presentValue } = Just presentValue', + 'local x = presentValue x', + ].join('\n'), + 'Consumer.hs': [ + 'module Consumer where', + 'import Patterns (presentValue)', + 'run x = presentValue x', + ].join('\n'), + }); + try { + const selector = graph.getNodesByName('presentValue') + .find((node) => node.filePath === 'Patterns.hs')!; + for (const [owner, filePath] of [['local', 'Patterns.hs'], ['run', 'Consumer.hs']] as const) { + expect(outgoingTargets(graph, owner, filePath) + .some(({ target }) => target.id === selector.id)).toBe(true); + } + } finally { + graph.destroy(); + } + }); + + it('does not reinterpret a multi-segment imported module as a qualified member', async () => { + const graph = await createGraph({ + 'src/Legacy/TT/Common/RateLimit.hs': [ + 'module Legacy.TT.Common.RateLimit where', + 'limit = 1', + ].join('\n'), + 'src/Consumer.hs': [ + 'module Consumer where', + 'import Legacy.TT.Common.RateLimit', + 'run = limit', + ].join('\n'), + }); + try { + const consumerModule = graph.getNodesByName('Consumer') + .find((node) => node.filePath === 'src/Consumer.hs' && node.kind === 'namespace')!; + expect(graph.getOutgoingEdges(consumerModule.id).some((edge) => { + const target = graph.getNode(edge.target); + return edge.kind === 'imports' + && target?.filePath === 'src/Legacy/TT/Common/RateLimit.hs'; + })).toBe(true); + } finally { + graph.destroy(); + } + }); + + it('recovers atomically when Haskell import invalidation is interrupted', async () => { + let graph = await createGraph({ + 'A.hs': 'module A (foo) where\nfoo x = x\n', + 'B.hs': 'module B (foo) where\nfoo x = x + 1\n', + 'Facade.hs': 'module Facade (foo) where\nimport A (foo)\n', + 'Consumer.hs': 'module Consumer where\nimport Facade (foo)\nrun = foo 1\n', + }); + const targetFile = () => outgoingTargets(graph, 'run', 'Consumer.hs') + .find(({ edge, target }) => edge.kind === 'calls' && target.name === 'foo')?.target.filePath; + try { + expect(targetFile()).toBe('A.hs'); + fs.writeFileSync(path.join(tmpDir!, 'Facade.hs'), [ + 'module Facade (foo) where', + 'import B (foo)', + '-- force a changed size and mtime', + ].join('\n')); + + const queries = (graph as unknown as { + queries: { deleteEdgesBySource(nodeId: string): void }; + }).queries; + const originalDelete = queries.deleteEdgesBySource.bind(queries); + let injected = false; + queries.deleteEdgesBySource = (nodeId: string) => { + originalDelete(nodeId); + if (!injected) { + injected = true; + throw new Error('injected Haskell invalidation interruption'); + } + }; + await expect(graph.sync()).rejects.toThrow('injected Haskell invalidation interruption'); + + graph.destroy(); + graph = CodeGraph.openSync(tmpDir!); + // The facade file record already matches disk; recovery must therefore be + // driven by the durable invalidation marker, not filesystem change data. + await graph.sync(); + expect(targetFile()).toBe('B.hs'); + } finally { + graph.destroy(); + } + }); + + it('keeps duplicate local helpers inside their exact equation scope', async () => { + const filler = Array.from({ length: 28 }, (_, index) => ` step${index} = ${index}`).join('\n'); + const graph = await createGraph({ + 'Main.hs': [ + 'module Main where', + 'equations True = helper 1 where', + filler, + ' helper x = x', + 'equations False = helper 2 where', + ' helper x = x + 1', + ].join('\n'), + }); + try { + const helpers = graph.getNodesByName('helper').sort((a, b) => a.startLine - b.startLine); + const calls = outgoingTargets(graph, 'equations', 'Main.hs') + .filter(({ edge, target }) => edge.kind === 'calls' && target.name === 'helper'); + expect(calls.find(({ edge }) => edge.line === 2)?.target.id).toBe(helpers[0]!.id); + expect(calls.find(({ edge }) => edge.line === 32)?.target.id).toBe(helpers[1]!.id); + } finally { + graph.destroy(); + } + }); + + it('lets a qualified import alias match the current module name when no local symbol exists', async () => { + const graph = await createGraph({ + 'X.hs': 'module X where\nfoo x = x\n', + 'A/B.hs': 'module A.B where\nimport qualified X as A.B\ny = A.B.foo\n', + }); + try { + const foo = graph.getNodesByName('foo').find((node) => node.filePath === 'X.hs')!; + expect(outgoingTargets(graph, 'y', 'A/B.hs').some(({ target }) => target.id === foo.id)).toBe(true); + } finally { + graph.destroy(); + } + }); + + it('parses grouped children of operator type parents', async () => { + const graph = await createGraph({ + 'Origin.hs': [ + '{-# LANGUAGE TypeFamilies, TypeOperators #-}', + 'module Origin ((:*:)(..)) where', + 'data family a :*: b', + 'data instance Int :*: Bool = PairIB', + ].join('\n'), + 'Consumer.hs': [ + '{-# LANGUAGE TypeOperators #-}', + 'module Consumer where', + 'import Origin ((:*:)(..))', + 'run = PairIB', + ].join('\n'), + }); + try { + const constructor = graph.getNodesByName('PairIB').find((node) => node.filePath === 'Origin.hs')!; + expect(outgoingTargets(graph, 'run', 'Consumer.hs') + .some(({ target }) => target.id === constructor.id)).toBe(true); + } finally { + graph.destroy(); + } + }); + + it('preserves DuplicateRecordFields parent identity through imports and re-exports', async () => { + const graph = await createGraph({ + 'Origin.hs': [ + '{-# LANGUAGE DuplicateRecordFields #-}', + 'module Origin (A(field), B(field)) where', + 'data A = A { field :: Int }', + 'data B = B { field :: Int }', + ].join('\n'), + 'Facade.hs': [ + '{-# LANGUAGE DuplicateRecordFields #-}', + 'module Facade (B(field)) where', + 'import Origin (B(field))', + ].join('\n'), + 'Consumer.hs': [ + 'module Consumer where', + 'import Facade (field)', + 'getB x = field x', + ].join('\n'), + 'Qualified.hs': [ + 'module Qualified where', + 'import qualified Origin as O (B(field))', + 'getQualified x = O.field x', + ].join('\n'), + }); + try { + const fields = graph.getNodesByName('field').filter((node) => node.filePath === 'Origin.hs'); + const bField = fields.find((node) => node.qualifiedName.includes('::B::'))!; + const aField = fields.find((node) => node.qualifiedName.includes('::A::'))!; + for (const [owner, filePath] of [ + ['getB', 'Consumer.hs'], ['getQualified', 'Qualified.hs'], + ] as const) { + const targets = outgoingTargets(graph, owner, filePath).map(({ target }) => target.id); + expect(targets).toContain(bField.id); + expect(targets).not.toContain(aField.id); + } + } finally { + graph.destroy(); + } + }); + + it('applies DuplicateRecordFields hiding before resolving a wildcard re-export', async () => { + const graph = await createGraph({ + 'Origin.hs': [ + '{-# LANGUAGE DuplicateRecordFields #-}', + 'module Origin (A(..), B(..)) where', + 'data A = A { field :: Int }', + 'data B = B { field :: Int }', + ].join('\n'), + 'Facade.hs': [ + 'module Facade (module Origin) where', + 'import Origin hiding (A(field))', + ].join('\n'), + 'Consumer.hs': [ + 'module Consumer where', + 'import Facade (field)', + 'getB x = field x', + ].join('\n'), + }); + try { + const fields = graph.getNodesByName('field').filter((node) => node.filePath === 'Origin.hs'); + const aField = fields.find((node) => node.qualifiedName.includes('::A::'))!; + const bField = fields.find((node) => node.qualifiedName.includes('::B::'))!; + const targets = outgoingTargets(graph, 'getB', 'Consumer.hs').map(({ target }) => target.id); + expect(targets).toContain(bField.id); + expect(targets).not.toContain(aField.id); + } finally { + graph.destroy(); + } + }); + + it('uses an annotated record-dot receiver and leaves an unknown receiver unresolved', async () => { + const graph = await createGraph({ + 'Origin.hs': [ + 'module Origin (B(..)) where', + 'data B = B { field :: Int }', + ].join('\n'), + 'Consumer.hs': [ + '{-# LANGUAGE DuplicateRecordFields, OverloadedRecordDot #-}', + 'module Consumer where', + 'import Origin (B(..))', + 'data A = A { field :: Int }', + 'getB :: B -> Int', + 'getB value = value.field', + 'getA :: A -> Int', + 'getA value = value.field', + 'unknown value = value.field', + 'unknownParenthesized value = (value).field', + ].join('\n'), + 'QualifiedDot.hs': [ + '{-# LANGUAGE DuplicateRecordFields, OverloadedRecordDot #-}', + 'module QualifiedDot where', + 'import qualified Origin as O', + 'data B = B { field :: Int }', + 'getQualified :: O.B -> Int', + 'getQualified value = value.field', + ].join('\n'), + }); + try { + const importedField = graph.getNodesByName('field') + .find((node) => node.filePath === 'Origin.hs')!; + const localField = graph.getNodesByName('field') + .find((node) => node.filePath === 'Consumer.hs')!; + expect(outgoingTargets(graph, 'getB', 'Consumer.hs') + .some(({ edge, target }) => edge.kind === 'references' && target.id === importedField.id)).toBe(true); + expect(outgoingTargets(graph, 'getB', 'Consumer.hs') + .some(({ target }) => target.id === localField.id)).toBe(false); + expect(outgoingTargets(graph, 'getA', 'Consumer.hs') + .some(({ edge, target }) => edge.kind === 'references' && target.id === localField.id)).toBe(true); + expect(outgoingTargets(graph, 'unknown', 'Consumer.hs') + .some(({ target }) => target.name === 'field')).toBe(false); + expect(outgoingTargets(graph, 'unknownParenthesized', 'Consumer.hs') + .some(({ target }) => target.name === 'field')).toBe(false); + expect(outgoingTargets(graph, 'getQualified', 'QualifiedDot.hs') + .some(({ target }) => target.name === 'field')).toBe(false); + } finally { + graph.destroy(); + } + }); + + it('does not let a direct Haskell export hide an ambiguous wildcard re-export', async () => { + const graph = await createGraph({ + 'Origin.hs': [ + '{-# LANGUAGE DuplicateRecordFields #-}', + 'module Origin (A(..), B(..)) where', + 'data A = A { field :: Int }', + 'data B = B { field :: Int }', + ].join('\n'), + 'Facade.hs': [ + '{-# LANGUAGE DuplicateRecordFields #-}', + 'module Facade (C(..), module Origin) where', + 'import Origin', + 'data C = C { field :: Int }', + ].join('\n'), + 'Consumer.hs': [ + 'module Consumer where', + 'import Facade (field)', + 'use value = field value', + ].join('\n'), + }); + try { + expect(outgoingTargets(graph, 'use', 'Consumer.hs') + .some(({ target }) => target.name === 'field')).toBe(false); + } finally { + graph.destroy(); + } + }); + + it('keeps same-line local helper IDs distinct and resolves each call to its own scope', async () => { + const filler = Array.from({ length: 36 }, () => '0 + ').join(''); + const graph = await createGraph({ + 'Main.hs': [ + 'module Main where', + `run b = if b then let helper x = x + 1 in ${filler}helper 1 else let helper x = x + 2 in helper 2`, + ].join('\n'), + }); + try { + const helpers = graph.getNodesByName('helper') + .filter((node) => node.filePath === 'Main.hs') + .sort((a, b) => a.startColumn - b.startColumn); + expect(helpers).toHaveLength(2); + expect(helpers[0]!.id).not.toBe(helpers[1]!.id); + const calls = outgoingTargets(graph, 'run', 'Main.hs') + .filter(({ edge, target }) => edge.kind === 'calls' && target.name === 'helper') + .sort((a, b) => (a.edge.column ?? 0) - (b.edge.column ?? 0)); + expect(calls).toHaveLength(2); + expect(calls[0]!.target.id).toBe(helpers[0]!.id); + expect(calls[1]!.target.id).toBe(helpers[1]!.id); + + const originalIds = helpers.map((helper) => helper.id); + fs.appendFileSync(path.join(tmpDir!, 'Main.hs'), '\n-- force a stable re-index\n'); + await graph.sync(); + expect(graph.getNodesByName('helper') + .filter((node) => node.filePath === 'Main.hs') + .sort((a, b) => a.startColumn - b.startColumn) + .map((helper) => helper.id)).toEqual(originalIds); + } finally { + graph.destroy(); + } + }); + + it('resolves imported, qualified, and self-alias constants used as values', async () => { + const graph = await createGraph({ + 'X.hs': 'module X (foo) where\nfoo = 1\n', + 'Direct.hs': 'module Direct where\nimport X (foo)\ny = foo\n', + 'Qualified.hs': 'module Qualified where\nimport qualified X as Q\nyq = Q.foo\n', + 'A/B.hs': 'module A.B where\nimport qualified X as A.B\nys = A.B.foo\n', + }); + try { + const foo = graph.getNodesByName('foo') + .find((node) => node.filePath === 'X.hs' && node.kind === 'constant')!; + expect(foo).toBeDefined(); + for (const [owner, filePath] of [ + ['y', 'Direct.hs'], + ['yq', 'Qualified.hs'], + ['ys', 'A/B.hs'], + ] as const) { + expect(outgoingTargets(graph, owner, filePath) + .some(({ edge, target }) => edge.kind === 'references' && target.id === foo.id)).toBe(true); + } + } finally { + graph.destroy(); + } + }); +}); diff --git a/__tests__/haskell-topology-sync.test.ts b/__tests__/haskell-topology-sync.test.ts new file mode 100644 index 000000000..cdac06fa0 --- /dev/null +++ b/__tests__/haskell-topology-sync.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +describe('Haskell topology-aware sync invalidation', () => { + let tmpDir: string | undefined; + let graph: CodeGraph | undefined; + + afterEach(() => { + graph?.destroy(); + graph = undefined; + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + const createGraph = async (files: Record) => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-haskell-topology-')); + for (const [filePath, content] of Object.entries(files)) { + fs.writeFileSync(path.join(tmpDir, filePath), content); + } + graph = CodeGraph.initSync(tmpDir); + await graph.indexAll(); + return graph; + }; + + const internals = (current: CodeGraph) => current as unknown as { + orchestrator: { invalidateHaskellImportEdges(...args: unknown[]): number }; + db: { getDb(): { prepare(sql: string): { run(...params: unknown[]): unknown } } }; + queries: { + getFileByPath(filePath: string): { contentHash: string; haskellTopologyHash?: string } | null; + getMetadata(key: string): string | null; + }; + }; + + const callTarget = (current: CodeGraph) => { + const run = current.getNodesByName('run').find((node) => node.filePath === 'Main.hs')!; + return current.getOutgoingEdges(run.id) + .map((edge) => current.getNode(edge.target)) + .find((node) => node?.name === 'foo'); + }; + + it('skips the broad replay for comment-only edits and keeps incoming edges current', async () => { + const current = await createGraph({ + 'Lib.hs': 'module Lib (foo) where\nfoo x = x\n', + 'Main.hs': 'module Main where\nimport Lib (foo)\nrun = foo 1\n', + }); + const privateState = internals(current); + const before = privateState.queries.getFileByPath('Lib.hs')!; + const oldTargetId = callTarget(current)!.id; + const original = privateState.orchestrator.invalidateHaskellImportEdges + .bind(privateState.orchestrator); + let broadReplays = 0; + privateState.orchestrator.invalidateHaskellImportEdges = (...args: unknown[]) => { + broadReplays++; + return original(...args); + }; + + fs.writeFileSync(path.join(tmpDir!, 'Lib.hs'), [ + 'module Lib (foo) where', + '-- implementation note', + 'foo x = x', + '', + ].join('\n')); + await current.sync(); + + const after = privateState.queries.getFileByPath('Lib.hs')!; + expect(after.contentHash).not.toBe(before.contentHash); + expect(after.haskellTopologyHash).toBe(before.haskellTopologyHash); + expect(broadReplays).toBe(0); + expect(privateState.queries.getMetadata('haskell_import_invalidation_pending')).toBe('0'); + expect(callTarget(current)!.id).not.toBe(oldTargetId); + expect(callTarget(current)!.filePath).toBe('Lib.hs'); + }); + + it('keeps the broad replay when an import/reexport topology changes', async () => { + const current = await createGraph({ + 'A.hs': 'module A (foo) where\nfoo x = x\n', + 'B.hs': 'module B (foo) where\nfoo x = x + 1\n', + 'Lib.hs': 'module Lib (foo) where\nimport A (foo)\n', + 'Main.hs': 'module Main where\nimport Lib (foo)\nrun = foo 1\n', + }); + const privateState = internals(current); + const before = privateState.queries.getFileByPath('Lib.hs')!; + const original = privateState.orchestrator.invalidateHaskellImportEdges + .bind(privateState.orchestrator); + let broadReplays = 0; + privateState.orchestrator.invalidateHaskellImportEdges = (...args: unknown[]) => { + broadReplays++; + return original(...args); + }; + + fs.writeFileSync(path.join(tmpDir!, 'Lib.hs'), 'module Lib (foo) where\nimport B (foo)\n'); + await current.sync(); + + expect(privateState.queries.getFileByPath('Lib.hs')!.haskellTopologyHash) + .not.toBe(before.haskellTopologyHash); + expect(broadReplays).toBe(1); + expect(callTarget(current)!.filePath).toBe('B.hs'); + }); + + it('replays conservatively once for a migrated file without a fingerprint', async () => { + const current = await createGraph({ + 'Lib.hs': 'module Lib (foo) where\nfoo x = x\n', + 'Main.hs': 'module Main where\nimport Lib (foo)\nrun = foo 1\n', + }); + const privateState = internals(current); + privateState.db.getDb().prepare( + 'UPDATE files SET haskell_topology_hash = NULL WHERE path = ?', + ).run('Lib.hs'); + const original = privateState.orchestrator.invalidateHaskellImportEdges + .bind(privateState.orchestrator); + let broadReplays = 0; + privateState.orchestrator.invalidateHaskellImportEdges = (...args: unknown[]) => { + broadReplays++; + return original(...args); + }; + + fs.writeFileSync(path.join(tmpDir!, 'Lib.hs'), [ + 'module Lib (foo) where', + '-- first post-migration edit', + 'foo x = x', + '', + ].join('\n')); + await current.sync(); + + expect(broadReplays).toBe(1); + expect(privateState.queries.getFileByPath('Lib.hs')!.haskellTopologyHash).toBeTruthy(); + expect(callTarget(current)!.filePath).toBe('Lib.hs'); + }); +}); diff --git a/__tests__/haskell.test.ts b/__tests__/haskell.test.ts new file mode 100644 index 000000000..34c608da4 --- /dev/null +++ b/__tests__/haskell.test.ts @@ -0,0 +1,768 @@ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { extractFromSource } from '../src/extraction'; +import { detectLanguage, initGrammars, loadAllGrammars } from '../src/extraction/grammars'; +import { extractImportMappings } from '../src/resolution/import-resolver'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +describe('Haskell support', () => { + let tmpDir: string | undefined; + + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('detects modules and extracts grouped equations, signatures, data, and calls', () => { + const source = ` +module Example.Domain where + +import Data.Text qualified as Text + +data Mode = Fast | Slow + +format :: Mode -> String +format Fast = Text.unpack "fast" +format Slow = helper "slow" + +helper value = value + +pointFree :: Mode -> String +pointFree = format + +label :: String +label = "mode" + +applyDollar value = helper $ value + +outer value = + let local item = helper item + in local value +`; + const result = extractFromSource('src/Example/Domain.hs', source); + + expect(detectLanguage('src/Example/Domain.hs')).toBe('haskell'); + expect(result.nodes.some((node) => node.kind === 'namespace' && node.name === 'Example.Domain')).toBe(true); + expect(result.nodes.filter((node) => node.kind === 'function' && node.name === 'format')).toHaveLength(1); + expect(result.nodes.find((node) => node.kind === 'function' && node.name === 'format')?.signature) + .toBe('format :: Mode -> String'); + expect(result.nodes.some((node) => node.kind === 'enum' && node.name === 'Mode')).toBe(true); + expect(result.nodes.some((node) => node.kind === 'enum_member' && node.name === 'Fast')).toBe(true); + expect(result.nodes.some((node) => node.kind === 'enum_member' && node.name === 'Slow')).toBe(true); + expect(result.nodes.some((node) => node.kind === 'function' && node.name === 'pointFree')).toBe(true); + expect(result.nodes.some((node) => node.kind === 'constant' && node.name === 'label')).toBe(true); + expect(result.nodes.some((node) => node.kind === 'function' && node.name === 'local' + && node.isExported === false)).toBe(true); + + const calls = result.unresolvedReferences + .filter((ref) => ref.referenceKind === 'calls') + .map((ref) => ref.referenceName); + expect(calls).toContain('Text::unpack'); + expect(calls).toContain('helper'); + expect(calls.filter((name) => name === '$')).toHaveLength(0); + }); + + it('extracts point-free instance methods and associated type families', () => { + const source = ` +module Example.Instances where + +class Runner a where + type Result a + run :: a -> Result a + +instance Runner Int where + type Result Int = String + run :: Int -> String + run = show +`; + const result = extractFromSource('src/Example/Instances.hs', source); + + expect(result.nodes.some((node) => node.kind === 'trait' && node.name === 'Runner')).toBe(true); + expect(result.nodes.filter((node) => node.kind === 'method' && node.name === 'run')).toHaveLength(2); + expect(result.nodes.some((node) => node.kind === 'method' && node.name === 'run' + && node.qualifiedName.includes('Runner Int') && node.startLine === 10 && node.endLine === 11)).toBe(true); + expect(result.nodes.some((node) => node.kind === 'type_alias' && node.name === 'Result')).toBe(true); + expect(result.nodes.some((node) => node.kind === 'type_alias' && node.name === 'Result Int')).toBe(true); + }); + + it('extracts associated data instances and standalone deriving instances', () => { + const source = ` +{-# LANGUAGE StandaloneDeriving #-} +module Example.DataInstances where + +data RowT f = RowT (f Int) + +class Table table where + data PrimaryKey table f + +instance Table RowT where + data PrimaryKey RowT f = RowId (f Int) + +deriving instance Show (RowT Maybe) +`; + const result = extractFromSource('src/Example/DataInstances.hs', source); + + expect(result.nodes.some((node) => node.kind === 'enum' && node.name === 'PrimaryKey' + && node.qualifiedName.includes('Table RowT'))).toBe(true); + expect(result.nodes.some((node) => node.kind === 'enum_member' && node.name === 'RowId')).toBe(true); + expect(result.nodes.some((node) => node.kind === 'class' && node.name === 'Show (RowT Maybe)' + && node.decorators?.includes('haskell-deriving-instance'))).toBe(true); + }); + + it('keeps declarations around a valid nested case expression', () => { + const source = ` +module Example.NestedCase where + +extractBankDetails entries = + let mDetails = do + entry <- entries + case lookupObject entry =<< case entry of + Object value -> Just value + _ -> Nothing of + Just details -> Just details + Nothing -> Nothing + extract details = details + in paymentMethodToRail $ extract mDetails +extractBankDetails value = value +`; + const result = extractFromSource('src/Example/NestedCase.hs', source); + + expect(result.nodes.filter((node) => node.kind === 'function' && node.name === 'extractBankDetails')).toHaveLength(1); + expect(result.nodes.some((node) => node.kind === 'function' && node.name === 'extract' + && node.isExported === false)).toBe(true); + + const calls = result.unresolvedReferences + .filter((ref) => ref.referenceKind === 'calls') + .map((ref) => ref.referenceName); + expect(calls).toContain('lookupObject'); + expect(calls).toContain('paymentMethodToRail'); + expect(calls).toContain('extract'); + expect(calls).not.toContain('$'); + }); + + it('parses explicit, wildcard, and ImportQualifiedPost imports', () => { + const mappings = extractImportMappings( + 'Consumer.hs', + [ + 'module Consumer where', + 'import Lib.Api (runThing, Result (..), (>=>))', + 'import Lib.All', + 'import Lib.Sql qualified as SQL', + 'import qualified Lib.Legacy as Legacy', + ].join('\n'), + 'haskell' + ); + + expect(mappings).toContainEqual(expect.objectContaining({ + localName: 'runThing', exportedName: 'runThing', source: 'Lib.Api', isNamespace: false, + })); + expect(mappings).toContainEqual(expect.objectContaining({ + localName: 'Result', exportedName: 'Result', source: 'Lib.Api', isNamespace: false, + })); + expect(mappings).toContainEqual(expect.objectContaining({ + localName: '>=>', exportedName: '>=>', source: 'Lib.Api', isNamespace: false, + })); + expect(mappings).toContainEqual(expect.objectContaining({ + localName: '*', source: 'Lib.All', isNamespace: false, + })); + expect(mappings).toContainEqual(expect.objectContaining({ + localName: 'SQL', source: 'Lib.Sql', isNamespace: true, + })); + expect(mappings).toContainEqual(expect.objectContaining({ + localName: 'Legacy', source: 'Lib.Legacy', isNamespace: true, + })); + }); + + it('resolves same-named functions by imported module and captures higher-order uses', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-haskell-')); + fs.mkdirSync(path.join(tmpDir, 'Lib'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'Lib', 'A.hs'), [ + 'module Lib.A where', + 'target x = helper x', + 'helper x = x', + 'pointFree :: Int -> Int', + 'pointFree = target', + ].join('\n')); + fs.writeFileSync(path.join(tmpDir, 'Lib', 'B.hs'), [ + 'module Lib.B where', + 'target x = x', + ].join('\n')); + fs.writeFileSync(path.join(tmpDir, 'Lib', 'Facade.hs'), [ + 'module Lib.Facade (module Lib.A) where', + 'import Lib.A', + ].join('\n')); + fs.writeFileSync(path.join(tmpDir, 'Lib', 'Local.hs'), [ + 'module Lib.Local where', + 'outer x = let hidden y = y in hidden x', + ].join('\n')); + fs.writeFileSync(path.join(tmpDir, 'Decoy.hs'), [ + 'module Decoy where', + 'pure x = x', + 'length _ = 0', + ].join('\n')); + fs.writeFileSync(path.join(tmpDir, 'Main.hs'), [ + 'module Main where', + 'import Lib.A (target)', + 'import Lib.A qualified as A', + 'import Lib.B qualified as B', + 'import Lib.Facade (pointFree)', + 'import Lib.Local (hidden)', + 'run xs = do', + ' mapM_ target xs', + ' print (A.target 1)', + ' print (pointFree 1)', + ' print (B.target 2)', + ' print (hidden 3)', + ' pure (length xs)', + ].join('\n')); + + const graph = CodeGraph.initSync(tmpDir); + try { + await graph.indexAll(); + const run = graph.getNodesByName('run').find((node) => node.filePath === 'Main.hs')!; + const targetA = graph.getNodesByName('target').find((node) => node.filePath === 'Lib/A.hs')!; + const targetB = graph.getNodesByName('target').find((node) => node.filePath === 'Lib/B.hs')!; + const pointFree = graph.getNodesByName('pointFree').find((node) => node.filePath === 'Lib/A.hs')!; + const decoyPure = graph.getNodesByName('pure').find((node) => node.filePath === 'Decoy.hs')!; + const decoyLength = graph.getNodesByName('length').find((node) => node.filePath === 'Decoy.hs')!; + const localHidden = graph.getNodesByName('hidden').find((node) => node.filePath === 'Lib/Local.hs')!; + const outgoing = graph.getOutgoingEdges(run.id); + + expect(outgoing.some((edge) => edge.target === targetA.id && edge.kind === 'references')).toBe(true); + expect(outgoing.some((edge) => edge.target === targetA.id && edge.kind === 'calls')).toBe(true); + expect(outgoing.some((edge) => edge.target === targetB.id && edge.kind === 'calls')).toBe(true); + expect(outgoing.some((edge) => edge.target === pointFree.id && edge.kind === 'calls')).toBe(true); + expect(outgoing.some((edge) => edge.target === decoyPure.id)).toBe(false); + expect(outgoing.some((edge) => edge.target === decoyLength.id)).toBe(false); + expect(outgoing.some((edge) => edge.target === localHidden.id)).toBe(false); + } finally { + graph.destroy(); + } + }); + + it('preserves the extraction coverage introduced by PR #395', () => { + const source = ` +module Combined where + +class (Eq a, Show a) => Render a where + render :: a -> String + +data Person = Person { personName :: String } deriving (Show, Eq) + +data Term a where + IntTerm :: Int -> Term Int + +area x = x +total xs = sum (map area xs) + +(===) :: Int -> Int -> Bool +x === y = x == y + +initialise = pure () +main = do + initialise +`; + const result = extractFromSource('Combined.hs', source); + const render = result.nodes.find((node) => node.kind === 'trait' && node.name === 'Render')!; + const person = result.nodes.find((node) => node.kind === 'enum' && node.name === 'Person')!; + const total = result.nodes.find((node) => node.kind === 'function' && node.name === 'total')!; + const main = result.nodes.find((node) => node.name === 'main')!; + + expect(result.nodes.some((node) => node.kind === 'field' && node.name === 'personName')).toBe(true); + expect(result.nodes.some((node) => node.kind === 'enum_member' && node.name === 'IntTerm')).toBe(true); + expect(result.nodes.some((node) => node.kind === 'function' && node.name === '(===)')).toBe(true); + expect(result.unresolvedReferences.filter((ref) => ref.fromNodeId === render.id && ref.referenceKind === 'extends') + .map((ref) => ref.referenceName).sort()).toEqual(['Eq', 'Show']); + expect(result.unresolvedReferences.filter((ref) => ref.fromNodeId === person.id && ref.referenceKind === 'implements') + .map((ref) => ref.referenceName).sort()).toEqual(['Eq', 'Show']); + expect(result.unresolvedReferences.some((ref) => ref.fromNodeId === total.id + && ref.referenceKind === 'calls' && ref.referenceName === 'area')).toBe(true); + expect(result.unresolvedReferences.some((ref) => ref.fromNodeId === main.id + && ref.referenceKind === 'calls' && ref.referenceName === 'initialise')).toBe(true); + }); + + it('keeps higher-order synthesis function-first and lexical-scope safe', () => { + const result = extractFromSource('HigherOrder.hs', ` +module HigherOrder where +worker x = x +good xs = map worker xs +parameter f xs = map f xs +dataFirst xs f = forM_ xs f +`); + const callsFrom = (name: string) => { + const owner = result.nodes.find((node) => node.kind === 'function' && node.name === name)!; + return result.unresolvedReferences + .filter((ref) => ref.fromNodeId === owner.id && ref.referenceKind === 'calls') + .map((ref) => ref.referenceName); + }; + expect(callsFrom('good')).toContain('worker'); + expect(callsFrom('parameter')).not.toContain('f'); + expect(callsFrom('dataFirst')).not.toContain('xs'); + }); + + it('attributes where and operator-body calls to their own grouped symbols', () => { + const result = extractFromSource('Scopes.hs', ` +module Scopes where +helper x = x +describe x = local x where + local value = helper value +x <+> y = helper x +`); + const local = result.nodes.find((node) => node.kind === 'function' && node.name === 'local')!; + const operator = result.nodes.find((node) => node.kind === 'function' && node.name === '(<+>)')!; + expect(result.unresolvedReferences.some((ref) => ref.fromNodeId === local.id + && ref.referenceKind === 'calls' && ref.referenceName === 'helper')).toBe(true); + expect(result.unresolvedReferences.some((ref) => ref.fromNodeId === operator.id + && ref.referenceKind === 'calls' && ref.referenceName === 'helper')).toBe(true); + }); + + it('captures monadic and composition operators without treating bound parameters as globals', () => { + const result = extractFromSource('Operators.hs', ` +module Operators where +parse x = x +validate x = x +handler x = x +pipeline = parse >=> validate +consume xs = xs >>= handler +parameter f xs = f <$> xs +`); + const refsFrom = (name: string) => { + const owner = result.nodes.find((node) => node.name === name)!; + return result.unresolvedReferences + .filter((ref) => ref.fromNodeId === owner.id && ref.referenceKind === 'function_ref') + .map((ref) => ref.referenceName); + }; + expect(refsFrom('pipeline')).toEqual(expect.arrayContaining(['parse', 'validate'])); + expect(refsFrom('consume')).toContain('handler'); + expect(refsFrom('parameter')).not.toContain('f'); + }); + + it('models child imports and hiding restrictions', () => { + const mappings = extractImportMappings('Consumer.hs', [ + 'module Consumer where', + 'import Lib.Api (Result (..), Runner (run), (>=>))', + 'import Lib.Hidden hiding (secret, Box (..))', + ].join('\n'), 'haskell'); + + expect(mappings).toContainEqual(expect.objectContaining({ + localName: '*', source: 'Lib.Api', parentExport: 'Result', isNamespace: false, + })); + expect(mappings).toContainEqual(expect.objectContaining({ + localName: 'run', source: 'Lib.Api', parentExport: 'Runner', isNamespace: false, + })); + expect(mappings).toContainEqual(expect.objectContaining({ + localName: '*', source: 'Lib.Hidden', isNamespace: false, + excludedNames: expect.arrayContaining(['secret', 'Box']), + excludedParentExports: ['Box'], + })); + }); + + it('resolves constructors and class methods imported through Parent(..)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-haskell-children-')); + fs.writeFileSync(path.join(tmpDir, 'Canonical.hs'), [ + 'module Canonical (EventExpr (..), IsEvent (..)) where', + 'data EventExpr = EventInsert | EventDelete', + 'class IsEvent a where', + ' runEvent :: a -> EventExpr', + ].join('\n')); + fs.writeFileSync(path.join(tmpDir, 'Decoy.hs'), [ + 'module Decoy where', + 'data EventExpr = EventDelete', + 'class IsEvent a where', + ' runEvent :: a -> EventExpr', + ].join('\n')); + fs.writeFileSync(path.join(tmpDir, 'Main.hs'), [ + 'module Main where', + 'import Canonical (EventExpr (..), IsEvent (..))', + 'run value = EventDelete (runEvent value)', + 'wrap = EventDelete . runEvent', + ].join('\n')); + + const graph = CodeGraph.initSync(tmpDir); + try { + await graph.indexAll(); + await graph.indexAll(); // Incremental/full repetition must stay stable. + const run = graph.getNodesByName('run').find((node) => node.filePath === 'Main.hs')!; + const canonicalDelete = graph.getNodesByName('EventDelete') + .find((node) => node.filePath === 'Canonical.hs')!; + const decoyDelete = graph.getNodesByName('EventDelete') + .find((node) => node.filePath === 'Decoy.hs')!; + const canonicalMethod = graph.getNodesByName('runEvent') + .find((node) => node.filePath === 'Canonical.hs')!; + const outgoing = graph.getOutgoingEdges(run.id); + const wrap = graph.getNodesByName('wrap').find((node) => node.filePath === 'Main.hs')!; + const wrapOutgoing = graph.getOutgoingEdges(wrap.id); + + expect(outgoing.some((edge) => edge.target === canonicalDelete.id && edge.kind === 'calls')).toBe(true); + expect(outgoing.some((edge) => edge.target === canonicalMethod.id && edge.kind === 'calls')).toBe(true); + expect(outgoing.some((edge) => edge.target === decoyDelete.id)).toBe(false); + expect(wrapOutgoing.some((edge) => edge.target === canonicalDelete.id + && edge.kind === 'references')).toBe(true); + expect(wrapOutgoing.some((edge) => edge.target === canonicalMethod.id + && edge.kind === 'references')).toBe(true); + expect(graph.getNodesByName('run').filter((node) => node.filePath === 'Main.hs')).toHaveLength(1); + } finally { + graph.destroy(); + } + }); + + it('honours module export lists and import hiding', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-haskell-exports-')); + fs.writeFileSync(path.join(tmpDir, 'Lib.hs'), [ + 'module Lib (public, Box (Visible)) where', + 'public x = x', + 'private x = x', + 'data Box = Visible | Secret', + ].join('\n')); + fs.writeFileSync(path.join(tmpDir, 'Main.hs'), [ + 'module Main where', + 'import Lib hiding (public)', + 'run x = public (private (Visible (Secret x)))', + ].join('\n')); + + const extracted = extractFromSource('Lib.hs', fs.readFileSync(path.join(tmpDir, 'Lib.hs'), 'utf8')); + expect(extracted.nodes.find((node) => node.name === 'public')?.isExported).toBe(true); + expect(extracted.nodes.find((node) => node.name === 'private')?.isExported).toBe(false); + expect(extracted.nodes.find((node) => node.name === 'Visible')?.isExported).toBe(true); + expect(extracted.nodes.find((node) => node.name === 'Secret')?.isExported).toBe(false); + + const graph = CodeGraph.initSync(tmpDir); + try { + await graph.indexAll(); + const run = graph.getNodesByName('run').find((node) => node.filePath === 'Main.hs')!; + const targets = new Set(graph.getOutgoingEdges(run.id).map((edge) => edge.target)); + for (const name of ['public', 'private', 'Secret']) { + const node = graph.getNodesByName(name).find((candidate) => candidate.filePath === 'Lib.hs')!; + expect(targets.has(node.id)).toBe(false); + } + const visible = graph.getNodesByName('Visible').find((node) => node.filePath === 'Lib.hs')!; + expect(targets.has(visible.id)).toBe(true); + } finally { + graph.destroy(); + } + }); + + it('follows a named re-export sourced from an unqualified wildcard import', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-haskell-reexport-')); + fs.writeFileSync(path.join(tmpDir, 'Origin.hs'), 'module Origin (foo) where\nfoo x = x\n'); + fs.writeFileSync(path.join(tmpDir, 'Facade.hs'), 'module Facade (foo) where\nimport Origin\n'); + fs.writeFileSync(path.join(tmpDir, 'Main.hs'), 'module Main where\nimport Facade (foo)\nrun = foo 1\n'); + + const graph = CodeGraph.initSync(tmpDir); + try { + await graph.indexAll(); + const run = graph.getNodesByName('run').find((node) => node.filePath === 'Main.hs')!; + const foo = graph.getNodesByName('foo').find((node) => node.filePath === 'Origin.hs')!; + expect(graph.getOutgoingEdges(run.id).some((edge) => edge.target === foo.id && edge.kind === 'calls')).toBe(true); + } finally { + graph.destroy(); + } + }); + + it('does not resolve monadic and let-bound callables to imported decoys', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-haskell-monad-scope-')); + fs.writeFileSync(path.join(tmpDir, 'Decoy.hs'), [ + 'module Decoy where', + 'handler x = x', + 'local x = x', + ].join('\n')); + fs.writeFileSync(path.join(tmpDir, 'Main.hs'), [ + 'module Main where', + 'import Decoy (handler, local)', + 'run request = do', + ' handler <- acquire', + ' let local = handler', + ' handler request', + ' local request', + ].join('\n')); + + const graph = CodeGraph.initSync(tmpDir); + try { + await graph.indexAll(); + const run = graph.getNodesByName('run').find((node) => node.filePath === 'Main.hs')!; + const outgoing = new Set(graph.getOutgoingEdges(run.id).map((edge) => edge.target)); + for (const name of ['handler', 'local']) { + const decoy = graph.getNodesByName(name).find((node) => node.filePath === 'Decoy.hs')!; + expect(outgoing.has(decoy.id)).toBe(false); + } + } finally { + graph.destroy(); + } + }); + + it('distinguishes patterns from calls and normalizes infix and bare qualified actions', () => { + const result = extractFromSource('Advanced.hs', ` +module Advanced where +combine x y = x +run xs value = xs \`combine\` value +qualified xs ys = xs \`Data.List.union\` ys +qualifiedHof xs = Data.List.map target xs +match value = case value of + Just handler -> handler 1 +lambda = \\(Just handler) -> handler 1 +monadic action = do + Just handler <- action + handler 1 + Server.start + (Server.stop) +alias :: Int -> Int +alias = combine 1 +target x = x +pointFree :: Int -> Int +pointFree = target +handlers :: [Int -> IO ()] +handlers = [] +class C a where + first, second :: a -> a +data family Family a +data instance Family Int = FamilyInt Int +data instance Family Bool = FamilyBool Bool +pattern Present x = Just x +`); + + const refs = result.unresolvedReferences; + expect(refs.some((ref) => ref.referenceKind === 'calls' && ref.referenceName === 'combine')).toBe(true); + expect(refs.some((ref) => ref.referenceKind === 'calls' + && ref.referenceName === 'Data.List::union')).toBe(true); + const qualifiedHof = result.nodes.find((node) => node.name === 'qualifiedHof')!; + expect(refs.some((ref) => ref.fromNodeId === qualifiedHof.id + && ref.referenceKind === 'function_ref' && ref.referenceName === 'target')).toBe(true); + expect(refs.some((ref) => ref.referenceKind === 'calls' && ref.referenceName === 'Server::start')).toBe(true); + expect(refs.some((ref) => ref.referenceKind === 'calls' && ref.referenceName === 'Server::stop')).toBe(true); + expect(refs.some((ref) => ref.referenceKind === 'calls' && ref.referenceName === 'Just')).toBe(false); + expect(refs.some((ref) => ref.referenceKind === 'references' && ref.referenceName === 'Just')).toBe(true); + expect(refs.some((ref) => ref.referenceKind === 'function_ref' && ref.referenceName === 'target')).toBe(true); + expect(result.nodes.find((node) => node.name === 'handlers')?.kind).toBe('constant'); + expect(result.nodes.filter((node) => node.kind === 'method' && ['first', 'second'].includes(node.name))) + .toHaveLength(2); + expect(result.nodes.some((node) => node.kind === 'enum' && node.name === 'Family' + && node.decorators?.includes('haskell-data-family'))).toBe(true); + expect(result.nodes.some((node) => node.kind === 'enum' && node.name === 'Family Int')).toBe(true); + expect(result.nodes.some((node) => node.kind === 'enum' && node.name === 'Family Bool')).toBe(true); + expect(result.nodes.some((node) => node.kind === 'enum_member' && node.name === 'Present' + && node.decorators?.includes('haskell-pattern-synonym'))).toBe(true); + }); + + it('prefers lexical let/where functions and suppresses comprehension and guard binders', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-haskell-lexical-')); + fs.writeFileSync(path.join(tmpDir, 'Decoy.hs'), [ + 'module Decoy where', + 'helper x = x', + 'local x = x', + 'f x = x', + ].join('\n')); + fs.writeFileSync(path.join(tmpDir, 'Main.hs'), [ + 'module Main where', + 'import Decoy (helper, local, f)', + 'run n = let helper x = if x == 0 then 0 else helper (x - 1) in helper n', + 'outer n = local n where', + ' local x = if x == 0 then 0 else local (x - 1)', + 'comp fs xs = [f x | f <- fs, x <- xs]', + 'guarded mf x | Just f <- mf = f x', + ].join('\n')); + + const graph = CodeGraph.initSync(tmpDir); + try { + await graph.indexAll(); + const decoys = new Set(['helper', 'local', 'f'].map((name) => + graph.getNodesByName(name).find((node) => node.filePath === 'Decoy.hs')!.id)); + for (const ownerName of ['run', 'outer', 'comp', 'guarded']) { + const owner = graph.getNodesByName(ownerName).find((node) => node.filePath === 'Main.hs')!; + expect(graph.getOutgoingEdges(owner.id).some((edge) => decoys.has(edge.target))).toBe(false); + } + for (const name of ['helper', 'local']) { + const owner = graph.getNodesByName(name).find((node) => node.filePath === 'Main.hs')!; + const outgoing = graph.getOutgoingEdges(owner.id); + expect(outgoing.some((edge) => edge.target === owner.id && edge.kind === 'calls')).toBe(true); + } + const run = graph.getNodesByName('run').find((node) => node.filePath === 'Main.hs')!; + const helper = graph.getNodesByName('helper').find((node) => node.filePath === 'Main.hs')!; + expect(graph.getOutgoingEdges(run.id).some((edge) => edge.target === helper.id && edge.kind === 'calls')).toBe(true); + } finally { + graph.destroy(); + } + }); + + it('keeps function references inside Haskell import scope', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-haskell-fnref-scope-')); + fs.writeFileSync(path.join(tmpDir, 'Lib.hs'), 'module Lib (hidden) where\nhidden x = x\n'); + fs.writeFileSync(path.join(tmpDir, 'Main.hs'), [ + 'module Main where', + 'import Lib hiding (hidden)', + 'wrap = hidden . id', + ].join('\n')); + fs.writeFileSync(path.join(tmpDir, 'NoImport.hs'), 'module NoImport where\nwrap = hidden . id\n'); + + const graph = CodeGraph.initSync(tmpDir); + try { + await graph.indexAll(); + const hidden = graph.getNodesByName('hidden').find((node) => node.filePath === 'Lib.hs')!; + for (const filePath of ['Main.hs', 'NoImport.hs']) { + const wrap = graph.getNodesByName('wrap').find((node) => node.filePath === filePath)!; + expect(graph.getOutgoingEdges(wrap.id).some((edge) => edge.target === hidden.id)).toBe(false); + } + } finally { + graph.destroy(); + } + }); + + it('preserves Haskell re-export aliases, restrictions, qualifiedness, and children', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-haskell-reexport-semantics-')); + fs.writeFileSync(path.join(tmpDir, 'Origin.hs'), [ + 'module Origin (foo, secret, T (..)) where', + 'foo x = x', + 'secret x = x', + 'data T = A | B', + ].join('\n')); + fs.writeFileSync(path.join(tmpDir, 'Restricted.hs'), [ + 'module Restricted (module Origin) where', + 'import Origin (foo)', + ].join('\n')); + fs.writeFileSync(path.join(tmpDir, 'Qualified.hs'), [ + 'module Qualified (module Origin) where', + 'import qualified Origin', + ].join('\n')); + fs.writeFileSync(path.join(tmpDir, 'Aliased.hs'), [ + 'module Aliased (module O) where', + 'import Origin as O', + ].join('\n')); + fs.writeFileSync(path.join(tmpDir, 'Children.hs'), [ + 'module Children (T (..)) where', + 'import Origin (T (..))', + ].join('\n')); + fs.writeFileSync(path.join(tmpDir, 'Main.hs'), [ + 'module Main where', + 'import Restricted qualified as R', + 'import Qualified qualified as Q', + 'import Aliased qualified as X', + 'import Children (T (..))', + 'restricted = R.foo 1', + 'restrictedSecret = R.secret 1', + 'qualifiedOnly = Q.foo 1', + 'aliased = X.foo 1', + 'children = A', + ].join('\n')); + + const graph = CodeGraph.initSync(tmpDir); + try { + await graph.indexAll(); + const origin = (name: string) => graph.getNodesByName(name) + .find((node) => node.filePath === 'Origin.hs')!; + const reaches = (owner: string, target: string) => { + const from = graph.getNodesByName(owner).find((node) => node.filePath === 'Main.hs')!; + return graph.getOutgoingEdges(from.id).some((edge) => edge.target === origin(target).id); + }; + expect(reaches('restricted', 'foo')).toBe(true); + expect(reaches('restrictedSecret', 'secret')).toBe(false); + expect(reaches('qualifiedOnly', 'foo')).toBe(false); + expect(reaches('aliased', 'foo')).toBe(true); + expect(reaches('children', 'A')).toBe(true); + } finally { + graph.destroy(); + } + }); + + it('prefers the exact module path when duplicate Haskell headers exist', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-haskell-duplicate-module-')); + fs.mkdirSync(path.join(tmpDir, 'pkg', 'src', 'Foo'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, 'pkg', 'app'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'pkg', 'src', 'Wrong.hs'), 'module Foo.Bar where\ntarget x = x\n'); + fs.writeFileSync(path.join(tmpDir, 'pkg', 'src', 'Foo', 'Bar.hs'), 'module Foo.Bar where\ntarget x = x\n'); + fs.writeFileSync(path.join(tmpDir, 'pkg', 'app', 'Main.hs'), [ + 'module Main where', + 'import Foo.Bar (target)', + 'run x = target x', + ].join('\n')); + + const graph = CodeGraph.initSync(tmpDir); + try { + await graph.indexAll(); + const run = graph.getNodesByName('run').find((node) => node.filePath === 'pkg/app/Main.hs')!; + const exact = graph.getNodesByName('target').find((node) => node.filePath === 'pkg/src/Foo/Bar.hs')!; + const wrong = graph.getNodesByName('target').find((node) => node.filePath === 'pkg/src/Wrong.hs')!; + const targets = new Set(graph.getOutgoingEdges(run.id).map((edge) => edge.target)); + expect(targets.has(exact.id)).toBe(true); + expect(targets.has(wrong.id)).toBe(false); + } finally { + graph.destroy(); + } + }); + + it('invalidates incoming edges when a Haskell export list changes during sync', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-haskell-sync-exports-')); + const libPath = path.join(tmpDir, 'Lib.hs'); + fs.writeFileSync(libPath, 'module Lib (foo) where\nfoo x = x\n'); + fs.writeFileSync(path.join(tmpDir, 'Main.hs'), 'module Main where\nimport Lib (foo)\nrun x = foo x\n'); + + const graph = CodeGraph.initSync(tmpDir); + try { + await graph.indexAll(); + let run = graph.getNodesByName('run').find((node) => node.filePath === 'Main.hs')!; + let foo = graph.getNodesByName('foo').find((node) => node.filePath === 'Lib.hs')!; + expect(graph.getOutgoingEdges(run.id).some((edge) => edge.target === foo.id)).toBe(true); + + fs.writeFileSync(libPath, 'module Lib () where\nfoo x = x\n'); + await graph.sync(); + run = graph.getNodesByName('run').find((node) => node.filePath === 'Main.hs')!; + foo = graph.getNodesByName('foo').find((node) => node.filePath === 'Lib.hs')!; + expect(foo.isExported).toBe(false); + expect(graph.getOutgoingEdges(run.id).some((edge) => edge.target === foo.id)).toBe(false); + } finally { + graph.destroy(); + } + }); + + it('ignores imports inside nested Haskell comments', () => { + const mappings = extractImportMappings('Comments.hs', [ + 'module Comments where', + '{- outer', + ' {- inner -}', + ' import Phantom', + '-}', + 'import Real', + ].join('\n'), 'haskell'); + expect(mappings.some((mapping) => mapping.source === 'Phantom')).toBe(false); + expect(mappings.some((mapping) => mapping.source === 'Real')).toBe(true); + }); + + it('indexes long do blocks without quadratic lexical rescans', () => { + const statements = Array.from({ length: 2000 }, (_, index) => ` value${index} <- action`); + const result = extractFromSource('Generated.hs', [ + 'module Generated where', + 'run = do', + ...statements, + ' pure ()', + ].join('\n')); + expect(result.nodes.some((node) => node.name === 'run')).toBe(true); + expect(result.errors).toHaveLength(0); + }, 4000); + + it('shares grouped signatures without leaking declaration state across extractions', () => { + const source = [ + 'module Grouped where', + 'foo, bar :: Int -> Int', + 'foo 0 = 0', + 'foo x = x', + 'bar x = x', + ].join('\n'); + + for (let i = 0; i < 25; i++) { + const result = extractFromSource('Grouped.hs', source); + const foo = result.nodes.filter((node) => node.kind === 'function' && node.name === 'foo'); + const bar = result.nodes.filter((node) => node.kind === 'function' && node.name === 'bar'); + expect(foo).toHaveLength(1); + expect(bar).toHaveLength(1); + expect(foo[0]?.signature).toBe('foo, bar :: Int -> Int'); + expect(bar[0]?.signature).toBe('foo, bar :: Int -> Int'); + expect(foo[0]?.startColumn).toBe(0); + expect(foo[0]?.endLine).toBe(4); + } + }); +}); diff --git a/__tests__/pr19-improvements.test.ts b/__tests__/pr19-improvements.test.ts index 6dbd20738..e233095eb 100644 --- a/__tests__/pr19-improvements.test.ts +++ b/__tests__/pr19-improvements.test.ts @@ -299,7 +299,7 @@ describe('Best-Candidate Resolution', () => { describe('Schema v2 Migration', () => { it.skipIf(!HAS_SQLITE)('should have correct current schema version', async () => { const { CURRENT_SCHEMA_VERSION } = await import('../src/db/migrations'); - expect(CURRENT_SCHEMA_VERSION).toBe(8); + expect(CURRENT_SCHEMA_VERSION).toBe(9); }); it.skipIf(!HAS_SQLITE)('should have migration for version 2', async () => { diff --git a/docs/benchmarks/codegraph-ab-matrix.md b/docs/benchmarks/codegraph-ab-matrix.md index db9d0370d..a1bd8c16b 100644 --- a/docs/benchmarks/codegraph-ab-matrix.md +++ b/docs/benchmarks/codegraph-ab-matrix.md @@ -10,22 +10,22 @@ current `main` HEAD), so the "with" arm reflects the shipped 0.9.4 resolvers. ## Headline -**Across 37 cells, codegraph cut total file reads from 159 → 38 — 76% fewer.** It never +**Across 41 cells, codegraph cut total file reads from 227 → 38 — 83% fewer.** It never *increased* reads in any cell (0 regressions). The mechanism: a few sub-millisecond codegraph calls replace a read-and-grep exploration. -**Cost stays roughly flat — marginally higher on the with-arm here** (summed across the 37 -cells: with `$15.4` vs without `$13.8`). On these short single-flow questions the without-arm +**Cost stays roughly flat.** Across all 82 agent runs, both arms cost `$31.63` combined. On +these short single-flow questions the without-arm resolves in <10 calls and never balloons, so it doesn't reach the regime where codegraph's cost savings compound, while the with-arm pays fixed MCP overhead (tool definitions in context + -tool-loading) that short tasks don't amortize. The win is **fewer tool calls (189 vs 321, −41%) -+ lower wall-clock** (mean **38s vs 48s**), which is the design target. On harder multi-turn +tool-loading) that short tasks don't amortize. The win is **fewer file reads and shell searches +and lower wall-clock** (mean **39s vs 53s**), which is the design target. On harder multi-turn investigations cost flips to a net saving as the without-arm's accumulated context balloons — see `docs/benchmarks/call-sequence-analysis.md`. The gap widens with repo size and flow complexity: on medium/large repos the without-codegraph arm often **thrashes** — many greps/globs, shell `find`/`grep` (Bash), and occasionally spawning -a **sub-agent** — while the with-codegraph arm answers in 2–8 calls. On tiny repos (a handful of +a **sub-agent** — while the with-codegraph arm answers in 2–9 calls. On tiny repos (a handful of files) the two arms tie or codegraph is marginally slower (MCP/index overhead doesn't pay off when the whole flow fits in one or two files) — but reads still drop. @@ -53,6 +53,10 @@ when the whole flow fits in one or two files) — but reads still drop. | Go | S | `gin-realworld` | 21 | 0R / 0G | 5 | 35s | 4R / 3G / 1Gl | 57s | 4 | | Go | M | `gin-vueadmin` | 625 | 1R / 1G | 4 | 47s | 3R / 3G / 1Gl | 44s | 2 | | Go | L | `gin-gitness` | 4438 | 4R / 3G | 4 | 64s | 8R / 7G / 2Gl | 57s | 4 | +| Haskell | S | `xmonad` | 39 | 0R / 0G | 2 | 29s | 14R / 1G / 7B / 1Ag | 84s | 14 | +| Haskell | S | `shellcheck` | 33 | 0R / 0G | 9 | 57s | 20R / 1G / 18B / 1Ag | 118s | 20 | +| Haskell | M | `purescript` | 270 | 0R / 0G | 9 | 69s | 18R / 15B / 1Ag | 110s | 18 | +| Haskell | M | `pandoc` | 557 | 0R / 0G | 5 | 50s | 16R / 2G / 15B / 1Ag | 103s | 16 | | Java | S | `spring-realworld` | 117 | 2R / 0G | 3 | 35s | 8R / 1G / 5B | 57s | 6 | | Java | M | `spring-mall` | 536 | 1R / 0G | 5 | 39s | 2R / 4G / 2Gl | 49s | 1 | | Java | L | `spring-halo` | 2444 | 1R / 2G | 8 | 60s | 4R / 1G / 6B | 52s | 3 | @@ -81,10 +85,10 @@ when the whole flow fits in one or two files) — but reads still drop. | TypeScript/JS | M | `excalidraw` | 643 | 1R / 0G | 3 | 55s | 7R / 5G / 3Gl / 1B | 87s | 6 | | TypeScript/JS | L | `nest-immich` | 2759 | 1R / 0G | 7 | 50s | 3R / 0G / 1Gl | 44s | 2 | -**Totals (37 cells):** with codegraph **38 reads / 22 greps**, without **159 reads / 72 greps** — -**76% fewer reads, ~69% fewer greps.** Codegraph never increased reads in any cell, and the -without-arm additionally ran **52 globs + 37 shell `find`/`grep` (Bash) + 1 sub-agent** that the -with-arm (**0 Bash, 0 sub-agents**) never needed. (74 agent runs, $29.18 total.) +**Totals (41 cells):** with codegraph **38 reads / 22 greps**, without **227 reads / 76 greps** — +**83% fewer reads, ~71% fewer greps.** Codegraph never increased reads in any cell, and the +without-arm additionally ran **52 globs + 92 shell `find`/`grep` (Bash) + 5 sub-agents** that the +with-arm (**0 Bash, 0 sub-agents**) never needed. (82 agent runs, $31.63 total.) ## Observations @@ -94,15 +98,15 @@ with-arm (**0 Bash, 0 sub-agents**) never needed. (74 agent runs, $29.18 total.) django-wagtail (2R vs 8R), excalidraw (1R / 55s vs 7R / 87s), Luau Knit (0R vs 5R), aspnet-realworld (0R vs 5R), c-redis (0R vs 5R). - **Without codegraph, large repos make the agent thrash:** it falls back to shell `find`/`grep` - (37 Bash calls across the matrix) and on jellyfin even spawned a sub-agent — exactly the behavior - codegraph is meant to prevent. The with-arm answers those in 2–8 codegraph calls and used **0 Bash + (92 Bash calls across the matrix) and in five cells spawned a sub-agent — exactly the behavior + codegraph is meant to prevent. The with-arm answers those in 2–9 codegraph calls and used **0 Bash and 0 sub-agents** anywhere. - **Tie zone = tiny repos** (Kotlin Jetcaster 1R/1R, Rust cratesio 1R/1R, express 1R/2R, Swift template 0R/2R): the whole flow fits in 1–2 files, so reading is already cheap; codegraph ties on reads and is sometimes a few seconds slower (MCP + index overhead — Kotlin petclinic 37s vs 23s, cratesio 22s vs 18s). This matches the design note that codegraph's value scales with repo size. - **Duration tracks reads on the big repos** (jellyfin 51s vs 212s, excalidraw 55s vs 87s, aspnet-eshop - 39s vs 58s, django-wagtail 45s vs 66s) and is noise on small ones; mean wall-clock is 38s with vs 48s + 39s vs 58s, django-wagtail 45s vs 66s) and is noise on small ones; mean wall-clock is 39s with vs 53s without. - Some "with" cells still read 2–4 files (jellyfin, gitness, forem, saleor, django) — the residual is the documented frontier (anonymous handlers, deep service chains, dynamic finders); codegraph gets the diff --git a/package.json b/package.json index 2defeb11b..066a1db22 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "scripts": { "build": "tsc && npm run copy-assets && node -e \"require('fs').chmodSync('dist/bin/codegraph.js', 0o755)\"", "preuninstall": "node dist/bin/uninstall.js", - "copy-assets": "node -e \"const fs=require('fs');fs.mkdirSync('dist/db',{recursive:true});fs.copyFileSync('src/db/schema.sql','dist/db/schema.sql');fs.mkdirSync('dist/extraction/wasm',{recursive:true});fs.readdirSync('src/extraction/wasm').filter(f=>f.endsWith('.wasm')).forEach(f=>fs.copyFileSync('src/extraction/wasm/'+f,'dist/extraction/wasm/'+f))\"", + "copy-assets": "node -e \"const fs=require('fs');fs.mkdirSync('dist/db',{recursive:true});fs.copyFileSync('src/db/schema.sql','dist/db/schema.sql');fs.mkdirSync('dist/extraction/wasm',{recursive:true});fs.readdirSync('src/extraction/wasm').filter(f=>f.endsWith('.wasm')||f.endsWith('.LICENSE')).forEach(f=>fs.copyFileSync('src/extraction/wasm/'+f,'dist/extraction/wasm/'+f))\"", "dev": "tsc --watch", "build:kernel": "bash scripts/build-kernel.sh", "cli": "npm run build && node dist/bin/codegraph.js", diff --git a/scripts/add-lang/check-grammar.mjs b/scripts/add-lang/check-grammar.mjs index 461b12966..34cb77a1e 100755 --- a/scripts/add-lang/check-grammar.mjs +++ b/scripts/add-lang/check-grammar.mjs @@ -57,8 +57,13 @@ parser.setLanguage(language); let ok = 0, err = 0; for (let i = 0; i < iters; i++) { const tree = parser.parse(source); - if (tree.rootNode.hasError) err++; else ok++; + try { + if (tree.rootNode.hasError) err++; else ok++; + } finally { + tree.delete(); + } } +parser.delete(); console.log(`grammar: ${wasmPath.split('/').pop()}`); console.log(` ABI version: ${language.abiVersion}`); diff --git a/site/src/content/docs/reference/languages.md b/site/src/content/docs/reference/languages.md index 0c5587773..1f2cc7225 100644 --- a/site/src/content/docs/reference/languages.md +++ b/site/src/content/docs/reference/languages.md @@ -22,6 +22,7 @@ Language support is automatic from the file extension — there's nothing to con | Swift | `.swift` | Full support | | Kotlin | `.kt`, `.kts` | Full support | | Scala | `.scala`, `.sc` | Full support (classes, traits, methods, type aliases, Scala 3 enums) | +| Haskell | `.hs` | Partial support (modules/imports/re-exports, export lists, functions, ADTs/GADTs, records, type families, typeclasses/instances, direct/infix/higher-order calls) | | Dart | `.dart` | Full support | | Svelte | `.svelte` | Full support (script extraction, Svelte 5 runes, SvelteKit routes) | | Vue | `.vue` | Full support (script + script-setup, Nuxt page/API/middleware routes) | diff --git a/src/db/migrations.ts b/src/db/migrations.ts index d083095bb..57ad34647 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -9,7 +9,7 @@ import { SqliteDatabase } from './sqlite-adapter'; /** * Current schema version */ -export const CURRENT_SCHEMA_VERSION = 8; +export const CURRENT_SCHEMA_VERSION = 9; /** * Migration definition @@ -150,6 +150,17 @@ const migrations: Migration[] = [ `); }, }, + { + version: 9, + description: + 'Persist Haskell module/import/export topology fingerprints for scoped incremental sync invalidation', + up: (db) => { + const cols = db.prepare('PRAGMA table_info(files)').all() as Array<{ name: string }>; + if (!cols.some((column) => column.name === 'haskell_topology_hash')) { + db.exec('ALTER TABLE files ADD COLUMN haskell_topology_hash TEXT'); + } + }, + }, ]; /** diff --git a/src/db/queries.ts b/src/db/queries.ts index 3f6b14de9..ea8b8f91d 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -96,6 +96,7 @@ interface FileRow { modified_at: number; indexed_at: number; node_count: number; + haskell_topology_hash: string | null; errors: string | null; } @@ -181,6 +182,7 @@ function rowToFileRecord(row: FileRow): FileRecord { modifiedAt: row.modified_at, indexedAt: row.indexed_at, nodeCount: row.node_count, + haskellTopologyHash: row.haskell_topology_hash ?? undefined, errors: row.errors ? safeJsonParse(row.errors, undefined) : undefined, }; } @@ -291,6 +293,11 @@ export class QueryBuilder { this.db = db; } + /** Run a compound graph mutation atomically. Nested query transactions flatten. */ + transaction(fn: () => T): T { + return this.db.transaction(fn)(); + } + /** Set the normalized project-name tokens used to down-weight non-discriminative * query words in path scoring (#720). Called once when the project opens. */ setProjectNameTokens(tokens: Set): void { @@ -1847,8 +1854,8 @@ export class QueryBuilder { upsertFile(file: FileRecord): void { if (!this.stmts.upsertFile) { this.stmts.upsertFile = this.db.prepare(` - INSERT INTO files (path, content_hash, language, size, modified_at, indexed_at, node_count, errors) - VALUES (@path, @contentHash, @language, @size, @modifiedAt, @indexedAt, @nodeCount, @errors) + INSERT INTO files (path, content_hash, language, size, modified_at, indexed_at, node_count, haskell_topology_hash, errors) + VALUES (@path, @contentHash, @language, @size, @modifiedAt, @indexedAt, @nodeCount, @haskellTopologyHash, @errors) ON CONFLICT(path) DO UPDATE SET content_hash = @contentHash, language = @language, @@ -1856,6 +1863,7 @@ export class QueryBuilder { modified_at = @modifiedAt, indexed_at = @indexedAt, node_count = @nodeCount, + haskell_topology_hash = @haskellTopologyHash, errors = @errors `); } @@ -1868,6 +1876,7 @@ export class QueryBuilder { modifiedAt: file.modifiedAt, indexedAt: file.indexedAt, nodeCount: file.nodeCount, + haskellTopologyHash: file.haskellTopologyHash ?? null, errors: file.errors ? JSON.stringify(file.errors) : null, }); } diff --git a/src/db/schema.sql b/src/db/schema.sql index 75df8c05c..b58b4ab32 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -64,6 +64,7 @@ CREATE TABLE IF NOT EXISTS files ( modified_at INTEGER NOT NULL, indexed_at INTEGER NOT NULL, node_count INTEGER DEFAULT 0, + haskell_topology_hash TEXT, errors TEXT -- JSON array ); diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index 2c435b9cc..c9da4ed4c 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -50,6 +50,7 @@ const WASM_GRAMMAR_FILES: Record = { terraform: 'tree-sitter-terraform.wasm', arkts: 'tree-sitter-arkts.wasm', nix: 'tree-sitter-nix.wasm', + haskell: 'tree-sitter-haskell.wasm', }; /** @@ -170,6 +171,8 @@ export const EXTENSION_MAP: Record = { '.tf': 'terraform', '.tfvars': 'terraform', '.tofu': 'terraform', + // Haskell — vendored tree-sitter/tree-sitter-haskell 0.23.1 (MIT, ABI 14). + '.hs': 'haskell', }; /** @@ -290,7 +293,7 @@ export async function initGrammars(): Promise { */ const VENDORED_WASM_LANGS: ReadonlySet = new Set([ 'pascal', 'scala', 'lua', 'luau', 'csharp', 'r', 'cfml', 'cfscript', 'cfquery', - 'cobol', 'vbnet', 'erlang', 'terraform', 'arkts', 'nix', + 'cobol', 'vbnet', 'erlang', 'terraform', 'arkts', 'nix', 'haskell', 'typescript', 'tsx', 'javascript', 'jsx', 'java', 'python', 'go', // R7a (C/C++ kernel port prep): tree-sitter-c v0.24.2 (b780e47) + // tree-sitter-cpp v0.23.4 (f41e1a0), parser.c/scanner.c sha-matched against @@ -614,6 +617,7 @@ export function getLanguageDisplayName(language: Language): string { erlang: 'Erlang', terraform: 'Terraform', arkts: 'ArkTS', + haskell: 'Haskell', unknown: 'Unknown', }; return names[language] || language; diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 3c158a1f0..aa8eb8cd5 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -34,6 +34,7 @@ import ignore, { Ignore } from 'ignore'; import { detectFrameworks } from '../resolution/frameworks'; import type { ResolutionContext } from '../resolution/types'; import { createYielder, type MaybeYield } from '../resolution/cooperative-yield'; +import { extractImportMappings, extractReExports } from '../resolution/import-resolver'; /** * Number of files to read in parallel during indexing. @@ -115,8 +116,12 @@ export interface SyncResult { nodesUpdated: number; durationMs: number; changedFilePaths?: string[]; + /** Internal recovery signal used to invalidate resolver caches after a crash. */ + haskellImportInvalidationRecovered?: boolean; } +const HASKELL_IMPORT_INVALIDATION_PENDING = 'haskell_import_invalidation_pending'; + /** * Calculate SHA256 hash of file contents */ @@ -124,6 +129,42 @@ export function hashContent(content: string): string { return crypto.createHash('sha256').update(content).digest('hex'); } +/** + * Hash only the Haskell surface that can change cross-file import resolution. + * Bodies, source positions, signatures, and docs are intentionally excluded so + * comment-only edits do not force a project-wide Haskell replay. + */ +export function computeHaskellTopologyHash( + filePath: string, + content: string, + result: ExtractionResult, +): string { + const symbols = result.nodes + .filter((node) => node.language === 'haskell' && (node.kind === 'namespace' || node.isExported)) + .map((node) => ({ + kind: node.kind, + name: node.name, + qualifiedName: node.qualifiedName, + exportParents: (node.decorators ?? []) + .filter((decorator) => decorator.startsWith('haskell-export-parent:')), + })); + const descriptor = { + version: 'haskell-topology-v1', + symbols, + imports: extractImportMappings(filePath, content, 'haskell'), + reExports: extractReExports(content, 'haskell'), + }; + const serialized = JSON.stringify(descriptor, (_key, value: unknown) => { + if (value instanceof Set) return [...value].sort(); + if (value instanceof Map) { + return [...value.entries()] + .sort(([left], [right]) => String(left).localeCompare(String(right))); + } + return value; + }); + return hashContent(serialized); +} + /** * Skip files larger than this (bytes). Generated bundles, minified JS, and * vendored blobs blow the WASM heap and the worker-recycle budget for no useful @@ -1733,7 +1774,7 @@ export class ExtractionOrchestrator { filePath, language, buffers: result.kernelBuffers, - file: this.buildFileRecord(filePath, content, language, stats, nodeCount, result.errors), + file: this.buildFileRecord(filePath, content, language, stats, result, nodeCount), }); } else { storeWriter.send(this.buildFreshStoreBundle(filePath, content, language, stats, result)); @@ -2339,6 +2380,9 @@ export class ExtractionOrchestrator { modifiedAt: stats.mtimeMs, indexedAt: Date.now(), nodeCount: result.nodes.length, + haskellTopologyHash: language === 'haskell' + ? computeHaskellTopologyHash(filePath, content, result) + : undefined, errors: result.errors.length > 0 ? result.errors : undefined, }, }); @@ -2399,6 +2443,9 @@ export class ExtractionOrchestrator { modifiedAt: stats.mtimeMs, indexedAt: Date.now(), nodeCount: result.nodes.length, + haskellTopologyHash: language === 'haskell' + ? computeHaskellTopologyHash(filePath, content, result) + : undefined, errors: result.errors.length > 0 ? result.errors : undefined, }; this.queries.upsertFile(fileRecord); @@ -2415,8 +2462,8 @@ export class ExtractionOrchestrator { content: string, language: Language, stats: fs.Stats, - nodeCount: number, - resultErrors: ExtractionResult['errors'] + result: ExtractionResult, + nodeCountOverride?: number, ): FileRecord { return { path: filePath, @@ -2425,8 +2472,11 @@ export class ExtractionOrchestrator { size: stats.size, modifiedAt: stats.mtimeMs, indexedAt: Date.now(), - nodeCount, - errors: resultErrors.length > 0 ? resultErrors : undefined, + nodeCount: nodeCountOverride ?? result.nodes.length, + haskellTopologyHash: language === 'haskell' + ? computeHaskellTopologyHash(filePath, content, result) + : undefined, + errors: result.errors.length > 0 ? result.errors : undefined, }; } @@ -2441,16 +2491,17 @@ export class ExtractionOrchestrator { result, filePath, language, - this.buildFileRecord(filePath, content, language, stats, result.nodes.length, result.errors) + this.buildFileRecord(filePath, content, language, stats, result) ); } /** * Re-attach cross-file incoming edges snapshotted before a re-index delete - * (#899): re-resolve each edge's target to the re-indexed node's new id by - * (kind, name); targets that vanished are resurrected as their original - * unresolved ref (#1240's removal-side counterpart) when the edge carries - * its refName stamp. + * (#899). Resolution-created edges are always resurrected from their stamped + * original reference, even when a same-named target still exists: edits can + * change export lists, module names, visibility, or re-export restrictions + * without renaming the declaration. Blind reattachment would preserve a now + * illegal edge. Older/synthesized unstamped edges keep the stable id remap. */ private reattachCrossFileEdges( crossFileIncomingEdges: Array, @@ -2463,12 +2514,14 @@ export class ExtractionOrchestrator { const reinserted: Edge[] = []; const resurrected: UnresolvedReference[] = []; for (const e of crossFileIncomingEdges) { + const ref = resurrectRefFromDroppedEdge(e); + if (ref) { + resurrected.push(ref); + continue; + } const newTargetId = newNodesByKindName.get(`${e.targetKind}\0${e.targetName}`); if (newTargetId) { reinserted.push({ source: e.source, target: newTargetId, kind: e.kind, metadata: e.metadata, line: e.line, column: e.column, provenance: e.provenance }); - } else { - const ref = resurrectRefFromDroppedEdge(e); - if (ref) resurrected.push(ref); } } if (reinserted.length > 0) { @@ -2479,6 +2532,100 @@ export class ExtractionOrchestrator { } } + /** + * Haskell import resolution depends on global module headers and arbitrary + * re-export chains. The persisted edge points directly at the final symbol, + * so neither a changed/deleted intermediate facade nor a newly-added better + * duplicate module is visible from that target edge. Until the edge stores + * its complete module provenance, correctness requires invalidating every + * Haskell edge produced by import resolution whenever the Haskell module + * topology changes. + * + * Preserve extraction/synthesis edges from each affected source node, and + * resurrect only faithfully stamped import edges as their original refs. The + * normal sync orphan sweep resolves those pending refs against the final + * post-sync module/re-export graph. + */ + private invalidateHaskellImportEdges( + sourceFilePaths: Iterable, + changedModuleNames: ReadonlySet, + retryAllHaskellFiles = false, + ): number { + return this.queries.transaction(() => { + const sourceFiles = [...sourceFilePaths]; + const resurrected: UnresolvedReference[] = []; + // Recovery after an interrupted topology sync no longer has the old/new + // module-name delta. Retrying known-name failures from every Haskell file + // is rare, bounded, and restores the same result as a full fresh index. + const touchedSourceFiles = new Set(retryAllHaskellFiles ? sourceFiles : []); + let invalidated = 0; + for (const filePath of sourceFiles) { + for (const node of this.queries.getNodesByFile(filePath)) { + if (node.language !== 'haskell') continue; + const outgoing = this.queries.getOutgoingEdges(node.id); + const invalid = outgoing.filter((edge) => + edge.metadata?.resolvedBy === 'import' + && typeof edge.metadata?.refName === 'string' + && edge.metadata.refName.length > 0 + ); + if (invalid.length === 0) continue; + touchedSourceFiles.add(node.filePath); + + const invalidSet = new Set(invalid); + const preserved = outgoing.filter((edge) => !invalidSet.has(edge)); + for (const edge of invalid) { + const ref = resurrectRefFromDroppedEdge({ + ...edge, + sourceFilePath: node.filePath, + sourceLanguage: 'haskell', + }); + if (ref) resurrected.push(ref); + } + this.queries.deleteEdgesBySource(node.id); + if (preserved.length > 0) this.queries.insertEdges(preserved); + invalidated += invalid.length; + } + } + + // A facade that was absent during the previous sync has no surviving edge + // to invalidate when it reappears. Its failed module-import ref still tells + // us which source files depend on the changed module; include those files + // in the retry set, then reset only refs whose leaf now exists somewhere in + // the project (avoids retrying every Prelude/external name). + const existingRefs = this.queries.getUnresolvedReferences() + .filter((ref) => ref.language === 'haskell'); + for (const ref of existingRefs) { + if (ref.filePath && ref.referenceKind === 'imports' && changedModuleNames.has(ref.referenceName)) { + touchedSourceFiles.add(ref.filePath); + } + } + const knownNames = new Set(this.queries.getAllNodeNames()); + const retryable = existingRefs.filter((ref) => { + if (!ref.filePath || !touchedSourceFiles.has(ref.filePath)) return false; + const normalized = ref.referenceName.replace(/^\(+|\)+$/g, ''); + const separator = normalized.lastIndexOf('::'); + const leaf = separator >= 0 ? normalized.slice(separator + 2) : normalized; + return knownNames.has(normalized) + || knownNames.has(leaf) + || knownNames.has(`(${leaf})`); + }); + const retryRowIds = retryable.flatMap((ref) => ref.rowId === undefined ? [] : [ref.rowId]); + if (retryRowIds.length > 0) this.queries.deleteReferencesByRowIds(retryRowIds); + + const BATCH = 2000; + for (let i = 0; i < retryable.length; i += BATCH) { + this.queries.insertUnresolvedRefsBatch(retryable.slice(i, i + BATCH)); + } + for (let i = 0; i < resurrected.length; i += BATCH) { + this.queries.insertUnresolvedRefsBatch(resurrected.slice(i, i + BATCH)); + } + // Clearing the durable marker in the same transaction as edge→ref + // conversion makes an interruption either fully committed or replayable. + this.queries.setMetadata(HASKELL_IMPORT_INVALIDATION_PENDING, '0'); + return invalidated; + }); + } + /** * Sync the index with the current file state. * @@ -2496,6 +2643,28 @@ export class ExtractionOrchestrator { let filesRemoved = 0; let nodesUpdated = 0; const changedFilePaths: string[] = []; + const recoveringHaskellInvalidation = + this.queries.getMetadata(HASKELL_IMPORT_INVALIDATION_PENDING) === '1'; + let haskellTopologyChanged = recoveringHaskellInvalidation; + let haskellRecoveryArmed = recoveringHaskellInvalidation; + const modifiedHaskellTopologies = new Map(); + const changedHaskellModuleNames = new Set(); + const armHaskellInvalidationRecovery = (): void => { + // Commit the recovery intent before any file/edge mutation. If a later + // write or the invalidation transaction is interrupted, the next sync + // replays a broad Haskell invalidation even when file hashes now match. + if (!haskellRecoveryArmed) { + this.queries.setMetadata(HASKELL_IMPORT_INVALIDATION_PENDING, '1'); + haskellRecoveryArmed = true; + } + }; + const markHaskellTopologyChanged = (): void => { + armHaskellInvalidationRecovery(); + haskellTopologyChanged = true; + }; onProgress?.({ phase: 'scanning', @@ -2523,8 +2692,10 @@ export class ExtractionOrchestrator { const trackedFiles = this.queries.getAllFiles(); if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-tracked-load: ${Date.now() - tTracked}ms (${trackedFiles.length} tracked)`); const trackedMap = new Map(); + const haskellSourcePaths = new Set(); for (const f of trackedFiles) { trackedMap.set(f.path, f); + if (f.language === 'haskell') haskellSourcePaths.add(f.path); } // Removals: tracked in the DB but no longer a present source file. Check the @@ -2535,6 +2706,14 @@ export class ExtractionOrchestrator { let reconcileChecks = 0; for (const tracked of trackedFiles) { if (!currentSet.has(tracked.path) || !fs.existsSync(path.join(this.rootDir, tracked.path))) { + if (tracked.language === 'haskell') { + markHaskellTopologyChanged(); + for (const node of this.queries.getNodesByFile(tracked.path)) { + if (node.kind === 'namespace' && node.language === 'haskell') { + changedHaskellModuleNames.add(node.name); + } + } + } // Before the cascade deletes them, resurrect incoming cross-file // resolution edges as their original refs (#1240 removal case): the // callers live in files this sync will NOT revisit, so this is their @@ -2569,6 +2748,9 @@ export class ExtractionOrchestrator { } const fullPath = path.join(this.rootDir, filePath); const tracked = trackedMap.get(filePath); + const isHaskell = tracked?.language === 'haskell' + || detectLanguage(filePath) === 'haskell'; + if (isHaskell) haskellSourcePaths.add(filePath); // Cheap pre-filter: an already-indexed file whose size AND mtime both match // the DB is unchanged — skip it without reading or hashing. (A content @@ -2601,7 +2783,23 @@ export class ExtractionOrchestrator { filesToIndex.push(filePath); changedFilePaths.push(filePath); filesAdded++; + if (isHaskell) markHaskellTopologyChanged(); } else if (tracked.contentHash !== contentHash) { + if (isHaskell) { + // Arm recovery before indexFile mutates nodes/file records, but defer + // the expensive global replay decision until the freshly committed + // topology fingerprint can be compared with this old one. + armHaskellInvalidationRecovery(); + modifiedHaskellTopologies.set(filePath, { + oldTopologyHash: tracked.haskellTopologyHash, + expectedContentHash: contentHash, + }); + for (const node of this.queries.getNodesByFile(filePath)) { + if (node.kind === 'namespace' && node.language === 'haskell') { + changedHaskellModuleNames.add(node.name); + } + } + } filesToIndex.push(filePath); changedFilePaths.push(filePath); filesModified++; @@ -2634,6 +2832,43 @@ export class ExtractionOrchestrator { nodesUpdated += result.nodes.length; } + if (!recoveringHaskellInvalidation) { + for (const [filePath, previous] of modifiedHaskellTopologies) { + const current = this.queries.getFileByPath(filePath); + const topologyUnchanged = previous.oldTopologyHash !== undefined + && current?.contentHash === previous.expectedContentHash + && current.haskellTopologyHash === previous.oldTopologyHash; + if (!topologyUnchanged) { + haskellTopologyChanged = true; + for (const node of this.queries.getNodesByFile(filePath)) { + if (node.kind === 'namespace' && node.language === 'haskell') { + changedHaskellModuleNames.add(node.name); + } + } + } + } + } + + if (haskellTopologyChanged) { + for (const filePath of filesToIndex) { + for (const node of this.queries.getNodesByFile(filePath)) { + if (node.kind === 'namespace' && node.language === 'haskell') { + changedHaskellModuleNames.add(node.name); + } + } + } + this.invalidateHaskellImportEdges( + haskellSourcePaths, + changedHaskellModuleNames, + recoveringHaskellInvalidation, + ); + } else if (haskellRecoveryArmed) { + // Every modified Haskell file committed the same cross-file topology. + // Clearing the pre-mutation marker is now safe; a crash before this line + // merely causes one conservative broad recovery on the next sync. + this.queries.setMetadata(HASKELL_IMPORT_INVALIDATION_PENDING, '0'); + } + return { filesChecked, filesAdded, @@ -2642,6 +2877,7 @@ export class ExtractionOrchestrator { nodesUpdated, durationMs: Date.now() - startTime, changedFilePaths: changedFilePaths.length > 0 ? changedFilePaths : undefined, + haskellImportInvalidationRecovered: recoveringHaskellInvalidation || undefined, }; } diff --git a/src/extraction/languages/haskell.ts b/src/extraction/languages/haskell.ts new file mode 100644 index 000000000..9aa42441d --- /dev/null +++ b/src/extraction/languages/haskell.ts @@ -0,0 +1,1729 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { getChildByField, getNodeText, getPrecedingDocstring } from '../tree-sitter-helpers'; +import type { ExtractorContext, LanguageExtractor } from '../tree-sitter-types'; + +// tree-sitter-haskell emits signatures, default signatures, and equations as +// separate syntax nodes. Keep them under one graph node per lexical declaration. +// The syntax-parent span distinguishes same-named locals in separate let blocks; +// methods are already uniquely scoped by their typeclass/instance owner. +// The core creates a lightweight ExtractorContext wrapper for every visited +// syntax node, so key the per-extraction state by the stable nodes array rather +// than by the ephemeral context object. Once an extraction result is released, +// the WeakMap entry can be collected; daemon re-indexing cannot accumulate old +// source coordinates indefinitely. +type ExtractedNode = NonNullable>; + +interface HaskellExtractionState { + declarationGroups: Map; + moduleExports?: HaskellModuleExports | null; + lexicalBindings: Map>; + signatureIndexes: Map>; + scopeNodes: Map; +} + +interface LexicalBinding { + startIndex: number; + materializedNode: boolean; +} + +const extractionStates = new WeakMap(); + +function extractionState(owner: object): HaskellExtractionState { + let state = extractionStates.get(owner); + if (!state) { + state = { + declarationGroups: new Map(), + lexicalBindings: new Map(), + signatureIndexes: new Map(), + scopeNodes: new Map(), + }; + extractionStates.set(owner, state); + } + return state; +} + +function declarationGroupMap(ctx: ExtractorContext): Map { + return extractionState(ctx.nodes as object).declarationGroups; +} + +function scopeOwner(ctx: ExtractorContext, id?: string): ExtractedNode | undefined { + const ownerId = id ?? ctx.nodeStack[ctx.nodeStack.length - 1]; + if (!ownerId) return undefined; + const state = extractionState(ctx.nodes as object); + if (state.scopeNodes.has(ownerId)) return state.scopeNodes.get(ownerId); + const owner = ctx.nodes.find((candidate) => candidate.id === ownerId); + state.scopeNodes.set(ownerId, owner); + return owner; +} + +function collapseWhitespace(text: string): string { + return text.replace(/\s+/g, ' ').trim(); +} + +function firstDescendant(node: SyntaxNode, types: ReadonlySet): SyntaxNode | null { + if (types.has(node.type)) return node; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) continue; + const hit = firstDescendant(child, types); + if (hit) return hit; + } + return null; +} + +interface HaskellModuleExports { + direct: Set; + allChildren: Set; + children: Map>; + exportsSelf: boolean; +} + +function syntaxRoot(node: SyntaxNode): SyntaxNode { + let root = node; + while (root.parent) root = root.parent; + return root; +} + +function normalizedExportName(node: SyntaxNode, source: string): string { + const text = getNodeText(node, source).replace(/\s+/g, '').trim(); + return text.startsWith('(') && text.endsWith(')') ? text.slice(1, -1) : text; +} + +function parenthesizedOperator(name: string): string { + const compact = name.replace(/\s+/g, '').trim(); + if (!compact) return compact; + return compact.startsWith('(') && compact.endsWith(')') ? compact : `(${compact})`; +} + +function getHaskellPrecedingDocstring(node: SyntaxNode, source: string): string | undefined { + const direct = getPrecedingDocstring(node, source); + if (direct) return direct; + // tree-sitter-haskell places a Haddock comment for the first declaration as + // a sibling of the `declarations` wrapper rather than inside it. + if ( + node.parent?.type === 'declarations' + && node.previousNamedSibling === null + ) { + return getPrecedingDocstring(node.parent, source); + } + return undefined; +} + +/** Canonical reference spelling used by the Haskell import resolver. */ +function normalizeReferenceText(text: string): string { + let compact = text.replace(/\s+/g, '').trim().replace(/^`|`$/g, ''); + // tree-sitter represents a qualified prefix operator as one prefix_id whose + // text is `(L.<+>)`. Remove only those reference-position parentheses; an + // unqualified operator declaration keeps its conventional `(+)` node name. + if (compact.startsWith('(') && compact.endsWith(')')) { + const inner = compact.slice(1, -1); + if (/^(?:[A-Z][A-Za-z0-9_']*\.)+/.test(inner)) compact = inner; + } + const qualified = compact.match(/^((?:[A-Z][A-Za-z0-9_']*\.)+)(.+)$/); + return qualified + ? `${qualified[1]!.slice(0, -1)}::${qualified[2]}` + : compact; +} + +interface HaskellDeclarationHead { + /** Declared family/class/type name, parenthesized when it is an operator. */ + baseName: string; + /** Full applied LHS, useful for instance node display names. */ + displayName: string; + nameNode: SyntaxNode; + patterns: SyntaxNode | null; + isInfix: boolean; +} + +/** Extract a declaration's LHS without ever falling through into its RHS. */ +function declarationHead(node: SyntaxNode, source: string): HaskellDeclarationHead | null { + const nameNode = getChildByField(node, 'name'); + if (nameNode) { + const baseName = getNodeText(nameNode, source).trim(); + const patterns = getChildByField(node, 'patterns'); + return { + baseName, + displayName: collapseWhitespace(`${baseName} ${patterns ? getNodeText(patterns, source) : ''}`), + nameNode, + patterns, + isInfix: false, + }; + } + + const infix = node.namedChildren.find((child) => child.type === 'infix'); + const operator = infix ? getChildByField(infix, 'operator') : null; + if (!infix || !operator) return null; + return { + baseName: parenthesizedOperator(getNodeText(operator, source)), + displayName: collapseWhitespace(getNodeText(infix, source)), + nameNode: operator, + patterns: infix, + isInfix: true, + }; +} + +/** Parse the module header's explicit export list from the existing AST. */ +function parseModuleExports(node: SyntaxNode, source: string): HaskellModuleExports | null { + const root = syntaxRoot(node); + const header = root.namedChildren.find((child) => child.type === 'header'); + if (!header) return null; + const exportsNode = getChildByField(header, 'exports'); + if (!exportsNode) return null; // No list means Haskell's normal "export all". + + const result: HaskellModuleExports = { + direct: new Set(), + allChildren: new Set(), + children: new Map(), + exportsSelf: false, + }; + const ownModule = getChildByField(header, 'module'); + const ownModuleName = ownModule ? getNodeText(ownModule, source).replace(/\s+/g, '') : ''; + + for (const entry of exportsNode.namedChildren) { + if (entry.type === 'module_export') { + const reexported = getChildByField(entry, 'module'); + if (reexported && getNodeText(reexported, source).replace(/\s+/g, '') === ownModuleName) { + result.exportsSelf = true; + } + continue; + } + if (entry.type !== 'export') continue; + const value = getChildByField(entry, 'variable') + ?? getChildByField(entry, 'type') + ?? firstDescendant(entry, new Set(['variable', 'constructor', 'name', 'prefix_id'])); + if (!value) continue; + const name = normalizedExportName(value, source); + if (!name) continue; + result.direct.add(name); + + const children = getChildByField(entry, 'children'); + if (!children) continue; + if (children.namedChildren.some((child) => child.type === 'all_names')) { + result.allChildren.add(name); + continue; + } + const names = new Set(); + const stack = [...children.namedChildren]; + while (stack.length > 0) { + const current = stack.pop()!; + if (['variable', 'constructor', 'name', 'prefix_id'].includes(current.type)) { + const childName = normalizedExportName(current, source); + if (childName) names.add(childName); + } else { + stack.push(...current.namedChildren); + } + } + result.children.set(name, names); + } + return result; +} + +function moduleExports( + node: SyntaxNode, + source: string, + stateOwner: object, +): HaskellModuleExports | null { + const state = extractionState(stateOwner); + if (state.moduleExports !== undefined) return state.moduleExports; + state.moduleExports = parseModuleExports(node, source); + return state.moduleExports; +} + +function isNameExported( + node: SyntaxNode, + source: string, + name: string, + stateOwner: object, + parentName?: string, +): boolean { + const exports = moduleExports(node, source, stateOwner); + if (!exports || exports.exportsSelf) return true; + const canonical = name.startsWith('(') && name.endsWith(')') ? name.slice(1, -1) : name; + const canonicalParent = parentName?.startsWith('(') && parentName.endsWith(')') + ? parentName.slice(1, -1) + : parentName; + if (!canonicalParent) return exports.direct.has(canonical); + return exports.direct.has(canonical) + || exports.allChildren.has(canonicalParent) + || exports.children.get(canonicalParent)?.has(canonical) + || false; +} + +/** Explicit parents that bundle a child in the module export list (`T(P)`). */ +function bundledExportParents( + node: SyntaxNode, + source: string, + name: string, + stateOwner: object, +): string[] { + const exports = moduleExports(node, source, stateOwner); + if (!exports) return []; + const canonical = name.startsWith('(') && name.endsWith(')') ? name.slice(1, -1) : name; + return [...exports.children] + .filter(([, children]) => children.has(canonical)) + .map(([parent]) => parent); +} + +function signatureNames(node: SyntaxNode, source: string): string[] { + const names = getChildByField(node, 'names'); + if (names) { + return names.namedChildren + .filter((child) => child.type === 'variable' || child.type === 'prefix_id') + .map((child) => getNodeText(child, source).trim()) + .filter(Boolean); + } + const name = getChildByField(node, 'name'); + return name ? [getNodeText(name, source).trim()] : []; +} + +function signatureIndex( + container: SyntaxNode, + source: string, + stateOwner: object, +): Map { + const key = `${container.type}:${container.startIndex}:${container.endIndex}`; + const state = extractionState(stateOwner); + const cached = state.signatureIndexes.get(key); + if (cached) return cached; + + const index = new Map(); + for (const child of container.namedChildren) { + const signature = child.type === 'signature' + ? child + : child.type === 'default_signature' + ? child.namedChildren.find((candidate) => candidate.type === 'signature') ?? null + : null; + if (!signature) continue; + for (const name of signatureNames(signature, source)) { + const entries = index.get(name) ?? []; + entries.push(signature); + index.set(name, entries); + } + } + state.signatureIndexes.set(key, index); + return index; +} + +function precedingSignature( + node: SyntaxNode, + name: string, + source: string, + stateOwner: object, +): SyntaxNode | null { + const container = node.parent; + if (!container) return null; + const candidates = signatureIndex(container, source, stateOwner).get(name); + if (!candidates) return null; + for (let i = candidates.length - 1; i >= 0; i--) { + if (candidates[i]!.startIndex < node.startIndex) return candidates[i]!; + } + return null; +} + +function patternSynonymNameNode(node: SyntaxNode): SyntaxNode | null { + const equation = node.namedChildren.find((child) => child.type === 'equation'); + const signature = node.namedChildren.find((child) => child.type === 'signature'); + let nameNode = equation + ? getChildByField(equation, 'synonym') + : signature + ? getChildByField(signature, 'synonym') ?? getChildByField(signature, 'name') + : null; + while (nameNode?.type === 'apply') nameNode = getChildByField(nameNode, 'function'); + if (nameNode?.type === 'infix') nameNode = getChildByField(nameNode, 'operator'); + if (nameNode?.type === 'record') nameNode = getChildByField(nameNode, 'constructor'); + return nameNode; +} + +function patternSynonymName(node: SyntaxNode, source: string): string { + const nameNode = patternSynonymNameNode(node); + if (!nameNode) return ''; + const rawName = normalizedExportName(nameNode, source); + return nameNode.type === 'constructor_operator' || nameNode.type === 'prefix_id' + ? parenthesizedOperator(rawName) + : rawName; +} + +function patternSignatureIndex( + container: SyntaxNode, + source: string, + stateOwner: object, +): Map { + const key = `pattern:${container.type}:${container.startIndex}:${container.endIndex}`; + const state = extractionState(stateOwner); + const cached = state.signatureIndexes.get(key); + if (cached) return cached; + + const index = new Map(); + for (const child of container.namedChildren) { + if (child.type !== 'pattern_synonym') continue; + const signature = child.namedChildren.find((candidate) => candidate.type === 'signature'); + if (!signature) continue; + const synonym = getChildByField(signature, 'synonym') ?? getChildByField(signature, 'name'); + const nameNodes = synonym?.type === 'binding_list' + ? synonym.namedChildren + : synonym + ? [synonym] + : []; + for (const nameNode of nameNodes) { + const rawName = normalizedExportName(nameNode, source); + if (!rawName) continue; + const name = nameNode.type === 'constructor_operator' + || nameNode.type === 'operator' + || nameNode.type === 'prefix_id' + || rawName.startsWith(':') + ? parenthesizedOperator(rawName) + : rawName; + const entries = index.get(name) ?? []; + // Retain the wrapper: the nested `signature` span omits the `pattern` + // keyword and a preceding Haddock comment belongs to this declaration. + entries.push(child); + index.set(name, entries); + } + } + state.signatureIndexes.set(key, index); + return index; +} + +function precedingPatternSignature( + node: SyntaxNode, + name: string, + source: string, + stateOwner: object, +): SyntaxNode | null { + const container = node.parent; + if (!container) return null; + const candidates = patternSignatureIndex(container, source, stateOwner).get(name); + if (!candidates) return null; + for (let i = candidates.length - 1; i >= 0; i--) { + if (candidates[i]!.startIndex < node.startIndex) return candidates[i]!; + } + return null; +} + +function declarationGroupKey( + node: SyntaxNode, + name: string, + kind: 'function' | 'method' | 'constant', + ctx: ExtractorContext, +): string { + const scopeId = ctx.nodeStack[ctx.nodeStack.length - 1] ?? ''; + if (kind === 'method') return `${ctx.filePath}:method:${scopeId}:${name}`; + const container = node.parent; + const containerSpan = container ? `${container.startIndex}:${container.endIndex}` : 'root'; + return `${ctx.filePath}:${kind}:${scopeId}:${containerSpan}:${name}`; +} + +function lexicalRangeDecorator(node: SyntaxNode): string | null { + let ancestor = node.parent; + while (ancestor) { + if (['let_in', 'alternative', 'function', 'do', 'list_comprehension'].includes(ancestor.type)) { + return `haskell-lexical-range:${ancestor.startPosition.row + 1}:${ancestor.endPosition.row + 1}`; + } + ancestor = ancestor.parent; + } + return null; +} + +function groupedNode(key: string, ctx: ExtractorContext) { + return declarationGroupMap(ctx).get(key); +} + +function extendGroupedNode(node: SyntaxNode, existing: NonNullable>): void { + const startLine = node.startPosition.row + 1; + const endLine = node.endPosition.row + 1; + if (startLine < existing.startLine) { + existing.startLine = startLine; + existing.startColumn = node.startPosition.column; + } else if (startLine === existing.startLine) { + existing.startColumn = Math.min(existing.startColumn, node.startPosition.column); + } + if (endLine > existing.endLine) { + existing.endLine = endLine; + existing.endColumn = node.endPosition.column; + } else if (endLine === existing.endLine) { + existing.endColumn = Math.max(existing.endColumn, node.endPosition.column); + } +} + +function visitFunctionPayload(node: SyntaxNode, functionId: string, ctx: ExtractorContext): void { + ctx.pushScope(functionId); + const explicitPattern = getChildByField(node, 'pattern'); + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) continue; + if ( + child.type === 'match' + || child.type === 'patterns' + || (!!explicitPattern + && child.startIndex === explicitPattern.startIndex + && child.endIndex === explicitPattern.endIndex) + || child.type === 'local_binds' + || child.type === 'where' + ) { + // Route through the full visitor, not the calls-only body walker: Haskell + // permits named local functions in let/where blocks and those need the + // language hook for clause grouping and signature attachment. + ctx.visitNode(child); + } else if (child.type === 'infix') { + // An infix declaration stores its two parameter patterns on the infix + // head rather than in a `patterns` field. Visit the operands, but not the + // declaring operator itself (which is not a reference). + const left = getChildByField(child, 'left_operand'); + const right = getChildByField(child, 'right_operand'); + if (left) ctx.visitNode(left); + if (right) ctx.visitNode(right); + } + } + ctx.popScope(); +} + +function signatureDeclaresFunction(signatureNode: SyntaxNode): boolean { + let type = getChildByField(signatureNode, 'type'); + while (type && (type.type === 'forall' || type.type === 'context' || type.type === 'parens')) { + type = getChildByField(type, 'type') ?? (type.namedChildCount === 1 ? type.namedChild(0) : null); + } + return type?.type === 'function'; +} + +function isFunctionBinding(node: SyntaxNode, signatureNode: SyntaxNode | null, source: string): boolean { + if (signatureNode && signatureDeclaresFunction(signatureNode)) return true; + const match = getChildByField(node, 'match'); + let expression = match ? getChildByField(match, 'expression') : null; + while (expression?.type === 'parens' && expression.namedChildCount === 1) { + expression = expression.namedChild(0); + } + if ( + expression?.type === 'lambda' + || expression?.type === 'lambda_case' + || expression?.type === 'left_section' + || expression?.type === 'right_section' + ) return true; + if (expression?.type === 'infix') { + const operator = getChildByField(expression, 'operator'); + const operatorName = operator ? getNodeText(operator, source).trim() : ''; + return operatorName === '.' || operatorName === '>=>' || operatorName === '<=<'; + } + return false; +} + +// Function-first combinators for which a bare first argument is genuinely +// invoked by the combinator. Data-first variants (`forM_`, `for_`, …) are +// intentionally excluded. +const HOF_NAMES = new Set([ + 'map', 'fmap', 'filter', 'foldr', 'foldl', "foldl'", 'foldr1', 'foldl1', + 'concatMap', 'find', 'any', 'all', 'mapM', 'mapM_', 'foldM', 'foldM_', + 'traverse', 'traverse_', 'mapAccumL', 'mapAccumR', 'takeWhile', 'dropWhile', + 'span', 'break', 'partition', 'groupBy', 'sortBy', 'nubBy', 'deleteBy', + 'insertBy', 'unionBy', 'intersectBy', 'zipWith', 'zipWith3', 'zipWithM', + 'zipWithM_', 'iterate', 'unfoldr', 'until', +]); + +function patternContainsName(pattern: SyntaxNode | null, name: string, source: string): boolean { + return collectPatternNames(pattern, source).includes(name); +} + +function nodeContains(container: SyntaxNode | null, node: SyntaxNode): boolean { + return !!container + && node.startIndex >= container.startIndex + && node.endIndex <= container.endIndex; +} + +function collectPatternNames(pattern: SyntaxNode | null, source: string): string[] { + if (!pattern) return []; + const names: string[] = []; + const stack = [pattern]; + while (stack.length > 0) { + const current = stack.pop()!; + if (current.type === 'view_pattern') { + // The expression is executed; only the RHS pattern introduces names. + const nestedPattern = getChildByField(current, 'pattern'); + if (nestedPattern) stack.push(nestedPattern); + continue; + } + if (current.type === 'field_pattern') { + const nestedPattern = getChildByField(current, 'pattern'); + if (nestedPattern) { + // `R { field = x }` binds x, not the label `field`. + stack.push(nestedPattern); + } else { + // `R { field }` is a field pun and does bind `field`. + const field = getChildByField(current, 'field'); + const variable = field ? firstDescendant(field, new Set(['variable'])) : null; + if (variable) { + const name = getNodeText(variable, source).trim(); + if (name) names.push(name); + } + } + continue; + } + if (current.type === 'variable') { + const name = getNodeText(current, source).trim(); + if (name) names.push(name); + } else { + stack.push(...current.namedChildren); + } + } + return names; +} + +function functionPatternRoots(node: SyntaxNode): SyntaxNode[] { + const patterns = getChildByField(node, 'patterns'); + if (patterns) return [patterns]; + const infix = node.namedChildren.find((child) => child.type === 'infix'); + if (!infix) return []; + return [getChildByField(infix, 'left_operand'), getChildByField(infix, 'right_operand')] + .filter((candidate): candidate is SyntaxNode => candidate !== null); +} + +function declarationBindings( + node: SyntaxNode, + source: string, + stateOwner: object, +): Array<[string, boolean]> { + const bindings: Array<[string, boolean]> = []; + const stack = [node]; + while (stack.length > 0) { + const current = stack.pop()!; + if (current.type === 'function') { + const head = declarationHead(current, source); + const name = head?.baseName ?? ''; + if (name) bindings.push([name, true]); + continue; // A declaration RHS introduces no additional names here. + } + if (current.type === 'bind') { + const pattern = getChildByField(current, 'pattern'); + if (pattern) { + for (const name of collectPatternNames(pattern, source)) bindings.push([name, false]); + } else { + const nameNode = getChildByField(current, 'name'); + const name = nameNode ? getNodeText(nameNode, source).trim() : ''; + if (name) { + const signature = precedingSignature(current, name, source, stateOwner); + bindings.push([name, isFunctionBinding(current, signature, source)]); + } + } + continue; + } + if (current.type === 'generator' || current.type === 'pattern_guard') { + for (const name of collectPatternNames(getChildByField(current, 'pattern'), source)) { + bindings.push([name, false]); + } + continue; + } + stack.push(...current.namedChildren); + } + return bindings; +} + +function lexicalBindingIndex( + container: SyntaxNode, + source: string, + stateOwner?: object, +): Map { + const key = `${container.type}:${container.startIndex}:${container.endIndex}`; + const state = stateOwner ? extractionState(stateOwner) : undefined; + const cached = state?.lexicalBindings.get(key); + if (cached) return cached; + + const index = new Map(); + for (const child of container.namedChildren) { + for (const [name, materializedNode] of declarationBindings(child, source, stateOwner ?? container)) { + const occurrences = index.get(name) ?? []; + occurrences.push({ startIndex: child.startIndex, materializedNode }); + index.set(name, occurrences); + } + } + state?.lexicalBindings.set(key, index); + return index; +} + +function bindingAt( + container: SyntaxNode, + name: string, + source: string, + beforeIndex: number | null, + stateOwner?: object, +): LexicalBinding | undefined { + const occurrences = lexicalBindingIndex(container, source, stateOwner).get(name); + if (!occurrences) return undefined; + if (beforeIndex === null) return occurrences[occurrences.length - 1]; + for (let i = occurrences.length - 1; i >= 0; i--) { + if (occurrences[i]!.startIndex < beforeIndex) return occurrences[i]; + } + return undefined; +} + +/** Haskell lexical values that must not become global/imported callees. */ +function isLexicallyBound( + name: string, + node: SyntaxNode, + source: string, + stateOwner?: object, +): boolean { + if (!name || name.includes('::')) return false; + let branch = node; + let ancestor = node.parent; + while (ancestor) { + if (ancestor.type === 'function' || ancestor.type === 'lambda') { + const patternRoots = ancestor.type === 'function' + ? functionPatternRoots(ancestor) + : [getChildByField(ancestor, 'patterns')].filter( + (candidate): candidate is SyntaxNode => candidate !== null, + ); + if (patternRoots.some((pattern) => patternContainsName(pattern, name, source))) return true; + const whereBinds = getChildByField(ancestor, 'binds'); + if (whereBinds) { + const binding = bindingAt(whereBinds, name, source, null, stateOwner); + if (binding && !binding.materializedNode) return true; + } + } + if (ancestor.type === 'bind') { + const whereBinds = getChildByField(ancestor, 'binds'); + if (whereBinds) { + const binding = bindingAt(whereBinds, name, source, null, stateOwner); + if (binding && !binding.materializedNode) return true; + } + } + if (ancestor.type === 'pattern_synonym') { + const equation = ancestor.namedChildren.find((child) => child.type === 'equation'); + const synonym = equation ? getChildByField(equation, 'synonym') : null; + if (patternContainsName(synonym, name, source)) return true; + } + if (ancestor.type === 'alternative') { + if (patternContainsName(getChildByField(ancestor, 'pattern'), name, source)) return true; + } + if (ancestor.type === 'let_in' || ancestor.type === 'let') { + const binds = getChildByField(ancestor, 'binds'); + if (binds) { + // Haskell let bindings are recursive: every declaration is in scope in + // every RHS as well as in the body. + const binding = bindingAt(binds, name, source, null, stateOwner); + if (binding && !binding.materializedNode) return true; + } + } + if (ancestor.type === 'local_binds') { + const binding = bindingAt(ancestor, name, source, null, stateOwner); + if (binding && !binding.materializedNode) return true; + } + if (ancestor.type === 'rec') { + // RecursiveDo's explicit `rec` block brings every contained monadic + // binder into scope throughout the block, even inside earlier RHSs. + const binding = bindingAt(ancestor, name, source, null, stateOwner); + if (binding && !binding.materializedNode) return true; + } + if (ancestor.type === 'do') { + const recursiveDo = /^\s*(?:[A-Z][A-Za-z0-9_']*(?:\.[A-Z][A-Za-z0-9_']*)*\.)?mdo\b/ + .test(getNodeText(ancestor, source)); + const binding = bindingAt( + ancestor, + name, + source, + recursiveDo ? null : branch.startIndex, + stateOwner, + ); + if (binding && !binding.materializedNode) return true; + } + if (ancestor.type === 'qualifiers') { + const binding = bindingAt(ancestor, name, source, branch.startIndex, stateOwner); + if (binding && !binding.materializedNode) return true; + } + if (ancestor.type === 'list_comprehension') { + const output = getChildByField(ancestor, 'expression'); + const qualifiers = getChildByField(ancestor, 'qualifiers'); + if (output && qualifiers && nodeContains(output, node)) { + const binding = bindingAt(qualifiers, name, source, null, stateOwner); + if (binding && !binding.materializedNode) return true; + } + } + if (ancestor.type === 'guards') { + const binding = bindingAt(ancestor, name, source, branch.startIndex, stateOwner); + if (binding && !binding.materializedNode) return true; + } + if (ancestor.type === 'match') { + const guards = getChildByField(ancestor, 'guards'); + const expression = getChildByField(ancestor, 'expression'); + if (guards && expression && nodeContains(expression, node)) { + const binding = bindingAt(guards, name, source, null, stateOwner); + if (binding && !binding.materializedNode) return true; + } + } + branch = ancestor; + ancestor = ancestor.parent; + } + return false; +} + +function isHaskellPatternPosition(node: SyntaxNode): boolean { + let ancestor = node.parent; + while (ancestor) { + if (ancestor.type === 'view_pattern') { + const expression = getChildByField(ancestor, 'expression'); + if (expression && nodeContains(expression, node)) return false; + } + const pattern = getChildByField(ancestor, 'pattern') + ?? getChildByField(ancestor, 'patterns') + ?? getChildByField(ancestor, 'synonym'); + if (pattern && nodeContains(pattern, node)) return true; + if (ancestor.type === 'function') { + const infix = ancestor.namedChildren.find((child) => child.type === 'infix'); + const left = infix ? getChildByField(infix, 'left_operand') : null; + const right = infix ? getChildByField(infix, 'right_operand') : null; + if (nodeContains(left, node) || nodeContains(right, node)) return true; + } + ancestor = ancestor.parent; + } + return false; +} + +function normalizedSimpleReference(node: SyntaxNode, source: string): { name: string; node: SyntaxNode } | null { + let current: SyntaxNode | null = node; + while (current?.type === 'parens' && current.namedChildCount === 1) { + current = current.namedChild(0); + } + while (current?.type === 'infix_id' && current.namedChildCount === 1) { + current = current.namedChild(0); + } + if ( + !current + || ![ + 'variable', 'constructor', 'name', 'operator', 'constructor_operator', + 'qualified', 'prefix_id', + ].includes(current.type) + ) return null; + const name = normalizeReferenceText(getNodeText(current, source)); + return name ? { name, node: current } : null; +} + +function referenceHead(node: SyntaxNode | null, source: string): { name: string; node: SyntaxNode } | null { + let current = node; + while (current?.type === 'parens' && current.namedChildCount === 1) { + current = current.namedChild(0); + } + while (current?.type === 'apply') { + current = getChildByField(current, 'constructor') + ?? getChildByField(current, 'function') + ?? current.namedChild(0); + } + if (current?.type === 'infix') { + const operator = getChildByField(current, 'operator'); + if (!operator) return null; + return { name: normalizeReferenceText(getNodeText(operator, source)), node: operator }; + } + return current ? normalizedSimpleReference(current, source) : null; +} + +function constraintHeads( + node: SyntaxNode | null, + source: string, +): Array<{ name: string; node: SyntaxNode }> { + if (!node) return []; + if (node.type === 'parens' && node.namedChildCount === 1) { + return constraintHeads(node.namedChild(0), source); + } + if (node.type === 'tuple') { + return node.namedChildren.flatMap((child) => constraintHeads(child, source)); + } + if (node.type === 'forall') { + const body = getChildByField(node, 'constraint') + ?? getChildByField(node, 'context') + ?? getChildByField(node, 'type'); + return constraintHeads(body, source); + } + if (node.type === 'context') { + const antecedent = getChildByField(node, 'context'); + const conclusion = getChildByField(node, 'type') ?? getChildByField(node, 'constraint'); + return [ + ...constraintHeads(antecedent, source), + ...constraintHeads(conclusion, source), + ]; + } + const head = referenceHead(node, source); + if (!head) return []; + const separator = head.name.lastIndexOf('::'); + const leaf = head.name.slice(separator < 0 ? 0 : separator + 2).replace(/^\(|\)$/g, ''); + // Lowercase, unqualified heads in ConstraintKinds/quantified constraints + // are bound type variables (`class c a => C c a`), not global classes. + if (separator < 0 && /^[a-z_]/.test(leaf)) return []; + // Equality/coercion constraints are predicates, not superclass declarations. + if (['~', '~~', '~#', '~R#', '~N#'].includes(leaf)) return []; + return [head]; +} + +function emitPointFreeReference(node: SyntaxNode, ownerId: string, ctx: ExtractorContext): void { + const match = getChildByField(node, 'match'); + const expression = match ? getChildByField(match, 'expression') : null; + const reference = expression ? normalizedSimpleReference(expression, ctx.source) : null; + if (!reference || isLexicallyBound(reference.name, reference.node, ctx.source, ctx.nodes as object)) return; + const separator = reference.name.lastIndexOf('::'); + const leaf = reference.name.slice(separator < 0 ? 0 : separator + 2).replace(/^\(|\)$/g, ''); + // A point-free constructor is a value dependency, not a function reference. + // The bare-constructor walker emits its single canonical `references` edge. + if (/^[A-Z]/.test(leaf) || leaf.startsWith(':')) return; + ctx.addUnresolvedReference({ + fromNodeId: ownerId, + referenceName: reference.name, + referenceKind: 'function_ref', + line: reference.node.startPosition.row + 1, + column: reference.node.startPosition.column, + }); +} + +function extractHaskellBareCall( + node: SyntaxNode, + source: string, + stateOwner?: object, +): string | undefined { + const reference = normalizedSimpleReference(node, source); + if (!reference) return undefined; + const parent = node.parent; + if (!parent) return undefined; + let sectionAncestor: SyntaxNode | null = parent; + while (sectionAncestor && ['parens', 'infix_id', 'qualified', 'prefix_id'].includes(sectionAncestor.type)) { + sectionAncestor = sectionAncestor.parent; + } + if ( + sectionAncestor + && (sectionAncestor.type === 'left_section' || sectionAncestor.type === 'right_section') + && nodeContains( + getChildByField(sectionAncestor, 'operator') + ?? sectionAncestor.namedChildren.find((child) => [ + 'infix_id', 'operator', 'constructor_operator', 'qualified', 'prefix_id', + ].includes(child.type)) + ?? null, + node, + ) + ) return undefined; + + let eligible = false; + // `do { initialise; Server.serve }`: a bare expression statement executes + // the action even though there is no `apply` node. + if (parent.type === 'exp' && parent.namedChildCount === 1) { + eligible = true; + } else if ( + parent.type === 'bind' + && nodeContains(getChildByField(parent, 'expression'), node) + && getChildByField(parent, 'expression')?.startIndex === node.startIndex + && getChildByField(parent, 'expression')?.endIndex === node.endIndex + ) { + // `value <- action` executes a bare monadic action just like a standalone + // expression statement. Applied actions are handled by the normal call + // walker; this branch covers the otherwise invisible nullary spelling. + eligible = true; + } else if ( + parent.type === 'view_pattern' + && nodeContains(getChildByField(parent, 'expression'), node) + && getChildByField(parent, 'expression')?.startIndex === node.startIndex + && getChildByField(parent, 'expression')?.endIndex === node.endIndex + ) { + eligible = true; + } else if (parent.type === 'apply') { + // Flatten the prefix-application spine so operator spellings have the same + // action semantics as their infix form: `(<*>) fun load`, `(>>) a b`, … + const applicationArguments: SyntaxNode[] = []; + let callee: SyntaxNode | null = parent; + while (callee?.type === 'apply') { + const argument = getChildByField(callee, 'argument'); + if (argument) applicationArguments.unshift(argument); + callee = getChildByField(callee, 'function'); + } + const functionRef = callee ? normalizedSimpleReference(callee, source) : null; + const separator = functionRef?.name.lastIndexOf('::') ?? -1; + const functionBaseRaw = functionRef + ? functionRef.name.slice(separator < 0 ? 0 : separator + 2) + : ''; + const functionBase = functionBaseRaw.replace(/^\((.*)\)$/, '$1'); + const argumentIndex = applicationArguments.findIndex((argument) => + argument.startIndex === node.startIndex && argument.endIndex === node.endIndex); + eligible = argumentIndex >= 0 && ( + (HOF_NAMES.has(functionBase) && argumentIndex === 0) + || (['>>', '*>', '<*', '<*>', '<**>', '<|>'].includes(functionBase) && argumentIndex <= 1) + || (['<$>', '<$', '=<<'].includes(functionBase) && argumentIndex === 1) + || (['<&>', '$>', '>>='].includes(functionBase) && argumentIndex === 0) + ); + } else if (parent.type === 'infix') { + const left = getChildByField(parent, 'left_operand'); + const right = getChildByField(parent, 'right_operand'); + const operator = getChildByField(parent, 'operator'); + const operatorRef = operator ? normalizedSimpleReference(operator, source) : null; + const separator = operatorRef?.name.lastIndexOf('::') ?? -1; + const operatorBase = operatorRef + ? operatorRef.name.slice(separator < 0 ? 0 : separator + 2) + : ''; + const isLeft = nodeContains(left, node) + && left?.startIndex === node.startIndex + && left?.endIndex === node.endIndex; + const isRight = nodeContains(right, node) + && right?.startIndex === node.startIndex + && right?.endIndex === node.endIndex; + // These operators execute statically named action operands. Continuations + // (`>>=` RHS / `=<<` LHS) are handled as higher-order function refs by the + // normal infix walker, while lexical parameters remain suppressed below. + eligible = ['>>', '*>', '<*', '<*>', '<**>', '<|>'].includes(operatorBase) && (isLeft || isRight) + || (['<$>', '<$', '=<<'].includes(operatorBase) && isRight) + || (['<&>', '$>', '>>='].includes(operatorBase) && isLeft); + } + if (!eligible || isLexicallyBound(reference.name, reference.node, source, stateOwner)) return undefined; + return reference.name; +} + +function extractHaskellBareReference( + node: SyntaxNode, + source: string, + stateOwner?: object, +): { name: string; referenceKind: 'references' | 'function_ref'; node?: SyntaxNode } + | Array<{ name: string; referenceKind: 'references' | 'function_ref'; node?: SyntaxNode }> + | undefined { + if (node.type === 'left_section' || node.type === 'right_section') { + const operator = getChildByField(node, 'operator') + ?? node.namedChildren.find((child) => [ + 'infix_id', 'operator', 'constructor_operator', 'qualified', + ].includes(child.type)); + const reference = operator ? referenceHead(operator, source) : null; + if (!reference || isLexicallyBound(reference.name, reference.node, source, stateOwner)) return undefined; + return { name: reference.name, referenceKind: 'function_ref' }; + } + + if (node.type === 'projection' || node.type === 'projection_selector') { + const fields = node.childrenForFieldName('field'); + const variables = fields.flatMap((field) => { + if (field.type === 'variable') return [field]; + const variable = firstDescendant(field, new Set(['variable'])); + return variable ? [variable] : []; + }); + return variables + .map((variable) => ({ + name: getNodeText(variable, source).trim(), + referenceKind: 'references' as const, + node: variable, + })) + .filter((reference) => reference.name.length > 0); + } + + if (node.type === 'field_update') { + const fieldRoot = getChildByField(node, 'field'); + if (!fieldRoot) return undefined; + const fields: SyntaxNode[] = []; + const stack = [fieldRoot]; + while (stack.length > 0) { + const current = stack.pop()!; + if (current.type === 'field_name') { + fields.push(current); + continue; + } + stack.push(...current.namedChildren); + } + fields.sort((left, right) => left.startIndex - right.startIndex); + return fields.map((field) => ({ + name: getNodeText(field, source).trim(), + referenceKind: 'references' as const, + node: field, + })).filter((reference) => reference.name.length > 0); + } + + const inPattern = isHaskellPatternPosition(node); + if (!['constructor', 'constructor_operator', 'qualified', 'prefix_id'].includes(node.type)) { + return undefined; + } + const parent = node.parent; + if (!parent) return undefined; + let sectionAncestor: SyntaxNode | null = parent; + while (sectionAncestor && ['parens', 'infix_id', 'qualified', 'prefix_id'].includes(sectionAncestor.type)) { + sectionAncestor = sectionAncestor.parent; + } + if ( + sectionAncestor + && (sectionAncestor.type === 'left_section' || sectionAncestor.type === 'right_section') + && nodeContains( + getChildByField(sectionAncestor, 'operator') + ?? sectionAncestor.namedChildren.find((child) => [ + 'infix_id', 'operator', 'constructor_operator', 'qualified', 'prefix_id', + ].includes(child.type)) + ?? null, + node, + ) + ) return undefined; + // Application/infix pattern nodes already emit their constructor through the + // normal call-shaped pattern handler. Descendants of a qualified/prefix name + // are likewise represented by their parent as one canonical reference. + if ( + (parent.type === 'apply' && nodeContains(getChildByField(parent, 'function'), node)) + || (!inPattern && parent.type === 'infix' + && nodeContains(getChildByField(parent, 'operator'), node)) + || ((parent.type === 'left_section' || parent.type === 'right_section') + && nodeContains(getChildByField(parent, 'operator'), node)) + || parent.type === 'qualified' + || parent.type === 'prefix_id' + ) return undefined; + + if (!inPattern && parent.type === 'apply' + && nodeContains(getChildByField(parent, 'argument'), node)) { + const directFunction = getChildByField(parent, 'function'); + const directReference = directFunction + ? normalizedSimpleReference(directFunction, source) + : null; + const separator = directReference?.name.lastIndexOf('::') ?? -1; + const directBase = directReference + ? directReference.name.slice(separator < 0 ? 0 : separator + 2) + : ''; + // A known higher-order combinator executes this constructor value, and + // the call walker emits the semantic call edge. Avoid a second value edge. + if (HOF_NAMES.has(directBase)) return undefined; + } + const reference = normalizedSimpleReference(node, source); + if (!reference) return undefined; + + if (!inPattern) { + const separator = reference.name.lastIndexOf('::'); + const leaf = reference.name.slice(separator < 0 ? 0 : separator + 2).replace(/^\(|\)$/g, ''); + if (!/^[A-Z]/.test(leaf) && !leaf.startsWith(':')) return undefined; + + // Bare constructor values are semantic references only in expressions, + // never in signatures, type heads, imports, or export lists. + let branch = node; + let ancestor: SyntaxNode | null = node.parent; + let expressionPosition = false; + while (ancestor) { + if (ancestor.type === 'view_pattern') { + expressionPosition = nodeContains(getChildByField(ancestor, 'expression'), branch); + break; + } + if (ancestor.type === 'guards') { + expressionPosition = true; + break; + } + if (ancestor.type === 'match' || ancestor.type === 'bind') { + expressionPosition = nodeContains(getChildByField(ancestor, 'expression'), branch) + || nodeContains(getChildByField(ancestor, 'guards'), branch); + break; + } + if (ancestor.type === 'constructor_synonym') { + expressionPosition = nodeContains(getChildByField(ancestor, 'match'), branch); + break; + } + if (['signature', 'header', 'import', 'data_type', 'newtype', 'type_synomym', + 'type_family', 'type_instance', 'data_family', 'class', 'instance'].includes(ancestor.type)) { + break; + } + branch = ancestor; + ancestor = ancestor.parent; + } + if (!expressionPosition) return undefined; + } + return { name: reference.name, referenceKind: 'references' }; +} + +function derivedClassNames(node: SyntaxNode, source: string): Array<{ name: string; node: SyntaxNode }> { + const deriving = getChildByField(node, 'deriving') + ?? node.namedChildren.find((child) => child.type === 'deriving'); + if (!deriving) return []; + const classes = getChildByField(deriving, 'classes') ?? deriving; + const result: Array<{ name: string; node: SyntaxNode }> = []; + const stack = [classes]; + while (stack.length > 0) { + const current = stack.pop()!; + if (current.type === 'qualified' || current.type === 'name' || current.type === 'prefix_id') { + const reference = normalizedSimpleReference(current, source); + if (reference) result.push(reference); + } else { + stack.push(...current.namedChildren); + } + } + return result; +} + +function handleBind(node: SyntaxNode, ctx: ExtractorContext): boolean { + // A `bind` under `do` is a monadic pattern bind (`x <- action`), not a named + // declaration. Local value binds stay attributed to their enclosing symbol; + // only function-valued local binds become their own graph nodes. + const scopeId = ctx.nodeStack[ctx.nodeStack.length - 1] ?? ''; + const owner = scopeOwner(ctx, scopeId); + const isMethod = !!owner && (owner.kind === 'trait' || owner.decorators?.includes('haskell-instance')); + const isTopLevel = !owner || owner.kind === 'file' || owner.kind === 'namespace'; + const nameNode = getChildByField(node, 'name'); + if (!nameNode) { + // Top-level pattern bindings introduce one exported value per bound + // variable. Local/monadic pattern binds remain lexical and are attributed + // to their enclosing symbol by the ordinary walker. + if (!isTopLevel) return false; + const pattern = getChildByField(node, 'pattern'); + const names = [...new Set(collectPatternNames(pattern, ctx.source))]; + if (names.length === 0) return false; + const lhs = pattern + ? collapseWhitespace(getNodeText(pattern, ctx.source)).slice(0, 240) + : ''; + for (const name of names) { + const signatureNode = precedingSignature(node, name, ctx.source, ctx.nodes as object); + const kind = signatureNode && signatureDeclaresFunction(signatureNode) ? 'function' : 'constant'; + const bindingNode = ctx.createNode(kind, name, node, { + signature: signatureNode + ? collapseWhitespace(getNodeText(signatureNode, ctx.source)).slice(0, 400) + : lhs || undefined, + docstring: getHaskellPrecedingDocstring(signatureNode ?? node, ctx.source), + isExported: isNameExported(node, ctx.source, name, ctx.nodes as object), + }); + if (!bindingNode) continue; + emitPointFreeReference(node, bindingNode.id, ctx); + visitFunctionPayload(node, bindingNode.id, ctx); + } + return true; + } + const name = getNodeText(nameNode, ctx.source).trim(); + if (!name) return false; + + const signatureNode = precedingSignature(node, name, ctx.source, ctx.nodes as object); + const functionBinding = isMethod || isFunctionBinding(node, signatureNode, ctx.source); + if (!isTopLevel && !functionBinding) return false; + + const kind = isMethod ? 'method' : functionBinding ? 'function' : 'constant'; + const groupKey = declarationGroupKey(node, name, kind, ctx); + const existing = groupedNode(groupKey, ctx); + if (existing) { + extendGroupedNode(node, existing); + emitPointFreeReference(node, existing.id, ctx); + visitFunctionPayload(node, existing.id, ctx); + return true; + } + const signature = signatureNode + ? collapseWhitespace(getNodeText(signatureNode, ctx.source)).slice(0, 400) + : collapseWhitespace(getNodeText(node, ctx.source).split('=', 1)[0] ?? '').slice(0, 240); + const bindingNode = ctx.createNode(kind, name, node, { + signature: signature || undefined, + docstring: getHaskellPrecedingDocstring(signatureNode ?? node, ctx.source), + isExported: isTopLevel + ? isNameExported(node, ctx.source, name, ctx.nodes as object) + : owner?.kind === 'trait' && isNameExported(node, ctx.source, name, ctx.nodes as object, owner.name), + decorators: !isTopLevel && kind === 'function' + ? [lexicalRangeDecorator(node)].filter((value): value is string => value !== null) + : undefined, + }); + if (!bindingNode) return true; + + declarationGroupMap(ctx).set(groupKey, bindingNode); + emitPointFreeReference(node, bindingNode.id, ctx); + visitFunctionPayload(node, bindingNode.id, ctx); + return true; +} + +function handleFunction(node: SyntaxNode, ctx: ExtractorContext): boolean { + const nameNode = getChildByField(node, 'name'); + let name = nameNode ? getNodeText(nameNode, ctx.source).trim() : ''; + if (!name) { + const infix = node.namedChildren.find((child) => child.type === 'infix'); + const operator = infix ? getChildByField(infix, 'operator') : null; + const operatorName = operator ? getNodeText(operator, ctx.source).trim() : ''; + if (operatorName) name = `(${operatorName})`; + } + if (!name) return true; + + const scopeId = ctx.nodeStack[ctx.nodeStack.length - 1] ?? ''; + const parent = scopeOwner(ctx, scopeId); + const kind = parent && (parent.kind === 'trait' || parent.decorators?.includes('haskell-instance')) + ? 'method' + : 'function'; + const isTopLevel = !parent || parent.kind === 'file' || parent.kind === 'namespace'; + const groupKey = declarationGroupKey(node, name, kind, ctx); + const existing = groupedNode(groupKey, ctx); + if (existing) { + extendGroupedNode(node, existing); + visitFunctionPayload(node, existing.id, ctx); + return true; + } + + const signatureNode = precedingSignature(node, name, ctx.source, ctx.nodes as object); + const signature = signatureNode + ? collapseWhitespace(getNodeText(signatureNode, ctx.source)).slice(0, 400) + : collapseWhitespace(getNodeText(node, ctx.source).split('=', 1)[0] ?? '').slice(0, 240); + const functionNode = ctx.createNode(kind, name, node, { + signature: signature || undefined, + docstring: getHaskellPrecedingDocstring(signatureNode ?? node, ctx.source), + // Instance implementations and let/where helpers are lexical; class + // methods are importable only when their parent class exports them. + isExported: isTopLevel + ? isNameExported(node, ctx.source, name, ctx.nodes as object) + : parent?.kind === 'trait' && isNameExported(node, ctx.source, name, ctx.nodes as object, parent.name), + decorators: !isTopLevel && kind === 'function' + ? [lexicalRangeDecorator(node)].filter((value): value is string => value !== null) + : undefined, + }); + if (!functionNode) return true; + + declarationGroupMap(ctx).set(groupKey, functionNode); + visitFunctionPayload(node, functionNode.id, ctx); + return true; +} + +const CONSTRUCTOR_DECLARATIONS = new Set([ + 'data_constructor', + 'newtype_constructor', + 'gadt_constructor', + 'constructor_synonym', +]); + +function handleDataDeclaration( + node: SyntaxNode, + ctx: ExtractorContext, + options?: { name?: string; isExported?: boolean; exportParentName?: string }, +): boolean { + const head = declarationHead(node, ctx.source); + const name = options?.name ?? head?.baseName ?? ''; + if (!name) return true; + + const typeNode = ctx.createNode(node.type === 'newtype' ? 'struct' : 'enum', name, node, { + signature: collapseWhitespace(getNodeText(node, ctx.source)).slice(0, 400), + docstring: getHaskellPrecedingDocstring(node, ctx.source), + isExported: options?.isExported + ?? isNameExported(node, ctx.source, name, ctx.nodes as object), + }); + if (!typeNode) return true; + + ctx.pushScope(typeNode.id); + const walk = (current: SyntaxNode): void => { + if (CONSTRUCTOR_DECLARATIONS.has(current.type)) { + const constructorShape = getChildByField(current, 'constructor'); + const infix = constructorShape?.type === 'infix' + ? constructorShape + : current.namedChildren.find((child) => child.type === 'infix'); + const names = getChildByField(current, 'names'); + const constructorNames = names?.type === 'binding_list' + ? names.namedChildren + : [getChildByField(current, 'name') + ?? (infix ? getChildByField(infix, 'operator') : null) + ?? firstDescendant(current, new Set(['constructor']))] + .filter((candidate): candidate is SyntaxNode => candidate !== null); + for (const constructorName of constructorNames) { + const rawConstructor = getNodeText(constructorName, ctx.source).trim(); + const constructor = constructorName.type === 'constructor_operator' + || constructorName.type === 'prefix_id' + || rawConstructor.startsWith(':') + ? parenthesizedOperator(rawConstructor) + : rawConstructor; + ctx.createNode('enum_member', constructor, current, { + signature: collapseWhitespace(getNodeText(current, ctx.source)).slice(0, 260), + isExported: isNameExported( + current, + ctx.source, + constructor, + ctx.nodes as object, + options?.exportParentName ?? name, + ), + decorators: options?.exportParentName + ? [`haskell-export-parent:${options.exportParentName}`] + : undefined, + }); + } + // Record fields can live below a constructor, so continue walking. + } else if (current.type === 'field') { + // Positional newtype payloads are also parsed as `field`, but only record + // declarations have one or more explicit `name:` children. Grouped + // selectors (`{ x, y :: Int }`) repeat that field and must all be emitted. + for (const fieldName of current.childrenForFieldName('name')) { + const field = getNodeText(fieldName, ctx.source).trim(); + ctx.createNode('field', field, current, { + isExported: isNameExported( + current, + ctx.source, + field, + ctx.nodes as object, + options?.exportParentName ?? name, + ), + decorators: options?.exportParentName + ? [`haskell-export-parent:${options.exportParentName}`] + : undefined, + }); + } + } + for (let i = 0; i < current.namedChildCount; i++) { + const child = current.namedChild(i); + if (child) walk(child); + } + }; + walk(node); + ctx.popScope(); + + for (const derived of derivedClassNames(node, ctx.source)) { + ctx.addUnresolvedReference({ + fromNodeId: typeNode.id, + referenceName: derived.name, + referenceKind: 'implements', + line: derived.node.startPosition.row + 1, + column: derived.node.startPosition.column, + }); + } + return true; +} + +function handleDataFamily(node: SyntaxNode, ctx: ExtractorContext): boolean { + const head = declarationHead(node, ctx.source); + if (!head) return true; + const name = head.baseName; + const ownerId = ctx.nodeStack[ctx.nodeStack.length - 1] ?? ''; + const owner = scopeOwner(ctx, ownerId); + ctx.createNode('enum', name, node, { + signature: collapseWhitespace(getNodeText(node, ctx.source)).slice(0, 400), + docstring: getHaskellPrecedingDocstring(node, ctx.source), + isExported: owner?.kind === 'trait' + ? isNameExported(node, ctx.source, name, ctx.nodes as object, owner.name) + : isNameExported(node, ctx.source, name, ctx.nodes as object), + decorators: ['haskell-data-family'], + }); + return true; +} + +function handleDataInstance(node: SyntaxNode, ctx: ExtractorContext): boolean { + const declaration = node.namedChildren.find((child) => child.type === 'data_type' || child.type === 'newtype'); + if (!declaration) return true; + const head = declarationHead(declaration, ctx.source); + if (!head) return true; + const baseName = head.baseName; + const ownerId = ctx.nodeStack[ctx.nodeStack.length - 1]; + const owner = scopeOwner(ctx, ownerId); + const instanceName = owner && (owner.kind === 'trait' || owner.decorators?.includes('haskell-instance')) + ? baseName + : head.displayName; + return handleDataDeclaration(declaration, ctx, { + name: instanceName, + // A family application (`F Int`) is not an independently importable type + // declaration. Its constructors retain their own module export semantics. + isExported: false, + exportParentName: baseName, + }); +} + +function handlePatternSynonym(node: SyntaxNode, ctx: ExtractorContext): boolean { + const equation = node.namedChildren.find((child) => child.type === 'equation'); + const nameNode = patternSynonymNameNode(node); + if (!nameNode) return true; + // A standalone pattern signature is a sibling declaration. It is indexed + // here and materialized together with the following equation. + if (!equation) return true; + const name = patternSynonymName(node, ctx.source); + const signatureNode = precedingPatternSignature(node, name, ctx.source, ctx.nodes as object); + const exportParents = bundledExportParents(node, ctx.source, name, ctx.nodes as object); + const patternNode = ctx.createNode('enum_member', name, node, { + signature: collapseWhitespace(getNodeText(signatureNode ?? node, ctx.source)).slice(0, 400), + docstring: getHaskellPrecedingDocstring(signatureNode ?? node, ctx.source), + isExported: isNameExported(node, ctx.source, name, ctx.nodes as object) + || exportParents.length > 0, + decorators: [ + 'haskell-pattern-synonym', + ...exportParents.map((parent) => `haskell-export-parent:${parent}`), + ], + }); + if (patternNode && signatureNode) extendGroupedNode(signatureNode, patternNode); + + // Record pattern synonyms introduce ordinary module-scope selector + // functions, even though the fields are syntactically nested in the synonym. + const synonym = getChildByField(equation, 'synonym'); + const selectorStack = synonym ? [synonym] : []; + const seenSelectors = new Set(); + while (selectorStack.length > 0) { + const current = selectorStack.pop()!; + if (current.type === 'field_pattern') { + const field = getChildByField(current, 'field'); + const variable = field ? firstDescendant(field, new Set(['variable'])) : null; + const selector = variable ? getNodeText(variable, ctx.source).trim() : ''; + if (selector && !seenSelectors.has(selector)) { + seenSelectors.add(selector); + ctx.createNode('field', selector, current, { + signature: signatureNode + ? collapseWhitespace(getNodeText(signatureNode, ctx.source)).slice(0, 400) + : `pattern selector ${selector} for ${name}`, + isExported: isNameExported(node, ctx.source, selector, ctx.nodes as object), + decorators: ['haskell-pattern-selector', `haskell-pattern-owner:${name}`], + }); + } + continue; + } + selectorStack.push(...current.namedChildren); + } + if (patternNode) { + // Pattern synonyms contain pattern-position constructors plus ordinary + // matcher/builder expressions. Reuse the normal semantic walker so view + // helpers and builder functions are calls while synonym arguments remain + // lexical and constructors keep their expression/pattern edge kinds. + ctx.pushScope(patternNode.id); + const matcher = getChildByField(equation, 'pattern'); + if (matcher) ctx.visitFunctionBody(matcher, patternNode.id); + const builderStack = [...node.namedChildren]; + while (builderStack.length > 0) { + const current = builderStack.pop()!; + if (current.type === 'constructor_synonym') { + const match = getChildByField(current, 'match'); + const expression = match ? getChildByField(match, 'expression') : null; + if (expression) ctx.visitFunctionBody(expression, patternNode.id); + continue; + } + builderStack.push(...current.namedChildren); + } + ctx.popScope(); + } + return true; +} + +function foreignSignature(node: SyntaxNode): SyntaxNode | null { + return node.namedChildren.find((child) => child.type === 'signature') + ?? firstDescendant(node, new Set(['signature'])); +} + +/** Model a foreign import as the Haskell binding it introduces. */ +function handleForeignImport(node: SyntaxNode, ctx: ExtractorContext): boolean { + const signatureNode = foreignSignature(node); + if (!signatureNode) return true; + const names = signatureNames(signatureNode, ctx.source); + for (const name of names) { + const kind = signatureDeclaresFunction(signatureNode) ? 'function' : 'constant'; + const foreignNode = ctx.createNode(kind, name, node, { + signature: collapseWhitespace(getNodeText(node, ctx.source)).slice(0, 400), + docstring: getHaskellPrecedingDocstring(node, ctx.source), + isExported: isNameExported(node, ctx.source, name, ctx.nodes as object), + decorators: ['haskell-foreign-import'], + }); + if (foreignNode) { + declarationGroupMap(ctx).set(declarationGroupKey(node, name, kind, ctx), foreignNode); + } + } + return true; +} + +/** A foreign export reuses an existing Haskell binding; retain that dependency. */ +function handleForeignExport(node: SyntaxNode, ctx: ExtractorContext): boolean { + const signatureNode = foreignSignature(node); + if (!signatureNode) return true; + const ownerId = ctx.nodeStack[ctx.nodeStack.length - 1]; + if (!ownerId) return true; + for (const nameNode of signatureNames(signatureNode, ctx.source)) { + ctx.addUnresolvedReference({ + fromNodeId: ownerId, + referenceName: normalizeReferenceText(nameNode), + referenceKind: 'function_ref', + line: signatureNode.startPosition.row + 1, + column: signatureNode.startPosition.column, + }); + } + return true; +} + +function handleTypeAlias(node: SyntaxNode, ctx: ExtractorContext): boolean { + const head = declarationHead(node, ctx.source); + if (head) { + const name = head.baseName; + ctx.createNode('type_alias', name, node, { + signature: collapseWhitespace(getNodeText(node, ctx.source)).slice(0, 400), + docstring: getHaskellPrecedingDocstring(node, ctx.source), + isExported: isNameExported(node, ctx.source, name, ctx.nodes as object), + }); + } + return true; +} + +function handleTypeFamily(node: SyntaxNode, ctx: ExtractorContext): boolean { + const head = declarationHead(node, ctx.source); + if (!head) return true; + const baseName = head.baseName; + const name = node.type === 'type_instance' ? head.displayName : baseName; + const ownerId = ctx.nodeStack[ctx.nodeStack.length - 1] ?? ''; + const owner = scopeOwner(ctx, ownerId); + ctx.createNode('type_alias', name, node, { + signature: collapseWhitespace(getNodeText(node, ctx.source)).slice(0, 400), + docstring: getHaskellPrecedingDocstring(node, ctx.source), + isExported: node.type !== 'type_instance' && ( + owner?.kind === 'trait' + ? isNameExported(node, ctx.source, baseName, ctx.nodes as object, owner.name) + : isNameExported(node, ctx.source, baseName, ctx.nodes as object) + ), + }); + return true; +} + +function visitDeclarationBody(node: SyntaxNode, bodyType: string, ownerId: string, ctx: ExtractorContext): void { + const body = getChildByField(node, 'declarations') + ?? node.namedChildren.find((child) => child.type === bodyType) + ?? null; + if (!body) return; + ctx.pushScope(ownerId); + ctx.visitNode(body); + ctx.popScope(); +} + +function handleTypeclass(node: SyntaxNode, ctx: ExtractorContext): boolean { + const head = declarationHead(node, ctx.source); + if (!head) return true; + const name = head.baseName; + const typeclass = ctx.createNode('trait', name, node, { + signature: collapseWhitespace(getNodeText(node, ctx.source)).slice(0, 400), + docstring: getHaskellPrecedingDocstring(node, ctx.source), + isExported: isNameExported(node, ctx.source, name, ctx.nodes as object), + }); + if (typeclass) { + const context = getChildByField(node, 'context'); + const inner = context ? getChildByField(context, 'context') : null; + const constraints = inner?.type === 'tuple' ? inner.namedChildren : inner ? [inner] : []; + const seenSuperclasses = new Set(); + for (const superclass of constraints.flatMap((constraint) => constraintHeads(constraint, ctx.source))) { + if (seenSuperclasses.has(superclass.name)) continue; + seenSuperclasses.add(superclass.name); + ctx.addUnresolvedReference({ + fromNodeId: typeclass.id, + referenceName: superclass.name, + referenceKind: 'extends', + line: superclass.node.startPosition.row + 1, + column: superclass.node.startPosition.column, + }); + } + visitDeclarationBody(node, 'class_declarations', typeclass.id, ctx); + } + return true; +} + +function handleInstance(node: SyntaxNode, ctx: ExtractorContext): boolean { + const head = declarationHead(node, ctx.source); + if (!head) return true; + const className = head.baseName; + const classReference = head.isInfix + ? className.slice(1, -1) + : normalizeReferenceText(getNodeText(head.nameNode, ctx.source)); + const instanceName = head.displayName; + const instanceNode = ctx.createNode('class', instanceName, node, { + signature: collapseWhitespace(getNodeText(node, ctx.source)).slice(0, 400), + decorators: ['haskell-instance'], + }); + if (!instanceNode) return true; + ctx.addUnresolvedReference({ + fromNodeId: instanceNode.id, + referenceName: classReference, + referenceKind: 'implements', + line: head.nameNode.startPosition.row + 1, + column: head.nameNode.startPosition.column, + }); + visitDeclarationBody(node, 'instance_declarations', instanceNode.id, ctx); + return true; +} + +function handleDerivingInstance(node: SyntaxNode, ctx: ExtractorContext): boolean { + const head = declarationHead(node, ctx.source); + if (!head) return true; + const className = head.baseName; + const classReference = head.isInfix + ? className.slice(1, -1) + : normalizeReferenceText(getNodeText(head.nameNode, ctx.source)); + const instanceName = head.displayName; + const instanceNode = ctx.createNode('class', instanceName, node, { + signature: collapseWhitespace(getNodeText(node, ctx.source)).slice(0, 400), + decorators: ['haskell-instance', 'haskell-deriving-instance'], + }); + if (instanceNode) { + ctx.addUnresolvedReference({ + fromNodeId: instanceNode.id, + referenceName: classReference, + referenceKind: 'implements', + line: head.nameNode.startPosition.row + 1, + column: head.nameNode.startPosition.column, + }); + } + return true; +} + +function handleClassSignature(node: SyntaxNode, ctx: ExtractorContext): boolean { + const ownerId = ctx.nodeStack[ctx.nodeStack.length - 1]; + if (!ownerId) return false; + const owner = scopeOwner(ctx, ownerId); + if (!owner || (owner.kind !== 'trait' && !owner.decorators?.includes('haskell-instance'))) return false; + + const namesNode = getChildByField(node, 'names'); + const nameNodes = namesNode + ? namesNode.namedChildren.filter((child) => child.type === 'variable' || child.type === 'prefix_id') + : [getChildByField(node, 'name')].filter((child): child is SyntaxNode => child !== null); + for (const nameNode of nameNodes) { + const name = getNodeText(nameNode, ctx.source).trim(); + if (!name) continue; + const groupKey = declarationGroupKey(node, name, 'method', ctx); + const existing = groupedNode(groupKey, ctx); + if (existing) { + extendGroupedNode(node, existing); + continue; + } + const method = ctx.createNode('method', name, node, { + signature: collapseWhitespace(getNodeText(node, ctx.source)).slice(0, 400), + isExported: owner.kind === 'trait' + && isNameExported(node, ctx.source, name, ctx.nodes as object, owner.name), + }); + if (method) declarationGroupMap(ctx).set(groupKey, method); + } + return true; +} + +export const haskellExtractor: LanguageExtractor = { + // Haskell's significant declarations need clause grouping and type-position + // filtering, so they are dispatched through visitNode rather than the generic + // one-node-per-declaration paths. + functionTypes: [], + classTypes: [], + methodTypes: [], + interfaceTypes: [], + structTypes: [], + enumTypes: [], + typeAliasTypes: [], + importTypes: ['import'], + callTypes: ['apply', 'infix'], + variableTypes: [], + nameField: 'name', + bodyField: 'declarations', + paramsField: 'patterns', + extractBareCall: extractHaskellBareCall, + extractBareReference: extractHaskellBareReference, + isLexicallyBound, + isPatternPosition: isHaskellPatternPosition, + higherOrderFunctionNames: HOF_NAMES, + + packageTypes: ['header'], + extractPackage: (node, source) => { + const moduleNode = getChildByField(node, 'module'); + return moduleNode ? getNodeText(moduleNode, source).replace(/\s+/g, '') : null; + }, + + extractImport: (node, source) => { + const moduleNode = getChildByField(node, 'module'); + if (!moduleNode) return null; + return { + moduleName: getNodeText(moduleNode, source).replace(/\s+/g, ''), + signature: collapseWhitespace(getNodeText(node, source)).slice(0, 300), + }; + }, + + visitNode: (node, ctx) => { + switch (node.type) { + case 'function': + return handleFunction(node, ctx); + case 'bind': + return handleBind(node, ctx); + case 'data_type': + case 'newtype': + return handleDataDeclaration(node, ctx); + case 'data_family': + return handleDataFamily(node, ctx); + case 'data_instance': + return handleDataInstance(node, ctx); + case 'pattern_synonym': + return handlePatternSynonym(node, ctx); + case 'type_synomym': + return handleTypeAlias(node, ctx); + case 'type_family': + case 'type_instance': + return handleTypeFamily(node, ctx); + case 'class': + return handleTypeclass(node, ctx); + case 'instance': + return handleInstance(node, ctx); + case 'deriving_instance': + return handleDerivingInstance(node, ctx); + case 'foreign_import': + return handleForeignImport(node, ctx); + case 'foreign_export': + return handleForeignExport(node, ctx); + case 'signature': + return handleClassSignature(node, ctx); + default: + return false; + } + }, +}; diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts index 6b760b01d..63f12f6fa 100644 --- a/src/extraction/languages/index.ts +++ b/src/extraction/languages/index.ts @@ -36,6 +36,7 @@ import { solidityExtractor } from './solidity'; import { terraformExtractor } from './terraform'; import { arktsExtractor } from './arkts'; import { nixExtractor } from './nix'; +import { haskellExtractor } from './haskell'; export const EXTRACTORS: Partial> = { typescript: typescriptExtractor, @@ -69,4 +70,5 @@ export const EXTRACTORS: Partial> = { terraform: terraformExtractor, arkts: arktsExtractor, nix: nixExtractor, + haskell: haskellExtractor, }; diff --git a/src/extraction/tree-sitter-helpers.ts b/src/extraction/tree-sitter-helpers.ts index a6438e1b4..8a514862a 100644 --- a/src/extraction/tree-sitter-helpers.ts +++ b/src/extraction/tree-sitter-helpers.ts @@ -78,11 +78,12 @@ function cleanCommentMarkers(comment: string): string { let c = comment.trim(); if (c.startsWith('/*')) c = c.replace(/^\/\*+!?/, '').replace(/\*+\/$/, ''); else if (c.startsWith('--[')) c = c.replace(/^--\[=*\[/, '').replace(/\]=*\]$/, ''); + else if (c.startsWith('{-')) c = c.replace(/^\{-[|^]?/, '').replace(/-\}$/, ''); else if (c.startsWith('(*')) c = c.replace(/^\(\*/, '').replace(/\*\)$/, ''); else if (c.startsWith('{')) c = c.replace(/^\{/, '').replace(/\}$/, ''); return c .replace(/^\/\/[/!]?\s?/gm, '') // // , and Rust/Swift doc lines /// //! - .replace(/^--\s?/gm, '') // Lua/Luau line comments + .replace(/^--[|^]?\s?/gm, '') // Lua/Luau lines and Haskell Haddock .replace(/^#\s?/gm, '') // Python/Ruby/shell line comments .replace(/^%+\s?/gm, '') // Erlang line comments (% / %% / %%%) .replace(/^\s*\*\s?/gm, '') // block-comment continuation (* foo) @@ -111,7 +112,8 @@ export function getPrecedingDocstring(node: SyntaxNode, source: string): string sibling.type === 'comment' || sibling.type === 'line_comment' || sibling.type === 'block_comment' || - sibling.type === 'documentation_comment' + sibling.type === 'documentation_comment' || + sibling.type === 'haddock' ) { comments.unshift(getNodeText(sibling, source)); sibling = sibling.previousNamedSibling; diff --git a/src/extraction/tree-sitter-types.ts b/src/extraction/tree-sitter-types.ts index 2a02b47b7..32c67b291 100644 --- a/src/extraction/tree-sitter-types.ts +++ b/src/extraction/tree-sitter-types.ts @@ -10,6 +10,7 @@ import { Node as SyntaxNode } from 'web-tree-sitter'; import { Node, NodeKind, + ReferenceKind, UnresolvedReference, } from '../types'; @@ -173,6 +174,15 @@ export interface LanguageExtractor { /** Additional node types to treat as class declarations (e.g. Dart: 'mixin_declaration') */ extraClassNodeTypes?: string[]; + /** + * Additional field names to walk for call/structural extraction after the + * primary `bodyField`. Used by Haskell where a function's `where`-clause + * bindings live in a sibling `binds:` field (not inside the `match:` body), + * so calls inside `where` would otherwise be silently dropped. Each named + * field is fed through `visitFunctionBody` with the same enclosing-function + * scope as the primary body. + */ + extraBodyFields?: string[]; /** Whether methods can be top-level without enclosing class (Go: true) */ methodsAreTopLevel?: boolean; /** @@ -281,7 +291,33 @@ export interface LanguageExtractor { * tree-sitter parses it as a plain `identifier` node instead of `call`/`method_call`. * Returns the callee name if this node is a bare call, or undefined if not. */ - extractBareCall?: (node: SyntaxNode, source: string) => string | undefined; + extractBareCall?: (node: SyntaxNode, source: string, stateOwner?: object) => string | undefined; + + /** + * Extract a statically named reference that is not represented by one of the + * language's normal call nodes. Haskell uses this for nullary constructors in + * patterns (`Nothing`) and operator sections (`(+ 1)`). + */ + extractBareReference?: ( + node: SyntaxNode, + source: string, + stateOwner?: object, + ) => { name: string; referenceKind: ReferenceKind; node?: SyntaxNode } + | Array<{ name: string; referenceKind: ReferenceKind; node?: SyntaxNode }> + | undefined; + + /** + * Return true when a name at this syntax node is introduced by an enclosing + * lexical pattern rather than referring to a global/imported callable. + * Haskell uses this for function/lambda, case, let, and monadic `do` binds. + */ + isLexicallyBound?: (name: string, node: SyntaxNode, source: string, stateOwner?: object) => boolean; + + /** Return true when an application-shaped AST node is a pattern, not an expression call. */ + isPatternPosition?: (node: SyntaxNode) => boolean; + + /** Function-first combinators whose first argument is semantically called. */ + higherOrderFunctionNames?: ReadonlySet; /** * Node types representing a file-level package/namespace declaration diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 05b13a9c1..9861c6c23 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -61,6 +61,9 @@ const VUE_STORE_FACTORY_CALLEES = new Set(['defineStore', 'createStore']); * `const actions = {…}` as a store collection — see looksLikeVueStoreFile). */ const VUE_STORE_FILE_SIGNAL = /\bdefineStore\b|\bcreateStore\b|\bVuex\b|\bmutations\b|\bactions\b|\bgetters\b|\bnamespaced\b/g; +/** Haskell operators whose operands are themselves functions. */ +const HASKELL_FUNCTION_COMPOSITION_OPERATORS = new Set(['.', '>=>', '<=<', '>>>', '<<<']); + /** * Erlang calls that take their real callee as (Module, Function, Args) * ARGUMENTS — the spawn/apply family. Keys are the callee as the call site @@ -393,6 +396,12 @@ export class TreeSitterExtractor { private source: string; private tree: Tree | null = null; private nodes: Node[] = []; + // Keep the long-standing line-based IDs stable for the overwhelmingly common + // case, while disambiguating declarations such as two same-named `let` + // helpers written on one physical line. Re-visiting the exact same syntax + // position retains the legacy ID; only a distinct-column collision gets the + // deterministic column-qualified fallback. + private nodeIdColumns = new Map(); private edges: Edge[] = []; private unresolvedReferences: UnresolvedReference[] = []; // Value-reference edges (default ON; set CODEGRAPH_VALUE_REFS=0 to disable; see flushValueRefs). @@ -567,10 +576,22 @@ export class TreeSitterExtractor { this.source = ''; } + let unresolvedReferences = this.unresolvedReferences; + if (this.language === 'haskell') { + const seen = new Set(); + unresolvedReferences = this.unresolvedReferences.filter((reference) => { + const key = `${reference.fromNodeId}\0${reference.referenceName}\0${reference.referenceKind}` + + `\0${reference.line}\0${reference.column}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + } + return { nodes: this.nodes, edges: this.edges, - unresolvedReferences: this.unresolvedReferences, + unresolvedReferences, errors: this.errors, durationMs: Date.now() - startTime, }; @@ -1248,6 +1269,16 @@ export class TreeSitterExtractor { else if (this.extractor.callTypes.includes(nodeType)) { this.extractCall(node); } + // Custom visitors may route a handled function body back through the full + // visitor so nested declarations are still extracted (Haskell `where` / + // `let` blocks). Honour bare semantic calls on that path too; the regular + // function-body walker has the equivalent branch below. + else if ( + this.language === 'haskell' + && (this.extractor.extractBareReference || this.extractor.extractBareCall) + ) { + this.extractBareSemanticReference(node); + } // `new Foo(...)` / `Foo::new(...)` / object_creation_expression — // produce an `instantiates` reference. Children still walked so // nested calls inside the constructor args (`new Foo(bar())`) get @@ -1317,7 +1348,14 @@ export class TreeSitterExtractor { return null; } - const id = generateNodeId(this.filePath, kind, name, node.startPosition.row + 1); + const line = node.startPosition.row + 1; + const column = node.startPosition.column; + const legacyId = generateNodeId(this.filePath, kind, name, line); + const previousColumn = this.nodeIdColumns.get(legacyId); + const id = previousColumn === undefined || previousColumn === column + ? legacyId + : generateNodeId(this.filePath, kind, `${name}\0column:${column}`, line); + this.nodeIdColumns.set(legacyId, previousColumn ?? column); // Some grammars (e.g. Dart) model a function/method body as a *sibling* of // the signature node, so the declaration node's own range is just the @@ -1605,6 +1643,12 @@ export class TreeSitterExtractor { if (body) { this.visitFunctionBody(body, funcNode.id); } + // Walk any extra body-shaped fields (e.g. Haskell's `binds:` for + // `where`-clause bindings — see LanguageExtractor.extraBodyFields). + for (const f of this.extractor.extraBodyFields ?? []) { + const extra = getChildByField(node, f); + if (extra) this.visitFunctionBody(extra, funcNode.id); + } this.nodeStack.pop(); } @@ -1825,6 +1869,12 @@ export class TreeSitterExtractor { if (body) { this.visitFunctionBody(body, methodNode.id); } + // Walk any extra body-shaped fields (Haskell `where` etc. — see + // LanguageExtractor.extraBodyFields). + for (const f of this.extractor.extraBodyFields ?? []) { + const extra = getChildByField(node, f); + if (extra) this.visitFunctionBody(extra, methodNode.id); + } this.nodeStack.pop(); } @@ -3681,12 +3731,253 @@ export class TreeSitterExtractor { return this.erlangAtomMacros.get(macroName) ?? null; } + /** Emit language-specific references represented by otherwise bare syntax. */ + private extractBareSemanticReference(node: SyntaxNode): void { + if (!this.extractor || this.nodeStack.length === 0) return; + const callerId = this.nodeStack[this.nodeStack.length - 1]; + if (!callerId) return; + + const bareReference = this.extractor.extractBareReference?.(node, this.source, this.nodes); + if (bareReference) { + for (const reference of Array.isArray(bareReference) ? bareReference : [bareReference]) { + const positionNode = reference.node ?? node; + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: reference.name, + referenceKind: reference.referenceKind, + line: positionNode.startPosition.row + 1, + column: positionNode.startPosition.column, + }); + } + return; + } + + const calleeName = this.extractor.extractBareCall?.(node, this.source, this.nodes); + if (!calleeName) return; + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: calleeName, + referenceKind: 'calls', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + private extractCall(node: SyntaxNode): void { if (this.nodeStack.length === 0) return; const callerId = this.nodeStack[this.nodeStack.length - 1]; if (!callerId) return; + // Haskell function application is left-associative (`f a b` nests two + // `apply` nodes) and the grammar uses the same `variable` shape for a + // statically named function and a higher-order parameter. Emit one call for + // the OUTERMOST application, unwrap it to the leftmost callee, and suppress + // names bound by the enclosing equation/lambda patterns. This avoids the + // classic false edge where `runEventExpr (... f) = f err` links to an + // unrelated top-level function named `f`. + if (this.language === 'haskell') { + const sameSyntaxNode = (a: SyntaxNode | null, b: SyntaxNode): boolean => + !!a && a.startIndex === b.startIndex && a.endIndex === b.endIndex; + const normalizeQualified = (text: string): string => { + let compact = text.replace(/\s+/g, '').replace(/^`|`$/g, ''); + if (compact.startsWith('(') && compact.endsWith(')')) { + const inner = compact.slice(1, -1); + if (/^(?:[A-Z][A-Za-z0-9_']*\.)+/.test(inner)) compact = inner; + } + const qualified = compact.match(/^((?:[A-Z][A-Za-z0-9_']*\.)+)(.+)$/); + return qualified + ? `${qualified[1]!.slice(0, -1)}::${qualified[2]}` + : compact; + }; + const patternBinds = (name: string): boolean => { + if (name.includes('::')) return false; + if (this.extractor?.isLexicallyBound) { + return this.extractor.isLexicallyBound(name, node, this.source, this.nodes); + } + let ancestor: SyntaxNode | null = node.parent; + while (ancestor) { + if (ancestor.type === 'function' || ancestor.type === 'lambda') { + const patterns = getChildByField(ancestor, 'patterns'); + if (patterns) { + const stack: SyntaxNode[] = [patterns]; + while (stack.length > 0) { + const current = stack.pop()!; + if (current.type === 'variable' && getNodeText(current, this.source) === name) { + return true; + } + for (let i = 0; i < current.namedChildCount; i++) { + const child = current.namedChild(i); + if (child) stack.push(child); + } + } + } + } + if (ancestor.type === 'function') break; + ancestor = ancestor.parent; + } + return false; + }; + const simpleReference = (candidate: SyntaxNode | null): { name: string; node: SyntaxNode } | null => { + let current = candidate; + while (current?.type === 'parens' && current.namedChildCount === 1) { + current = current.namedChild(0); + } + if (!current || !['variable', 'constructor', 'qualified', 'prefix_id'].includes(current.type)) { + return null; + } + let name = getNodeText(current, this.source).trim(); + if (current.type === 'qualified' || name.includes('.') || name.startsWith('`')) { + name = normalizeQualified(name); + } + if (!name || patternBinds(name)) return null; + return { name, node: current }; + }; + const emitFunctionRef = (candidate: SyntaxNode | null): void => { + const reference = simpleReference(candidate); + if (!reference) return; + const separator = reference.name.lastIndexOf('::'); + const leaf = reference.name + .slice(separator < 0 ? 0 : separator + 2) + .replace(/^\(|\)$/g, ''); + // Constructor arguments are values unless a known combinator below + // explicitly executes them. The Haskell bare-reference walker records + // the value dependency, so a speculative function_ref here would be a + // duplicate (and can resolve to the wrong semantic kind). + if (/^[A-Z]/.test(leaf) || leaf.startsWith(':')) return; + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: reference.name, + referenceKind: 'function_ref', + line: reference.node.startPosition.row + 1, + column: reference.node.startPosition.column, + }); + }; + const emitCall = (candidate: SyntaxNode | null): boolean => { + const reference = simpleReference(candidate); + if (!reference) return false; + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: reference.name, + referenceKind: 'calls', + line: reference.node.startPosition.row + 1, + column: reference.node.startPosition.column, + }); + return true; + }; + + // Haskell reuses `apply`/`infix` syntax nodes for constructor patterns. + // They are dependencies on the constructor, but never runtime calls. + if (this.extractor?.isPatternPosition?.(node)) { + let candidate: SyntaxNode | null = null; + if (node.type === 'apply') { + if ( + node.parent?.type === 'apply' + && sameSyntaxNode(getChildByField(node.parent, 'function'), node) + ) return; + candidate = getChildByField(node, 'function') ?? node.namedChild(0); + while (candidate?.type === 'apply') { + candidate = getChildByField(candidate, 'function') ?? candidate.namedChild(0); + } + } else if (node.type === 'infix') { + candidate = getChildByField(node, 'operator'); + } + const reference = candidate ? simpleReference(candidate) : null; + if (reference) { + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: reference.name, + referenceKind: 'references', + line: reference.node.startPosition.row + 1, + column: reference.node.startPosition.column, + }); + } + return; + } + + if (node.type === 'apply') { + // A named expression passed as an application argument is a function + // value candidate (`mapM_ handler xs`). The function-ref resolver is + // intentionally strict (callables only; import/same-file/unique), so + // ordinary value arguments do not turn into fuzzy call edges. + const argument = getChildByField(node, 'argument'); + let appliedArgumentCount = 0; + let prefixCallee = getChildByField(node, 'function'); + while (prefixCallee?.type === 'apply') { + appliedArgumentCount++; + prefixCallee = getChildByField(prefixCallee, 'function'); + } + const prefixReference = simpleReference(prefixCallee); + const prefixSeparator = prefixReference?.name.lastIndexOf('::') ?? -1; + const prefixBaseRaw = prefixReference + ? prefixReference.name.slice(prefixSeparator < 0 ? 0 : prefixSeparator + 2) + : ''; + const prefixBase = prefixBaseRaw.replace(/^\((.*)\)$/, '$1'); + const actionArgument = ['>>', '*>', '<*', '<*>', '<**>', '<|>'].includes(prefixBase) + || (['<$>', '<$', '=<<'].includes(prefixBase) && appliedArgumentCount === 1) + || (['<&>', '$>', '>>='].includes(prefixBase) && appliedArgumentCount === 0); + if (!actionArgument) emitFunctionRef(argument); + // Known Haskell HOF arguments are emitted by the language-specific + // bare-call hook during the child walk. Keeping that semantic decision + // in one place avoids duplicate calls for `map Just xs` and friends. + + if ( + node.parent?.type === 'apply' && + sameSyntaxNode(getChildByField(node.parent, 'function'), node) + ) { + return; + } + let callee = getChildByField(node, 'function') ?? node.namedChild(0); + while (callee?.type === 'apply') { + callee = getChildByField(callee, 'function') ?? callee.namedChild(0); + } + while (callee?.type === 'parens' && callee.namedChildCount === 1) { + callee = callee.namedChild(0); + } + if (!callee || !['variable', 'constructor', 'qualified', 'prefix_id'].includes(callee.type)) { + return; + } + emitCall(callee); + return; + } + + if (node.type === 'infix') { + const operator = getChildByField(node, 'operator'); + if (!operator) return; + const operatorName = normalizeQualified(getNodeText(operator, this.source).trim()); + if (!operatorName || patternBinds(operatorName)) return; + + // `$`/`$!` and `&` are application syntax in practice. The function is + // an operand rather than an `apply` node, so surface the semantic callee + // (`handler $ value`, `value & handler`) and omit the Prelude operator. + if (operatorName === '$' || operatorName === '$!') { + emitCall(getChildByField(node, 'left_operand')); + return; + } + if (operatorName === '&') { + emitCall(getChildByField(node, 'right_operand')); + return; + } + + if (HASKELL_FUNCTION_COMPOSITION_OPERATORS.has(operatorName)) { + emitFunctionRef(getChildByField(node, 'left_operand')); + emitFunctionRef(getChildByField(node, 'right_operand')); + } else if (operatorName === '<$>' || operatorName === '=<<') { + emitFunctionRef(getChildByField(node, 'left_operand')); + } else if (operatorName === '<&>' || operatorName === '>>=') { + emitFunctionRef(getChildByField(node, 'right_operand')); + } + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: operatorName, + referenceKind: 'calls', + line: operator.startPosition.row + 1, + column: operator.startPosition.column, + }); + return; + } + } + // VB.NET: `foo(args)` is syntactically ambiguous between a call and an // index read, so the grammar parses non-empty parens as // array_access_expression (field `array`, not `function`) — even Roslyn @@ -5156,20 +5447,8 @@ export class TreeSitterExtractor { this.extractAnonymousClass(node, anonBody); return; } - } else if (this.extractor!.extractBareCall) { - const calleeName = this.extractor!.extractBareCall(node, this.source); - if (calleeName && this.nodeStack.length > 0) { - const callerId = this.nodeStack[this.nodeStack.length - 1]; - if (callerId) { - this.unresolvedReferences.push({ - fromNodeId: callerId, - referenceName: calleeName, - referenceKind: 'calls', - line: node.startPosition.row + 1, - column: node.startPosition.column, - }); - } - } + } else if (this.extractor!.extractBareReference || this.extractor!.extractBareCall) { + this.extractBareSemanticReference(node); } // C++ stack / direct-initialization construction — `Calculator calc(0)` @@ -5409,6 +5688,36 @@ export class TreeSitterExtractor { } } + // Haskell type-class superclass constraints: `class (Eq a, Show a) => Ord a where ...` + // The `class` node has a `context:` child of type `context`, whose own `context:` + // field is either a single `apply` (one constraint) or a `tuple` of `apply` nodes. + // Each apply's `constructor:` field is the superclass name. Emit an `extends` + // reference per superclass. + if (child.type === 'context') { + const inner = getChildByField(child, 'context'); + const applies: SyntaxNode[] = []; + if (inner?.type === 'apply') { + applies.push(inner); + } else if (inner?.type === 'tuple') { + for (let j = 0; j < inner.namedChildCount; j++) { + const a = inner.namedChild(j); + if (a?.type === 'apply') applies.push(a); + } + } + for (const a of applies) { + const ctor = getChildByField(a, 'constructor'); + if (ctor) { + this.unresolvedReferences.push({ + fromNodeId: classId, + referenceName: getNodeText(ctor, this.source), + referenceKind: 'extends', + line: ctor.startPosition.row + 1, + column: ctor.startPosition.column, + }); + } + } + } + // C++ base classes: `class Derived : public Base, private Other` → // base_class_clause holds access specifiers + base type(s). Emit an extends // ref per base type (skip the public/private/protected keywords). A diff --git a/src/extraction/wasm/README.md b/src/extraction/wasm/README.md new file mode 100644 index 000000000..795574eb5 --- /dev/null +++ b/src/extraction/wasm/README.md @@ -0,0 +1,79 @@ +# Vendored tree-sitter grammars + +Most language grammars in codegraph resolve at runtime from the +[`tree-sitter-wasms`](https://www.npmjs.com/package/tree-sitter-wasms) npm +package (see `src/extraction/grammars.ts`). The `.wasm` files in **this** +directory are the exceptions — grammars vendored into the repo because: + +- they're missing from `tree-sitter-wasms` (Pascal, Haskell), or +- the version `tree-sitter-wasms` ships is too old / broken (Lua's ABI-13 + build corrupts the shared WASM heap under `web-tree-sitter` 0.25; see the + inline comment in `grammars.ts` for the full story). + +`copy-assets` (run from `npm run build`) ships every `*.wasm` here into +`dist/extraction/wasm/`. **Add a `.wasm` here, the matching token to the +vendored branch in `grammars.ts:174`, and a row to the table below.** + +## Vendored grammars + +| Grammar | sha256 (first 16) | ABI | Source | Commit | Built with | +|---|---|---|---|---|---| +| `tree-sitter-haskell.wasm` | `d82f63a8c3df7748` | 14 | [tree-sitter/tree-sitter-haskell](https://github.com/tree-sitter/tree-sitter-haskell) | [`0975ef72`](https://github.com/tree-sitter/tree-sitter-haskell/commit/0975ef72fc3c47b530309ca93937d7d143523628) | `tree-sitter build --wasm` (WASI-SDK 29) | +| `tree-sitter-lua.wasm` | `6d95607fc7d78964` | 15 | upstream `tree-sitter-lua` (ABI-15) | TBD on next rebuild | `tree-sitter build --wasm` | +| `tree-sitter-luau.wasm` | `f1647052518f2bdf` | TBD | upstream `tree-sitter-luau` | TBD on next rebuild | `tree-sitter build --wasm` | +| `tree-sitter-pascal.wasm` | `be3634fca99c19f5` | TBD | upstream Pascal grammar | TBD on next rebuild | `tree-sitter build --wasm` | +| `tree-sitter-scala.wasm` | `7945b13e6f9b15b5` | TBD | upstream `tree-sitter-scala` | TBD on next rebuild | `tree-sitter build --wasm` | + +The table records compact hash prefixes; run `sha256sum *.wasm` inside this +directory to verify the full hashes. Whenever you re-vendor a grammar, update +the matching row. + +## Rebuilding a grammar (the Haskell recipe) + +This is the exact path used to produce `tree-sitter-haskell.wasm`. The same +recipe works for any tree-sitter grammar that ships its `grammar.js` / +`parser.c` (almost all do). + +```bash +# 1. Tooling (pinned to the version used for the vendored artifact) +npm i -g tree-sitter-cli@0.24.4 # provides the `tree-sitter` binary + +# 2. Clone the grammar at a specific commit (pin it!) +git clone https://github.com/tree-sitter/tree-sitter-haskell /tmp/ts-haskell +cd /tmp/ts-haskell +git checkout 0975ef72fc3c47b530309ca93937d7d143523628 + +# 3. Build the wasm. Downloads WASI-SDK 29 (~113 MB) into +# ~/.cache/tree-sitter/ on first run; subsequent builds reuse it. +tree-sitter build --wasm + +# 4. Vendor it +cp tree-sitter-haskell.wasm /src/extraction/wasm/ + +# 5. Health-check it against codegraph's multi-grammar runtime +cd +node scripts/add-lang/check-grammar.mjs \ + src/extraction/wasm/tree-sitter-haskell.wasm \ + .hs +# Must print "RESULT: PASS — grammar parses cleanly and reuses safely." +# A FAIL here (e.g. ABI 13 grammars under web-tree-sitter 0.25) means the +# wasm corrupts the shared WASM heap and silently drops nodes on every parse +# after the first — DO NOT ship it. + +# 6. Record the sha256 + commit in the table above so future rebuilds are +# reproducible: +sha256sum src/extraction/wasm/tree-sitter-haskell.wasm +``` + +### Why not `tree-sitter-wasms`? + +Two reasons it can't cover Haskell today: + +- The published `tree-sitter-wasms@0.1.13` does not include a haskell build + (`tar tzf tree-sitter-wasms-0.1.13.tgz | grep haskell` is empty). +- The official `tree-sitter-haskell` npm package ships `grammar.js`, + `parser.c`, and Node bindings — but **no precompiled `.wasm`**. + +If a future `tree-sitter-wasms` adds a healthy haskell grammar, this vendored +copy can be deleted: remove `'haskell'` from the vendored branch in +`grammars.ts` and the row above. diff --git a/src/extraction/wasm/tree-sitter-haskell.LICENSE b/src/extraction/wasm/tree-sitter-haskell.LICENSE new file mode 100644 index 000000000..4b52d191c --- /dev/null +++ b/src/extraction/wasm/tree-sitter-haskell.LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Max Brunsfeld + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/extraction/wasm/tree-sitter-haskell.wasm b/src/extraction/wasm/tree-sitter-haskell.wasm new file mode 100755 index 000000000..bcc4b5268 Binary files /dev/null and b/src/extraction/wasm/tree-sitter-haskell.wasm differ diff --git a/src/index.ts b/src/index.ts index f3aca0c89..2637861dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -791,6 +791,12 @@ export class CodeGraph { // itself, so the changed-files branch is already covered.) this.resolver.clearCaches(); } + if (result.haskellImportInvalidationRecovered) { + // The recovered sync may have no filesystem delta, so neither branch + // above clears resolver/import caches. Its replayed pending refs must + // see the already-committed post-crash module topology. + this.resolver.clearCaches(); + } // Resolve references if files were updated const filesChanged = result.filesAdded > 0 || result.filesModified > 0; diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 75056d3ce..927290e5d 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -46,6 +46,7 @@ const EXTENSION_RESOLUTION: Record = { ruby: ['.rb'], objc: ['.h', '.m', '.mm'], nix: ['.nix', '/default.nix'], + haskell: ['.hs'], }; export function isNixPathImportRef(ref: UnresolvedRef): boolean { @@ -76,11 +77,19 @@ const exportedSymbolMemos = new WeakMap; + allByName: Map; defaultComponent: Node | undefined; defaultFnClass: Node | undefined; } const fileExportIndexes = new WeakMap>(); +/** + * Haskell imports name modules rather than filesystem paths. Cabal can map a + * module to any source directory, so derive the map from the namespace nodes + * emitted for `module Foo.Bar where` headers instead of guessing `Foo/Bar.hs`. + */ +const haskellModuleIndexes = new WeakMap>(); + function getFileExportIndex(filePath: string, context: ResolutionContext): FileExportIndex { let perFile = fileExportIndexes.get(context); if (!perFile) { @@ -89,10 +98,13 @@ function getFileExportIndex(filePath: string, context: ResolutionContext): FileE } let idx = perFile.get(filePath); if (!idx) { - idx = { byName: new Map(), defaultComponent: undefined, defaultFnClass: undefined }; + idx = { byName: new Map(), allByName: new Map(), defaultComponent: undefined, defaultFnClass: undefined }; for (const n of context.getNodesInFile(filePath)) { if (!n.isExported) continue; if (!idx.byName.has(n.name)) idx.byName.set(n.name, n); + const named = idx.allByName.get(n.name) ?? []; + named.push(n); + idx.allByName.set(n.name, named); if (idx.defaultComponent === undefined && n.kind === 'component') idx.defaultComponent = n; if (idx.defaultFnClass === undefined && (n.kind === 'function' || n.kind === 'class')) idx.defaultFnClass = n; } @@ -106,6 +118,7 @@ export function clearImportResolverMemos(context: ResolutionContext): void { importPathMemos.delete(context); exportedSymbolMemos.delete(context); fileExportIndexes.delete(context); + haskellModuleIndexes.delete(context); } export function resolveImportPath( @@ -141,6 +154,13 @@ function resolveImportPathUncached( return resolveCobolCopybook(importPath, fromFile, context); } + // Haskell's `import Foo.Bar` is a compiler module name, not an npm-style + // bare package and not necessarily a path relative to the importing file. + // Resolve it from indexed module headers before the external-import gate. + if (language === 'haskell') { + return resolveHaskellModule(importPath, fromFile, context); + } + // Skip external/npm packages — but pass the context so the // bare-specifier heuristic can consult the project's tsconfig // alias map first (custom prefixes like `@components/*` would @@ -171,6 +191,72 @@ function resolveImportPathUncached( return null; } +function resolveHaskellModule( + moduleName: string, + fromFile: string, + context: ResolutionContext +): string | null { + let index = haskellModuleIndexes.get(context); + if (!index) { + index = new Map(); + for (const namespace of context.getNodesByKind('namespace')) { + if (namespace.language !== 'haskell') continue; + const paths = index.get(namespace.name); + if (paths) { + if (!paths.includes(namespace.filePath)) paths.push(namespace.filePath); + } else { + index.set(namespace.name, [namespace.filePath]); + } + } + haskellModuleIndexes.set(context, index); + } + + const candidates = index.get(moduleName); + if (!candidates || candidates.length === 0) return null; + if (candidates.length === 1) return candidates[0]!; + + // A module header can be duplicated accidentally or across Cabal components. + // Prefer the conventional exact `Foo/Bar.hs` suffix before proximity: this + // disambiguates a real-world `CompositeAsset.hs` typo from the exposed + // `CompositeAssets.hs` module. Never let SQLite insertion order break a tie. + const expectedSuffix = `${moduleName.replace(/\./g, '/')}.hs`; + const suffixMatches = candidates.filter((candidate) => { + const normalized = candidate.replace(/\\/g, '/'); + return normalized === expectedSuffix || normalized.endsWith(`/${expectedSuffix}`); + }); + const pool = suffixMatches.length > 0 ? suffixMatches : candidates; + + // Monorepos can contain the same module name in several Cabal components + // (especially Main/Spec). Prefer the source tree closest to the importer. + const fromParts = fromFile.replace(/\\/g, '/').split('/').slice(0, -1); + const sharedPrefix = (candidate: string): number => { + const parts = candidate.replace(/\\/g, '/').split('/').slice(0, -1); + let shared = 0; + while ( + shared < fromParts.length && + shared < parts.length && + fromParts[shared] === parts[shared] + ) { + shared++; + } + return shared; + }; + let best: string | null = null; + let bestScore = -1; + let tied = false; + for (const candidate of pool) { + const score = sharedPrefix(candidate); + if (score > bestScore) { + best = candidate; + bestScore = score; + tied = false; + } else if (score === bestScore) { + tied = true; + } + } + return tied ? null : best; +} + /** * COBOL copybook lookup: `COPY CVACT01Y` (or `EXEC SQL INCLUDE X`) names a * library member resolved by the compiler's copybook search path, so we match @@ -759,6 +845,8 @@ export function extractImportMappings( mappings.push(...extractPHPImports(content)); } else if (language === 'c' || language === 'cpp') { mappings.push(...extractCppImports(content)); + } else if (language === 'haskell') { + mappings.push(...extractHaskellImports(content)); } return mappings; @@ -1066,6 +1154,311 @@ function extractCppImports(content: string): ImportMapping[] { return mappings; } +/** + * Extract Haskell module imports, including both GHC spellings of qualified + * imports (`import qualified M as X` and `import M qualified as X`). A module + * mapping is always emitted for qualified uses; unqualified imports also emit + * either their explicit import-list symbols or a wildcard binding. + */ +interface HaskellImportItem { + name: string; + children: '*' | string[] | null; +} + +/** Strip nested Haskell comments while preserving offsets/newlines and strings. */ +function stripHaskellComments(content: string): string { + let out = ''; + let depth = 0; + let lineComment = false; + let inString = false; + for (let i = 0; i < content.length; i++) { + const ch = content[i]!; + const next = content[i + 1]; + if (lineComment) { + if (ch === '\n') { + lineComment = false; + out += '\n'; + } else { + out += ' '; + } + continue; + } + if (depth > 0) { + if (ch === '{' && next === '-') { + depth++; + out += ' '; + i++; + } else if (ch === '-' && next === '}') { + depth--; + out += ' '; + i++; + } else { + out += ch === '\n' ? '\n' : ' '; + } + continue; + } + if (inString) { + out += ch; + if (ch === '\\' && next !== undefined) { + out += next; + i++; + } else if (ch === '"') { + inString = false; + } + continue; + } + if (ch === '"') { + inString = true; + out += ch; + } else if ( + ch === '-' + && next === '-' + // A comment may start with any run of at least two dashes. Its + // disambiguating character is the one *after the complete run*, not + // necessarily the third character (`--- Haddock` is still a comment). + // Conversely `-->` and `---+` are symbolic operators and must survive. + && (() => { + let afterDashes = i + 2; + while (content[afterDashes] === '-') afterDashes++; + return !/[!#$%&*+.\/<=>?@\\^|~:-]/.test(content[afterDashes] ?? ''); + })() + ) { + lineComment = true; + out += ' '; + i++; + } else if (ch === '{' && next === '-') { + depth = 1; + out += ' '; + i++; + } else { + out += ch; + } + } + return out; +} + +/** + * Canonical spelling used by the Haskell resolver. Tree-sitter can surface a + * prefix-qualified operator with its source parentheses (`(Ops.<+>)`), while + * infix and ordinary qualified references use `Ops::<+>` / `Ops::map`. + * Removing only balanced whole-expression parentheses keeps operator payloads + * intact and makes every spelling share one import lookup path. + */ +export function normalizeHaskellReferenceName(referenceName: string): string { + let value = referenceName.trim(); + const hasWholeOuterParens = (candidate: string): boolean => { + if (!candidate.startsWith('(') || !candidate.endsWith(')')) return false; + let depth = 0; + for (let i = 0; i < candidate.length; i++) { + if (candidate[i] === '(') depth++; + else if (candidate[i] === ')') depth--; + if (depth === 0 && i < candidate.length - 1) return false; + if (depth < 0) return false; + } + return depth === 0; + }; + while (hasWholeOuterParens(value)) value = value.slice(1, -1).trim(); + + if (!value.includes('::')) { + const qualified = value.match( + /^([A-Z][A-Za-z0-9_']*(?:\.[A-Z][A-Za-z0-9_']*)*)\.([^\s.]+)$/ + ); + if (qualified) value = `${qualified[1]}::${qualified[2]}`; + } + return value; +} + +/** Node-name spellings used for a canonical Haskell value/operator name. */ +function haskellNodeNameVariants(name: string): string[] { + const normalized = normalizeHaskellReferenceName(name); + return /^[A-Za-z_][A-Za-z0-9_']*$/.test(normalized) + ? [normalized] + : [normalized, `(${normalized})`]; +} + +function haskellNodeOwnedBy(node: Node, parent: string): boolean { + const canonical = (value: string): string => value.startsWith('(') && value.endsWith(')') + ? value.slice(1, -1) + : value; + const canonicalParent = canonical(parent); + const owners = node.qualifiedName.split('::').slice(0, -1); + return owners.some((owner) => { + const canonicalOwner = canonical(owner); + return canonicalOwner === canonicalParent || canonicalOwner.startsWith(`${canonicalParent} `); + }) || node.decorators?.some((decorator) => + decorator.startsWith('haskell-export-parent:') + && canonical(decorator.slice('haskell-export-parent:'.length)) === canonicalParent + ) === true; +} + +function splitHaskellList(value: string): string[] { + const items: string[] = []; + let item = ''; + let depth = 0; + for (const char of value) { + if (char === '(') depth++; + else if (char === ')') depth--; + if (char === ',' && depth === 0) { + if (item.trim()) items.push(item.trim()); + item = ''; + } else { + item += char; + } + } + if (item.trim()) items.push(item.trim()); + return items; +} + +function haskellImportName(value: string): string { + const cleaned = value.trim().replace(/^(?:type|pattern)\s+/, ''); + const operator = cleaned.match(/^\(([^()]+)\)$/)?.[1]?.trim(); + return operator ?? cleaned.match(/^([A-Za-z_][A-Za-z0-9_']*)/)?.[1] ?? ''; +} + +function parseHaskellImportItem(rawItem: string): HaskellImportItem | null { + const cleaned = rawItem.trim().replace(/^(?:type|pattern)\s+/, ''); + if (!cleaned) return null; + const operatorHead = cleaned.match(/^\(([^()]+)\)([\s\S]*)$/); + if (operatorHead) { + const name = operatorHead[1]!.trim(); + const suffix = operatorHead[2]!.trim(); + if (!suffix) return { name, children: null }; + if (!suffix.startsWith('(') || !suffix.endsWith(')')) return null; + const childList = suffix.slice(1, -1).trim(); + return { + name, + children: childList === '..' + ? '*' + : splitHaskellList(childList).map(haskellImportName).filter(Boolean), + }; + } + const name = cleaned.match(/^([A-Za-z_][A-Za-z0-9_']*)/)?.[1] ?? ''; + if (!name) return null; + const open = cleaned.indexOf('(', name.length); + const close = cleaned.lastIndexOf(')'); + if (open < 0 || close <= open) return { name, children: null }; + const childList = cleaned.slice(open + 1, close).trim(); + if (childList === '..') return { name, children: '*' }; + return { + name, + children: splitHaskellList(childList).map(haskellImportName).filter(Boolean), + }; +} + +function extractHaskellImports(content: string): ImportMapping[] { + const mappings: ImportMapping[] = []; + const stripped = stripHaskellComments(content); + const lines = stripped.split(/\r?\n/); + + const declarations: string[] = []; + for (let i = 0; i < lines.length; i++) { + const first = lines[i]!; + if (!/^\s*import\b/.test(first)) continue; + let declaration = first.trim(); + let depth = (declaration.match(/\(/g) ?? []).length - (declaration.match(/\)/g) ?? []).length; + while (depth > 0 && i + 1 < lines.length) { + const next = lines[++i]!; + declaration += ` ${next.trim()}`; + depth += (next.match(/\(/g) ?? []).length - (next.match(/\)/g) ?? []).length; + } + declarations.push(declaration.replace(/\s+/g, ' ').trim()); + } + + for (const declaration of declarations) { + const body = declaration.replace(/^import\s+/, ''); + const match = body.match( + /^(?:(safe)\s+)?(?:(qualified)\s+)?(?:"[^"]+"\s+)?([A-Z][A-Za-z0-9_']*(?:\.[A-Z][A-Za-z0-9_']*)*)([\s\S]*)$/ + ); + if (!match) continue; + const moduleName = match[3]!; + const tail = match[4] ?? ''; + const listStart = tail.indexOf('('); + const modifiers = listStart >= 0 ? tail.slice(0, listStart) : tail; + const isQualified = !!match[2] || /\bqualified\b/.test(modifiers); + const isHiding = /\bhiding\b/.test(modifiers); + const alias = modifiers.match(/\bas\s+([A-Z][A-Za-z0-9_']*(?:\.[A-Z][A-Za-z0-9_']*)*)\b/)?.[1]; + const listEnd = listStart >= 0 ? tail.lastIndexOf(')') : -1; + const items = listStart >= 0 && listEnd > listStart + ? splitHaskellList(tail.slice(listStart + 1, listEnd)) + .map(parseHaskellImportItem) + .filter((item): item is HaskellImportItem => item !== null) + : []; + + const directNames = items.map((item) => item.name); + const parentExports = items.filter((item) => item.children === '*').map((item) => item.name); + const parentChildren = items.flatMap((item) => Array.isArray(item.children) + ? item.children.map((child) => ({ parent: item.name, child })) + : []); + const restrictions = listStart < 0 ? {} : isHiding ? { + excludedNames: directNames, + excludedParentExports: parentExports, + excludedParentChildren: parentChildren, + } : { + includedNames: directNames, + includedParentExports: parentExports, + includedParentChildren: parentChildren, + }; + + // Even an unqualified Haskell import permits references through the full + // module name; a qualified import exposes only this namespace mapping. + mappings.push({ + localName: alias ?? moduleName, + exportedName: '*', + source: moduleName, + isDefault: false, + isNamespace: true, + isQualifiedOnly: isQualified, + ...restrictions, + }); + + if (isQualified) continue; + if (listStart < 0 || isHiding) { + mappings.push({ + localName: '*', + exportedName: '*', + source: moduleName, + isDefault: false, + isNamespace: false, + ...restrictions, + }); + continue; + } + + for (const item of items) { + mappings.push({ + localName: item.name, + exportedName: item.name, + source: moduleName, + isDefault: false, + isNamespace: false, + }); + if (item.children === '*') { + mappings.push({ + localName: '*', + exportedName: '*', + source: moduleName, + isDefault: false, + isNamespace: false, + parentExport: item.name, + }); + } else if (item.children) { + for (const child of item.children) { + mappings.push({ + localName: child, + exportedName: child, + source: moduleName, + isDefault: false, + isNamespace: false, + parentExport: item.name, + }); + } + } + } + } + + return mappings; +} + // Cache import mappings per file to avoid re-reading and re-parsing const importMappingCache = new Map(); @@ -1144,6 +1537,7 @@ function stripJsComments(content: string): string { * fall through silently; resolution simply skips the broken file. */ export function extractReExports(content: string, language: Language): ReExport[] { + if (language === 'haskell') return extractHaskellReExports(content); if ( language !== 'typescript' && language !== 'javascript' && @@ -1199,6 +1593,169 @@ export function extractReExports(content: string, language: Language): ReExport[ return out; } +/** + * Extract Haskell module re-exports from a module header: + * + * module Facade (module Lib.Api, helper) where + * + * `module Lib.Api` is an explicit wildcard re-export. A named header item can + * also forward a symbol that was explicitly imported into the facade. + */ +function extractHaskellReExports(content: string): ReExport[] { + const cleaned = stripHaskellComments(content); + const header = cleaned.match(/\bmodule\s+[A-Z][A-Za-z0-9_']*(?:\.[A-Z][A-Za-z0-9_']*)*\s*/); + if (!header) return []; + const open = cleaned.indexOf('(', header.index! + header[0].length); + if (open < 0) return []; + + let depth = 0; + let close = -1; + for (let i = open; i < cleaned.length; i++) { + if (cleaned[i] === '(') depth++; + else if (cleaned[i] === ')') { + depth--; + if (depth === 0) { + close = i; + break; + } + } + } + if (close < 0 || !/^\s*where\b/.test(cleaned.slice(close + 1))) return []; + + const items: string[] = []; + let item = ''; + depth = 0; + for (const char of cleaned.slice(open + 1, close)) { + if (char === '(') depth++; + else if (char === ')') depth--; + if (char === ',' && depth === 0) { + if (item.trim()) items.push(item.trim()); + item = ''; + } else { + item += char; + } + } + if (item.trim()) items.push(item.trim()); + + const out: ReExport[] = []; + const imports = extractHaskellImports(content); + const pushUnique = (reExport: ReExport): void => { + const key = JSON.stringify(reExport); + if (!out.some((existing) => JSON.stringify(existing) === key)) out.push(reExport); + }; + const restrictionsFrom = (imp: ImportMapping) => ({ + ...(imp.includedNames ? { includedNames: imp.includedNames } : {}), + ...(imp.includedParentExports ? { includedParentExports: imp.includedParentExports } : {}), + ...(imp.includedParentChildren ? { includedParentChildren: imp.includedParentChildren } : {}), + ...(imp.excludedNames ? { excludedNames: imp.excludedNames } : {}), + ...(imp.excludedParentExports ? { excludedParentExports: imp.excludedParentExports } : {}), + ...(imp.excludedParentChildren ? { excludedParentChildren: imp.excludedParentChildren } : {}), + }); + const mappingExposes = (imp: ImportMapping, name: string, parent?: string): boolean => { + if (imp.isNamespace) return false; + if (imp.localName !== '*' && imp.localName !== name) return false; + if (imp.parentExport && imp.parentExport !== parent) return false; + if (imp.excludedNames?.includes(name)) return false; + if (parent && imp.excludedParentExports?.includes(parent)) return false; + if (parent && imp.excludedParentChildren?.some((item) => + item.parent === parent && item.child === name + )) return false; + const hasAllowList = imp.includedNames !== undefined + || imp.includedParentExports !== undefined + || imp.includedParentChildren !== undefined; + if (!hasAllowList) return true; + return imp.includedNames?.includes(name) === true + || (!!parent && imp.includedParentExports?.includes(parent) === true) + || (!!parent && imp.includedParentChildren?.some((item) => + item.parent === parent && item.child === name + ) === true); + }; + const sourcesFor = (name: string, parent?: string): ImportMapping[] => { + const seen = new Set(); + return imports.filter((imp) => { + if (!mappingExposes(imp, name, parent)) return false; + const key = `${imp.source}\0${imp.parentExport ?? ''}\0${imp.localName}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + }; + + for (const rawItem of items) { + const moduleMatch = rawItem.match(/^module\s+([A-Z][A-Za-z0-9_']*(?:\.[A-Z][A-Za-z0-9_']*)*)$/); + if (moduleMatch) { + // `module X` means the unqualified names brought into scope through the + // local module binding X. It may be an alias (`import Origin as X`) and a + // qualified-only import contributes no unqualified exports. + for (const imp of imports) { + if (!imp.isNamespace || imp.isQualifiedOnly || imp.localName !== moduleMatch[1]) continue; + pushUnique({ kind: 'wildcard', source: imp.source, ...restrictionsFrom(imp) }); + } + continue; + } + + const item = parseHaskellImportItem(rawItem); + if (!item) continue; + for (const imp of sourcesFor(item.name)) { + pushUnique({ + kind: 'named', + exportedName: item.name, + originalName: item.name, + source: imp.source, + }); + } + + if (Array.isArray(item.children)) { + for (const child of item.children) { + for (const imp of sourcesFor(child, item.name)) { + pushUnique({ + kind: 'named', + exportedName: child, + originalName: child, + source: imp.source, + parentExport: item.name, + }); + } + } + } else if (item.children === '*') { + // Preserve the intersection between `T(..)` in the facade header and + // the constructors/methods that its own imports actually brought into + // scope. Explicit `T(A)` imports become named A re-exports; unrestricted + // or `T(..)` imports retain a parent-scoped wildcard. + for (const imp of imports) { + if (imp.isNamespace) continue; + if (imp.parentExport === item.name && imp.localName !== '*') { + pushUnique({ + kind: 'named', + exportedName: imp.localName, + originalName: imp.exportedName, + source: imp.source, + }); + continue; + } + const unrestrictedWildcard = imp.localName === '*' + && !imp.includedNames + && !imp.includedParentExports + && !imp.includedParentChildren; + const parentWildcard = imp.localName === '*' + && (imp.parentExport === item.name + || imp.includedParentExports?.includes(item.name) + || unrestrictedWildcard); + if (!parentWildcard || imp.excludedParentExports?.includes(item.name)) continue; + pushUnique({ + kind: 'wildcard', + source: imp.source, + includedParentExports: [item.name], + ...(imp.excludedNames ? { excludedNames: imp.excludedNames } : {}), + ...(imp.excludedParentExports ? { excludedParentExports: imp.excludedParentExports } : {}), + ...(imp.excludedParentChildren ? { excludedParentChildren: imp.excludedParentChildren } : {}), + }); + } + } + } + return out; +} + /** * Resolve a reference using import mappings */ @@ -1404,6 +1961,11 @@ export function resolveViaImport( return null; } + if (ref.language === 'haskell') { + const haskellResult = resolveHaskellImportedReference(ref, imports, context); + if (haskellResult) return haskellResult; + } + // Go cross-package calls: `pkga.FuncX(...)` extracts to referenceName // `pkga.FuncX` and the import `github.com/example/myproject/pkga` // maps to a *package directory* containing one or more .go files. @@ -1545,6 +2107,133 @@ export function resolveViaImport( return null; } +/** + * Resolve Haskell references through the importing module's import table. + * Bare names search unqualified imports only; `Alias::member` searches the + * corresponding qualified module. Ambiguous matches are deliberately dropped. + */ +function resolveHaskellImportedReference( + ref: UnresolvedRef, + imports: ImportMapping[], + context: ResolutionContext +): ResolvedRef | null { + const referenceName = normalizeHaskellReferenceName(ref.referenceName); + const moduleFileNode = (moduleName: string): Node | undefined => { + const resolvedPath = resolveImportPath(moduleName, ref.filePath, 'haskell', context); + if (!resolvedPath) return undefined; + return context.getNodesInFile(resolvedPath).find((node) => node.kind === 'file'); + }; + + if (ref.referenceKind === 'imports') { + const fileNode = moduleFileNode(ref.referenceName); + return fileNode + ? { original: ref, targetNodeId: fileNode.id, confidence: 0.95, resolvedBy: 'import' } + : null; + } + + const isOwnedBy = (node: Node, parent: string): boolean => { + return haskellNodeOwnedBy(node, parent); + }; + + const mappingAllows = (imp: ImportMapping, name: string, node: Node): boolean => { + if (imp.parentExport && !isOwnedBy(node, imp.parentExport)) return false; + if (imp.excludedNames?.includes(name)) return false; + if (imp.excludedParentExports?.some((parent) => isOwnedBy(node, parent))) return false; + if (imp.excludedParentChildren?.some((item) => + item.child === name && isOwnedBy(node, item.parent) + )) return false; + const hasAllowList = imp.includedNames !== undefined + || imp.includedParentExports !== undefined + || imp.includedParentChildren !== undefined; + if (!hasAllowList) return true; + return imp.includedNames?.includes(name) === true + || imp.includedParentExports?.some((parent) => isOwnedBy(node, parent)) === true + || imp.includedParentChildren?.some((item) => + item.child === name && isOwnedBy(node, item.parent) + ) === true; + }; + + const findInModule = ( + moduleName: string, + exportedName: string, + mapping?: ImportMapping, + ): Node | undefined => { + const resolvedPath = resolveImportPath(moduleName, ref.filePath, 'haskell', context); + if (!resolvedPath) return undefined; + const mappingNeedsPredicate = !!mapping && ( + mapping.includedNames !== undefined + || mapping.includedParentExports !== undefined + || mapping.includedParentChildren !== undefined + || mapping.excludedNames !== undefined + || mapping.excludedParentExports !== undefined + || mapping.excludedParentChildren !== undefined + ); + // Direct declarations and re-exports form one Haskell export namespace. + // Apply this import's restrictions during (not after) that global + // ambiguity check so `B(field)` can select B, while bare `field` cannot + // silently prefer a facade-local field over a re-exported one. + return findExportedSymbol( + resolvedPath, + { + isDefault: false, + isNamespace: false, + exportedName, + memberName: null, + ...(mapping?.parentExport ? { haskellParent: mapping.parentExport } : {}), + ...(mappingNeedsPredicate ? { + haskellAllows: (node: Node) => mappingAllows(mapping, exportedName, node), + } : {}), + }, + 'haskell', + context, + new Set() + ); + }; + + const uniqueResult = (nodes: Node[]): ResolvedRef | null => { + const unique = [...new Map(nodes.map((node) => [node.id, node])).values()]; + if (unique.length !== 1) return null; + return { + original: ref, + targetNodeId: unique[0]!.id, + confidence: 0.95, + resolvedBy: 'import', + }; + }; + + const separator = referenceName.lastIndexOf('::'); + if (separator > 0) { + const receiver = referenceName.slice(0, separator); + const member = referenceName.slice(separator + 2); + if (!member) return null; + const targets: Node[] = []; + for (const imp of imports) { + if (!imp.isNamespace || imp.localName !== receiver) continue; + const target = findInModule(imp.source, member, imp); + if (target) targets.push(target); + } + return uniqueResult(targets); + } + + // Explicit import lists are stronger than wildcard imports. If present, + // resolve only through their declared source modules. + const explicitTargets: Node[] = []; + for (const imp of imports) { + if (imp.isNamespace || imp.localName !== referenceName) continue; + const target = findInModule(imp.source, imp.exportedName, imp); + if (target) explicitTargets.push(target); + } + if (explicitTargets.length > 0) return uniqueResult(explicitTargets); + + const wildcardTargets: Node[] = []; + for (const imp of imports) { + if (imp.isNamespace || imp.localName !== '*') continue; + const target = findInModule(imp.source, referenceName, imp); + if (target) wildcardTargets.push(target); + } + return uniqueResult(wildcardTargets); +} + /** * Resolve a Python qualified reference whose receiver is an imported MODULE: * `certs.where()` after `from . import certs`, `mod.func()` after `import mod` @@ -2029,10 +2718,10 @@ function resolveGoCrossPackageReference( return null; } -/** Recursive depth cap for re-export chain following. Real codebases - * rarely chain barrels more than 2–3 deep; 8 is a generous safety - * net that still bounds worst-case work. */ -const REEXPORT_MAX_DEPTH = 8; +/** Recursive depth cap for re-export chain following. It is deliberately + * high enough for generated/deep facade chains while still bounding + * malformed acyclic graphs; cycles are stopped separately by `visited`. */ +const REEXPORT_MAX_DEPTH = 64; /** * Find an exported symbol in `filePath`, following `export { x } from @@ -2044,14 +2733,23 @@ const REEXPORT_MAX_DEPTH = 8; * nothing — the existing code only looked for declarations IN the * resolved file, not declarations the file forwarded. */ +type ExportedSymbolWant = { + isDefault: boolean; + isNamespace: boolean; + exportedName: string; + memberName: string | null; + haskellParent?: string; + /** Path-local Haskell export-list restrictions accumulated by wildcard re-exports. */ + haskellAllows?: (node: Node) => boolean; +}; + +/** Internal-only result: unlike `undefined` (not found), ambiguity must survive recursion. */ +const HASKELL_EXPORT_AMBIGUOUS = Symbol('haskell-export-ambiguous'); +type ExportedSymbolWalkResult = Node | typeof HASKELL_EXPORT_AMBIGUOUS | undefined; + function findExportedSymbol( filePath: string, - want: { - isDefault: boolean; - isNamespace: boolean; - exportedName: string; - memberName: string | null; - }, + want: ExportedSymbolWant, language: Language, context: ResolutionContext, visited: Set, @@ -2062,39 +2760,50 @@ function findExportedSymbol( // Every ref to the same imported symbol repeats this exact walk, so the // top-level memo removes the re-export chase + per-file linear scans from // all but the first occurrence. - if (depth === 0 && visited.size === 0) { + if (depth === 0 && visited.size === 0 && !want.haskellAllows) { let memo = exportedSymbolMemos.get(context); if (!memo) { memo = new Map(); exportedSymbolMemos.set(context, memo); } - const key = `${filePath}\0${want.isDefault ? 1 : 0}${want.isNamespace ? 1 : 0}\0${want.exportedName}\0${want.memberName ?? ''}\0${language}`; + const key = `${filePath}\0${want.isDefault ? 1 : 0}${want.isNamespace ? 1 : 0}\0${want.exportedName}\0${want.memberName ?? ''}\0${want.haskellParent ?? ''}\0${language}`; if (memo.has(key)) return memo.get(key); - const result = findExportedSymbolWalk(filePath, want, language, context, visited, depth); + const walked = findExportedSymbolWalk(filePath, want, language, context, visited, depth); + const result = walked === HASKELL_EXPORT_AMBIGUOUS ? undefined : walked; memo.set(key, result); return result; } - return findExportedSymbolWalk(filePath, want, language, context, visited, depth); + const walked = findExportedSymbolWalk(filePath, want, language, context, visited, depth); + return walked === HASKELL_EXPORT_AMBIGUOUS ? undefined : walked; } function findExportedSymbolWalk( filePath: string, - want: { - isDefault: boolean; - isNamespace: boolean; - exportedName: string; - memberName: string | null; - }, + want: ExportedSymbolWant, language: Language, context: ResolutionContext, visited: Set, depth: number -): Node | undefined { +): ExportedSymbolWalkResult { if (depth > REEXPORT_MAX_DEPTH) return undefined; if (visited.has(filePath)) return undefined; visited.add(filePath); const exportIndex = getFileExportIndex(filePath, context); + const exportedNames = language === 'haskell' + ? haskellNodeNameVariants(want.exportedName) + : [want.exportedName]; + // Haskell can expose the same value name from a local declaration and one + // or more `module X` re-exports. All authorized candidates participate in + // the ambiguity check; a direct declaration must not win merely because it + // is visited first. + const haskellCandidates: Node[] = []; + let haskellAmbiguous = false; + const uniqueHaskellCandidate = (): ExportedSymbolWalkResult => { + const unique = [...new Map(haskellCandidates.map((node) => [node.id, node])).values()]; + if (haskellAmbiguous || unique.length > 1) return HASKELL_EXPORT_AMBIGUOUS; + return unique.length === 1 ? unique[0] : undefined; + }; // 1. Direct hit: the symbol is declared in this file. if (want.isDefault) { @@ -2110,13 +2819,24 @@ function findExportedSymbolWalk( const direct = exportIndex.byName.get(want.memberName); if (direct) return direct; } else { - const direct = exportIndex.byName.get(want.exportedName); - if (direct) return direct; + for (const name of exportedNames) { + if (language === 'haskell') { + const candidates = (exportIndex.allByName.get(name) ?? []) + .filter((node) => !want.haskellParent || haskellNodeOwnedBy(node, want.haskellParent)) + .filter((node) => !want.haskellAllows || want.haskellAllows(node)); + haskellCandidates.push(...candidates); + } else { + const direct = exportIndex.byName.get(name); + if (direct) return direct; + } + } } // 2. Re-export hit: the file forwards the symbol to another module. const reExports = context.getReExports?.(filePath, language) ?? []; - if (reExports.length === 0) return undefined; + if (reExports.length === 0) { + return language === 'haskell' ? uniqueHaskellCandidate() : undefined; + } // Look for explicit `export { want } from './other'` (with optional rename). const targetName = want.isDefault ? 'default' : want.exportedName; @@ -2126,20 +2846,30 @@ function findExportedSymbolWalk( if (!next) continue; // After rename: `export { foo as bar } from './x'` — to chase // `bar`, we look for `foo` in `./x`. - const chained = findExportedSymbol( + const chained = findExportedSymbolWalk( next, { isDefault: rex.originalName === 'default', isNamespace: false, exportedName: rex.originalName, memberName: null, + ...(rex.parentExport ? { haskellParent: rex.parentExport } : {}), + ...(want.haskellAllows ? { haskellAllows: want.haskellAllows } : {}), }, language, context, - visited, + // Cycle detection is path-local. Sharing the mutable set between + // sibling alternatives lets a restricted/denied first branch poison a + // later valid route that converges on the same origin module. + new Set(visited), depth + 1 ); - if (chained) return chained; + if (chained === HASKELL_EXPORT_AMBIGUOUS) { + haskellAmbiguous = true; + } else if (chained) { + if (language === 'haskell') haskellCandidates.push(chained); + else return chained; + } } } @@ -2149,12 +2879,78 @@ function findExportedSymbolWalk( if (rex.kind === 'wildcard') { const next = resolveImportPath(rex.source, filePath, language, context); if (!next) continue; - const chained = findExportedSymbol(next, want, language, context, visited, depth + 1); - if (chained) return chained; + const restrictedParents = language === 'haskell' + ? [...new Set([ + ...(rex.includedParentExports ?? []), + ...(rex.includedParentChildren ?? []) + .filter((item) => item.child === want.exportedName) + .map((item) => item.parent), + ])] + : []; + const parentVariants = restrictedParents.length > 0 ? restrictedParents : [want.haskellParent]; + const haskellAllows = language === 'haskell' + ? (node: Node): boolean => ( + (!want.haskellAllows || want.haskellAllows(node)) + && haskellReExportAllows(rex, want.exportedName, node) + ) + : want.haskellAllows; + const candidates = parentVariants.flatMap((parent) => { + const chained = findExportedSymbolWalk( + next, + { + ...want, + ...(parent ? { haskellParent: parent } : {}), + ...(haskellAllows ? { haskellAllows } : {}), + }, + language, + context, + new Set(visited), + depth + 1 + ); + if (chained === HASKELL_EXPORT_AMBIGUOUS) { + haskellAmbiguous = true; + return []; + } + return chained ? [chained] : []; + }); + const unique = [...new Map(candidates.map((node) => [node.id, node])).values()]; + const chained = unique.length === 1 ? unique[0] : undefined; + if (chained && ( + language !== 'haskell' + || haskellReExportAllows(rex, want.exportedName, chained) + )) { + if (language === 'haskell') haskellCandidates.push(chained); + else return chained; + } } } - return undefined; + return language === 'haskell' ? uniqueHaskellCandidate() : undefined; +} + +function haskellReExportAllows( + reExport: Extract, + name: string, + node: Node, +): boolean { + const owners = node.qualifiedName.split('::').slice(0, -1); + const ownedBy = (parent: string): boolean => owners.some((owner) => + owner === parent || owner.startsWith(`${parent} `) + ) || node.decorators?.includes(`haskell-export-parent:${parent}`) === true; + if (reExport.excludedNames?.includes(name)) return false; + if (reExport.excludedParentExports?.some(ownedBy)) return false; + if (reExport.excludedParentChildren?.some((item) => + item.child === name && ownedBy(item.parent) + )) return false; + const hasAllowList = reExport.includedNames !== undefined + || reExport.includedParentExports !== undefined + || reExport.includedParentChildren !== undefined; + if (!hasAllowList) return true; + return reExport.includedNames?.includes(name) === true + || reExport.includedParentExports?.some(ownedBy) === true + || reExport.includedParentChildren?.some((item) => + item.child === name && ownedBy(item.parent) + ) === true; } /** Node kinds that own static members reachable as `Container.member`. */ diff --git a/src/resolution/index.ts b/src/resolution/index.ts index e218c5f3a..e359f1601 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -17,7 +17,7 @@ import { ImportMapping, } from './types'; import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesKnownFamily } from './name-matcher'; -import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, clearImportResolverMemos } from './import-resolver'; +import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, clearImportResolverMemos, normalizeHaskellReferenceName } from './import-resolver'; import { ResolverPool, minRefsForPool } from './resolver-pool'; import { detectFrameworks } from './frameworks'; import { synthesizeCallbackEdges } from './callback-synthesizer'; @@ -745,6 +745,17 @@ export class ReferenceResolver { return false; } + /** Haskell operators are declared under either `op` or `(op)` node names. */ + private hasAnyPossibleHaskellMatch(name: string): boolean { + const normalized = normalizeHaskellReferenceName(name); + if (this.hasAnyPossibleMatch(normalized)) return true; + const separator = normalized.lastIndexOf('::'); + const leaf = separator >= 0 ? normalized.slice(separator + 2) : normalized; + return !/^[A-Za-z_][A-Za-z0-9_']*$/.test(leaf) + && (this.hasAnyPossibleMatch(`(${leaf})`) + || (separator < 0 && this.hasAnyPossibleMatch(`(${normalized})`))); + } + /** * Does `ref.referenceName` match an import declared in its containing * file? Used as a pre-filter escape so re-export chain resolution @@ -753,10 +764,14 @@ export class ReferenceResolver { private matchesAnyImport(ref: UnresolvedRef): boolean { const imports = this.context.getImportMappings(ref.filePath, ref.language); if (imports.length === 0) return false; + const referenceName = ref.language === 'haskell' && ref.referenceKind !== 'imports' + ? normalizeHaskellReferenceName(ref.referenceName) + : ref.referenceName; for (const imp of imports) { if ( - imp.localName === ref.referenceName || - ref.referenceName.startsWith(imp.localName + '.') + imp.localName === referenceName || + referenceName.startsWith(imp.localName + '.') || + (ref.language === 'haskell' && referenceName.startsWith(imp.localName + '::')) ) { return true; } @@ -764,6 +779,364 @@ export class ReferenceResolver { return false; } + /** + * Recognize a simple OverloadedRecordDot projection at the unresolved ref's + * source position. `undefined` means this is not projection syntax; `null` + * means it is a projection whose immediate receiver cannot be inferred + * safely (for example the second field in `value.user.email`). + */ + private getHaskellProjectionReceiver(ref: UnresolvedRef): string | null | undefined { + if ( + ref.language !== 'haskell' + || ref.referenceKind !== 'references' + || ref.referenceName.includes('::') + || !/^[a-z_][A-Za-z0-9_']*$/.test(ref.referenceName) + ) return undefined; + + const lines = this.context.getFileLines?.(ref.filePath) + ?? this.context.readFile(ref.filePath)?.split(/\r?\n/) + ?? null; + if (!lines || ref.line < 1 || ref.line > lines.length) return undefined; + const sourceLine = lines[ref.line - 1]!; + const atReference = sourceLine.slice(ref.column); + if (new RegExp(`^${ref.referenceName}\\b`).test(atReference)) { + const prefix = sourceLine.slice(0, ref.column); + if (/\.\s*$/.test(prefix)) { + const preceding = prefix.match(/([a-z_][A-Za-z0-9_']*)\s*\.\s*$/); + if (preceding?.index !== undefined) { + // In `value.user.email`, `email`'s immediate receiver is itself a + // projection with an unknown result type. Only the first member can + // be related safely to the annotated base parameter. + const beforeReceiver = prefix.slice(0, preceding.index).trimEnd(); + return beforeReceiver.endsWith('.') ? null : preceding[1]!; + } + // Parenthesized, applied, or multiline receivers are still definitely + // projection syntax. Claim them conservatively so they cannot fall + // through to an arbitrary same-named record selector. + return null; + } + } + const fragment = lines.slice(ref.line - 1, Math.min(lines.length, ref.line + 2)); + fragment[0] = fragment[0]!.slice(ref.column); + const projection = fragment.join('\n').match( + /^\s*([a-z_][A-Za-z0-9_']*)((?:\s*\.\s*[a-z_][A-Za-z0-9_']*)+)/, + ); + if (!projection) return undefined; + const members = [...projection[2]!.matchAll(/\.\s*([a-z_][A-Za-z0-9_']*)/g)] + .map((match) => match[1]!); + const memberIndex = members.indexOf(ref.referenceName); + if (memberIndex < 0) return undefined; + return memberIndex === 0 ? projection[1]! : null; + } + + /** Infer the record parent for the narrow, reliable `f :: T -> ...; f x = x.field` case. */ + private inferHaskellProjectionParent( + ref: UnresolvedRef, + from: Node, + receiver: string, + ): string | null { + const signature = from.signature; + const marker = signature?.indexOf('::') ?? -1; + if (!signature || marker < 0) return null; + + const splitTopLevel = (input: string, separator: string): string[] => { + const parts: string[] = []; + let depth = 0; + let start = 0; + for (let index = 0; index <= input.length - separator.length; index++) { + const char = input[index]!; + if (char === '(' || char === '[' || char === '{') depth++; + else if (char === ')' || char === ']' || char === '}') depth = Math.max(0, depth - 1); + if (depth === 0 && input.startsWith(separator, index)) { + parts.push(input.slice(start, index).trim()); + start = index + separator.length; + index += separator.length - 1; + } + } + parts.push(input.slice(start).trim()); + return parts; + }; + + let declaredType = signature.slice(marker + 2).trim() + .replace(/^forall\b[^.]*\.\s*/, ''); + const constrained = splitTopLevel(declaredType, '=>'); + declaredType = constrained[constrained.length - 1] ?? declaredType; + const typeParts = splitTopLevel(declaredType, '->'); + if (typeParts.length < 2) return null; + + const lines = this.context.getFileLines?.(ref.filePath) + ?? this.context.readFile(ref.filePath)?.split(/\r?\n/) + ?? null; + if (!lines) return null; + const escapedName = from.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + let parameters: string[] | null = null; + for (let line = Math.min(ref.line, lines.length); line >= from.startLine; line--) { + const equation = lines[line - 1]?.match(new RegExp(`^\\s*${escapedName}\\b(.*?)(?:=|\\|)`)); + if (!equation) continue; + const raw = equation[1]!.trim(); + parameters = raw === '' ? [] : raw.split(/\s+/); + break; + } + if (!parameters || parameters.some((parameter) => !/^[a-z_][A-Za-z0-9_']*$/.test(parameter))) { + return null; + } + const argumentIndex = parameters.indexOf(receiver); + if (argumentIndex < 0 || argumentIndex >= typeParts.length - 1) return null; + let argumentType = typeParts[argumentIndex]!.trim(); + while (argumentType.startsWith('(') && argumentType.endsWith(')')) { + argumentType = argumentType.slice(1, -1).trim(); + } + // A qualified `O.B` must retain `O` to distinguish it from a same-named + // local `B`. This narrow inference intentionally handles only the simple + // unqualified case until qualifier-aware type lookup is available. + if (!/^[A-Z][A-Za-z0-9_']*$/.test(argumentType)) return null; + return argumentType; + } + + /** + * Resolve record-dot only when a simple receiver annotation identifies one + * parent type. An untyped/ambiguous projection is claimed but deliberately + * left unresolved so lexical bare-name matching cannot pick an arbitrary + * DuplicateRecordFields selector. + */ + private resolveHaskellProjection(ref: UnresolvedRef): { + claimed: boolean; + result: ResolvedRef | null; + } { + const receiver = this.getHaskellProjectionReceiver(ref); + if (receiver === undefined) return { claimed: false, result: null }; + if (receiver === null) return { claimed: true, result: null }; + const from = this.queries.getNodeById(ref.fromNodeId); + if (!from) return { claimed: true, result: null }; + const parent = this.inferHaskellProjectionParent(ref, from, receiver); + if (!parent) return { claimed: true, result: null }; + + const ownedByParent = (node: Node): boolean => { + const owners = node.qualifiedName.split('::').slice(0, -1); + return owners.some((owner) => owner === parent || owner.startsWith(`${parent} `)) + || node.decorators?.includes(`haskell-export-parent:${parent}`) === true; + }; + const targets = this.context.getNodesByName(ref.referenceName) + .filter((node) => node.language === 'haskell' + && node.kind === 'field' + && node.filePath === ref.filePath + && ownedByParent(node)); + const imported = this.gateLanguage(resolveViaImport(ref, this.context), ref); + if (imported) { + const target = this.queries.getNodeById(imported.targetNodeId); + if (target?.kind === 'field' && ownedByParent(target)) targets.push(target); + } + const unique = [...new Map(targets.map((node) => [node.id, node])).values()]; + if (unique.length !== 1) return { claimed: true, result: null }; + return { + claimed: true, + result: { + original: ref, + targetNodeId: unique[0]!.id, + confidence: 0.99, + resolvedBy: imported?.targetNodeId === unique[0]!.id ? 'import' : 'qualified-name', + }, + }; + } + + /** + * Resolve Haskell's lexical/module scope before imports. Local declarations + * shadow imports, and nested helpers are visible only from their owner, + * siblings, and descendants — a same-file global name match is not enough. + */ + private resolveHaskellLexical(ref: UnresolvedRef): { + claimed: boolean; + result: ResolvedRef | null; + } { + if ( + ref.language !== 'haskell' + || (ref.referenceKind !== 'calls' + && ref.referenceKind !== 'function_ref' + && ref.referenceKind !== 'references') + ) return { claimed: false, result: null }; + + const from = this.queries.getNodeById(ref.fromNodeId); + if (!from) return { claimed: false, result: null }; + const moduleNode = this.context.getNodesInFile(ref.filePath) + .find((node) => node.kind === 'namespace' && node.language === 'haskell'); + const moduleScope = moduleNode?.qualifiedName ?? moduleNode?.name; + const normalizedReference = normalizeHaskellReferenceName(ref.referenceName); + const qualifiedSeparator = normalizedReference.lastIndexOf('::'); + let localName = normalizedReference; + let selfQualified = false; + if (qualifiedSeparator > 0) { + const receiver = normalizedReference.slice(0, qualifiedSeparator); + localName = normalizedReference.slice(qualifiedSeparator + 2); + selfQualified = !!moduleNode + && (receiver === moduleNode.name || receiver === moduleNode.qualifiedName); + // A qualifier other than this file's own module belongs to import + // resolution. In particular, do not let a same-file leaf accidentally + // capture `Other.foo`. + if (!selfQualified) return { claimed: false, result: null }; + } + + const names = /^[A-Za-z_][A-Za-z0-9_']*$/.test(localName) + ? [localName] + : [localName, `(${localName})`]; + const candidates = [...new Map(names.flatMap((name) => this.context.getNodesByName(name)) + .filter((node) => node.filePath === ref.filePath && node.language === 'haskell') + .map((node) => [node.id, node])).values()]; + if (candidates.length === 0) { + const qualifierIsImportAlias = selfQualified && this.context + .getImportMappings(ref.filePath, 'haskell') + .some((mapping) => mapping.isNamespace && mapping.localName === normalizedReference.slice( + 0, + qualifiedSeparator, + )); + return { claimed: selfQualified && !qualifierIsImportAlias, result: null }; + } + + const visibleFunctionParents = new Map(); + let scope = from.qualifiedName; + let rank = 100; + while (scope) { + visibleFunctionParents.set(scope, rank--); + if (scope === moduleScope) break; + const separator = scope.lastIndexOf('::'); + if (separator < 0) break; + scope = scope.slice(0, separator); + } + if (moduleScope && !visibleFunctionParents.has(moduleScope)) { + visibleFunctionParents.set(moduleScope, 1); + } + + const methodOwnerIsInstance = (node: Node): boolean => { + const separator = node.qualifiedName.lastIndexOf('::'); + if (separator < 0) return false; + const ownerName = node.qualifiedName.slice(0, separator); + return this.context.getNodesByQualifiedName(ownerName) + .some((owner) => owner.decorators?.includes('haskell-instance')); + }; + + const ranked = candidates.flatMap((node): Array<{ node: Node; rank: number }> => { + if (node.kind === 'function' || node.kind === 'constant') { + const separator = node.qualifiedName.lastIndexOf('::'); + const parent = separator > 0 ? node.qualifiedName.slice(0, separator) : ''; + if (selfQualified) { + // A module qualifier exposes only module-scope declarations, never a + // let/where helper that happens to share the same leaf name. + return parent === moduleScope ? [{ node, rank: 300 }] : []; + } + const lexicalRank = visibleFunctionParents.get(parent); + return lexicalRank === undefined ? [] : [{ node, rank: 200 + lexicalRank }]; + } + // Constructors, pattern synonyms, and record selectors occupy the module + // value namespace even though their graph qualifiedName includes the + // owning type. + if (node.kind === 'enum_member') return [{ node, rank: 100 }]; + if (node.kind === 'field') return [{ node, rank: 100 }]; + if (node.kind === 'method') { + if (methodOwnerIsInstance(node)) { + // An instance implementation is not a module-scope binding. It is + // visible recursively from its own equation/helpers only; elsewhere + // the imported/local class selector owns the name. + const recursive = node.id === from.id + || from.qualifiedName.startsWith(`${node.qualifiedName}::`); + return recursive && !selfQualified ? [{ node, rank: 400 }] : []; + } + // Class methods are module-scope selectors even when the module export + // list keeps them private; export status matters only cross-file. + return [{ node, rank: 100 }]; + } + return []; + }); + if (ranked.length === 0) return { claimed: selfQualified, result: null }; + + const bestRank = Math.max(...ranked.map((entry) => entry.rank)); + let best = ranked.filter((entry) => entry.rank === bestRank); + + // Recursive/self references are exact even when another lexical scope + // materialized a helper with the same qualifiedName. + const exactSelf = best.filter((entry) => entry.node.id === from.id); + if (exactSelf.length === 1) best = exactSelf; + + if (best.length > 1) { + // Separate equations/branches may each declare `where`/`let helper`, but + // graph qualified names intentionally omit synthetic scope ids. Position + // is the remaining safe discriminator: apply it only to same-qualified + // local functions/constants, never to duplicate fields or methods whose + // selection would require type inference. + const sameQualifiedLocal = best.every((entry) => + (entry.node.kind === 'function' || entry.node.kind === 'constant') + && entry.node.qualifiedName === best[0]!.node.qualifiedName + && entry.node.qualifiedName.slice(0, entry.node.qualifiedName.lastIndexOf('::')) !== moduleScope + ); + if (sameQualifiedLocal) { + const sourceLine = this.context.getFileLines?.(ref.filePath)?.[ref.line - 1]; + if (sourceLine) { + const visible = best.filter(({ node }) => !( + node.startLine === ref.line + && node.startColumn > ref.column + // A later sibling/nested `let` is outside this call's scope. Keep + // ordinary forward references within the same recursive let group: + // their shared `let` keyword precedes the reference, not the node. + && /\blet\b/.test(sourceLine.slice(ref.column, node.startColumn)) + )); + if (visible.length > 0) best = visible; + } + const lexicalRange = (node: Node): [number, number] | null => { + const decorator = node.decorators?.find((value) => value.startsWith('haskell-lexical-range:')); + const match = decorator?.match(/^haskell-lexical-range:(\d+):(\d+)$/); + return match ? [Number(match[1]), Number(match[2])] : null; + }; + const containing = best.filter(({ node }) => { + const range = lexicalRange(node); + return range !== null && ref.line >= range[0] && ref.line <= range[1]; + }); + if (containing.length > 0) best = containing; + + const distance = (node: Node): [number, number] => { + const lineDistance = ref.line < node.startLine + ? node.startLine - ref.line + : ref.line > node.endLine + ? ref.line - node.endLine + : 0; + const columnDistance = lineDistance === 0 + ? Math.abs(ref.column - node.startColumn) + : 0; + return [lineDistance, columnDistance]; + }; + const ordered = best.map((entry) => ({ entry, distance: distance(entry.node) })) + .sort((a, b) => a.distance[0] - b.distance[0] || a.distance[1] - b.distance[1]); + if ( + ordered.length === 1 + || ordered[0]!.distance[0] !== ordered[1]!.distance[0] + || ordered[0]!.distance[1] !== ordered[1]!.distance[1] + ) { + best = [ordered[0]!.entry]; + } else { + const tied = ordered.filter((candidate) => + candidate.distance[0] === ordered[0]!.distance[0] + && candidate.distance[1] === ordered[0]!.distance[1] + ); + // `let` binders precede their `in` use. When two sibling scopes are + // exactly equidistant (the common then/else layout), this positional + // direction safely rejects the not-yet-visible later branch. + const preceding = tied.filter(({ entry }) => + entry.node.endLine < ref.line + || (entry.node.endLine === ref.line && entry.node.endColumn <= ref.column) + ); + if (preceding.length === 1) best = [preceding[0]!.entry]; + } + } + } + if (best.length !== 1) return { claimed: true, result: null }; + return { + claimed: true, + result: { + original: ref, + targetNodeId: best[0]!.node.id, + confidence: 0.99, + resolvedBy: 'qualified-name', + }, + }; + } + /** * Resolve a single reference */ @@ -773,6 +1146,12 @@ export class ReferenceResolver { return null; } + const haskellProjection = this.resolveHaskellProjection(ref); + if (haskellProjection.claimed) return haskellProjection.result; + + const haskellLexical = this.resolveHaskellLexical(ref); + if (haskellLexical.claimed) return haskellLexical.result; + // CFML component paths in inheritance (#1152): `extends="coldbox.system.web. // Controller"` names the supertype by its dot-separated path (or `extends= // "../base"` by relative file path) — the graph indexes the class under its @@ -804,10 +1183,14 @@ export class ReferenceResolver { const existenceName = ref.language === 'arkts' && ref.referenceName.startsWith('.') ? ref.referenceName.slice(1) - : ref.referenceName; + : ref.language === 'haskell' && ref.referenceKind !== 'imports' + ? normalizeHaskellReferenceName(ref.referenceName) + : ref.referenceName; if ( !isNixPathImportRef(ref) && - !this.hasAnyPossibleMatch(existenceName) && + !(ref.language === 'haskell' && ref.referenceKind !== 'imports' + ? this.hasAnyPossibleHaskellMatch(existenceName) + : this.hasAnyPossibleMatch(existenceName)) && !this.matchesAnyImport(ref) && !this.frameworks.some((f) => f.claimsReference?.(ref.referenceName)) ) { @@ -828,10 +1211,21 @@ export class ReferenceResolver { const viaImport = this.gateLanguage(resolveViaImport(ref, this.context), ref); if (viaImport) { const target = this.queries.getNodeById(viaImport.targetNodeId); - if (target && (target.kind === 'function' || target.kind === 'method')) { + if (target && ( + target.kind === 'function' + || target.kind === 'method' + || (ref.language === 'haskell' && target.kind === 'enum_member') + || (ref.language === 'haskell' && target.kind === 'field') + || (ref.language === 'haskell' && target.kind === 'constant') + )) { return viaImport; } } + // Haskell has no ambient cross-file namespace. If an import did not + // authorize this function value, a unique project-wide match is still + // out of scope (notably after `hiding`). Lexical same-file matches were + // already handled above. + if (ref.language === 'haskell') return null; return this.gateLanguage(matchFunctionRef(ref, this.context), ref); } @@ -905,6 +1299,14 @@ export class ReferenceResolver { if (!target || target.filePath !== ref.filePath) { nameResult = null; } + } else if (ref.language === 'haskell') { + // Haskell has no ambient project-wide namespace: a bare name is local + // or brought into scope by an import. Cross-file import matches were + // already handled above; accepting a global name-only fallback here + // fabricates edges for Prelude names such as `pure` and `length`. + if (!target || target.filePath !== ref.filePath || ref.referenceKind === 'calls') { + nameResult = null; + } } else if (target && target.language === 'nix') { // The reverse direction is just as impossible: no other language can // symbolically call into a .nix binding (interop is eval/CLI, never a diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 329dc5d40..e611ff5e4 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -200,7 +200,7 @@ function applyLanguageGate(candidates: Node[], ref: UnresolvedRef): Node[] { * Resolve a function-as-value reference (#756) — a function name used as a * callback/function-pointer value (`register(handler)`, `o->cb = handler`, * `{ .cb = handler }`, `signal(SIGINT, handler)`). The ONLY strategy allowed - * for `function_ref` refs: exact name, function/method targets only, same + * for `function_ref` refs: exact name, callable targets only, same * language family, same-file first, and cross-file only when the match is * UNIQUE. No fuzzy fallback, no qualified-name walking — a wrong callback * edge is worse than none. @@ -231,6 +231,12 @@ export function matchFunctionRef( ref.language === 'arkts' || ref.language === 'cpp' || ref.language === 'python' || ref.language === 'php'; + const isCallable = (node: Node, explicitlyQualified = false): boolean => + node.kind === 'function' + || ((explicitlyQualified || !bareFnOnly) && node.kind === 'method') + // Algebraic-data constructors are ordinary first-class functions in + // Haskell (`map Just xs`, `EventDelete . deleteRow`). + || (ref.language === 'haskell' && node.kind === 'enum_member'); // Qualified member-pointer (`&Widget::on_click` → "Widget::on_click"): // resolve the member ON THAT SCOPE — exempt from bareFnOnly (the `&Cls::m` @@ -241,7 +247,7 @@ export function matchFunctionRef( .getNodesByName(memberName) .filter( (n) => - (n.kind === 'function' || n.kind === 'method') && + isCallable(n, true) && sameLanguageFamily(n.language, ref.language) && n.id !== ref.fromNodeId && (n.qualifiedName === ref.referenceName || @@ -264,7 +270,7 @@ export function matchFunctionRef( .getNodesByName(ref.referenceName) .filter( (n) => - (n.kind === 'function' || (!bareFnOnly && n.kind === 'method')) && + isCallable(n) && sameLanguageFamily(n.language, ref.language) && n.id !== ref.fromNodeId // a function registering itself is not a dependency edge ); diff --git a/src/resolution/types.ts b/src/resolution/types.ts index bc80e2fc7..756f04050 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -249,6 +249,25 @@ export interface ImportMapping { isDefault: boolean; /** Whether it's a namespace import (import * as X) */ isNamespace: boolean; + /** Haskell: the binding is available only through its qualifier. */ + isQualifiedOnly?: boolean; + /** + * Optional parent export for languages with grouped exports, such as + * Haskell's `Type(Constructor)` and `Type(..)` import items. + */ + parentExport?: string; + /** Namespace/wildcard allow-list of directly named exports. */ + includedNames?: string[]; + /** Namespace/wildcard allow-list of all children owned by these exports. */ + includedParentExports?: string[]; + /** Haskell: specifically allowed children keyed by their owning type/class. */ + includedParentChildren?: Array<{ parent: string; child: string }>; + /** Namespace/wildcard deny-list of directly named exports. */ + excludedNames?: string[]; + /** Namespace/wildcard deny-list of all children owned by these exports. */ + excludedParentExports?: string[]; + /** Haskell: specifically hidden children keyed by their owning type/class. */ + excludedParentChildren?: Array<{ parent: string; child: string }>; /** Resolved file path (if local) */ resolvedPath?: string; } @@ -267,9 +286,18 @@ export type ReExport = originalName: string; /** Module specifier of the upstream module. */ source: string; + /** Haskell grouped export owner (`T(C)`) when the child is ambiguous. */ + parentExport?: string; } | { kind: 'wildcard'; /** Module specifier of the upstream module. */ source: string; + /** Optional Haskell re-export allow/deny lists inherited from its import. */ + includedNames?: string[]; + includedParentExports?: string[]; + includedParentChildren?: Array<{ parent: string; child: string }>; + excludedNames?: string[]; + excludedParentExports?: string[]; + excludedParentChildren?: Array<{ parent: string; child: string }>; }; diff --git a/src/types.ts b/src/types.ts index 5b0e407c5..bc395409d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -116,6 +116,7 @@ export const LANGUAGES = [ 'vbnet', 'erlang', 'terraform', + 'haskell', 'unknown', ] as const; @@ -250,6 +251,9 @@ export interface FileRecord { /** Number of nodes extracted */ nodeCount: number; + /** Haskell module/import/export topology fingerprint for incremental sync */ + haskellTopologyHash?: string; + /** Any extraction errors */ errors?: ExtractionError[]; }