diff --git a/CHANGELOG.md b/CHANGELOG.md index 98427a1a8..46dd52ba5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- A generic type parameter, like `A` in `def zipWith[A, B](…)`, `T` in ` T max(…)` or `class Foo`, no longer links to a project symbol that happens to share its name. On cats, one `implicit def A` had thousands of made-up dependents from every `A` in the library. The same goes for Rust, Dart, TypeScript, Java, Kotlin, Swift, Go, C# and C++. A class or other type declared inside a function is only linked from inside that function. In Scala, a `def`'s own parameter, like `f` in `(f: A => B)`, no longer links to a same-named field elsewhere. A bare name no longer reaches a type nested inside another type unless the code is inside that type, extends it or imports its members; the type in scope is linked instead, like cats' own `FlatMap` in place of `Eval`'s nested `FlatMap`. Re-index projects in these languages after upgrading. - A minified or bundled JavaScript file is now recognized as generated even when it isn't named `.min.js`, for example a docs site's `bundle.js`, a readable webpack build or a vendored `underscore-min.js`. Its one-letter functions now rank below your own code in search and `codegraph_explore` answers. - In C# and VB.NET, a type written in code, like `Type sourceType`, `List`, `new TypeMap()` or `Dictionary`, now links to the type and no longer to a property, method or field that shares its name. `List<…>` used to link to some test class's `List` property, `Type` to an attribute's `Type` property, and a parameter of type `TypeMap` to a `TypeMap` property instead of the `TypeMap` class. Those members showed up with hundreds of made-up dependents. Reading a property or passing a method, like `Builder.Services` or `MapGet("/x", GetItems)`, still links as before. Re-index C# and VB.NET projects after upgrading. - JavaScript and TypeScript calls are no longer linked to a class member that merely shares their name. A call on a namespace import, like zod's `z.string()` (`import * as z from 'zod'`), or on something imported from an npm package, used to link to whichever class in the project had a `string` member, and a bare call like mocha's `it(…)` or `describe(…)` to an interface's `it` property or a class's `describe` field. On zod one test helper's getter had thousands of made-up callers and topped the most-depended-on list. A React hook or component imported from a package, like `useQuery` from `@tanstack/react-query`, no longer links to a same-named one in the project, and a hook nested inside another function is no longer a target at all. Re-index JavaScript and TypeScript projects after upgrading. diff --git a/__tests__/type-parameter-refs.test.ts b/__tests__/type-parameter-refs.test.ts new file mode 100644 index 000000000..0cbea6439 --- /dev/null +++ b/__tests__/type-parameter-refs.test.ts @@ -0,0 +1,180 @@ +/** + * A type parameter is not a project symbol that shares its name. + * + * cats' `def zipWith[A, B, C](fa: ISeq[A], …)` sent `A` to an `implicit def A` + * (4,916 dependents) and `B` to a `case class B()` declared inside a test; + * getx's `T` landed on a constant named `T`. A type declared inside a function + * is only in scope in there. + */ +import { describe, it, expect, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +const roots: string[] = []; +afterAll(() => { + for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); +}); + +async function project(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-type-params-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +function typeTargets(cg: CodeGraph, name: string): string[] { + const from = cg.getNodesByName(name).filter((n) => n.kind === 'function' || n.kind === 'method'); + return cg + .getOutgoingEdgesFrom(from.map((n) => n.id), ['references', 'type_of', 'returns', 'instantiates']) + .map((e) => cg.getNode(e.target)) + .map((n) => `${n!.kind}:${n!.name}`) + .sort(); +} + +describe('type parameters', () => { + it('Scala: a def’s type parameters, and a case class local to another test', async () => { + const cg = await project({ + 'src/Inst.scala': `package app +object Inst { + implicit def A: Int = 1 +} +`, + 'src/Seqs.scala': `package app +object Seqs { + def zipWith[A, B, C](fa: List[A], fb: List[B])(f: (A, B) => C): List[C] = ??? +} +`, + // Operator names are declared names too — and must not break the reading. + 'src/Ops.scala': `package app +object Ops { + def |+|[A](x: A, y: A): A = x + def *[B](b: B): B = b +} +trait Heyting[@sp(Int, Long) A] { + def and(a: A, b: A): A +} +final case class FlatMapped(f: Int => Int) { + type A = Int +} +trait Laws { + type L = Int +} +class MyLaws extends Laws { + def check(x: L): Boolean = true +} +object UsesImport { + import FlatMapped._ + def viaImport(x: A): Int = 1 +} +object Folds { + def foldMap[A](fa: List[A])(f: A => Int)(implicit G: Ordering[Int]): Int = { f(fa.head); G.max(1, 2) } +} +object TraverseLaws { + def updatedRef[A, B >: A](fa: List[A], b: B): Option[B] = None +} +trait UnorderedTraverseTests { + def unorderedTraverse[ + A, + B, + C, + X, + Y + ](implicit + ArbXB: Option[X], + ArbYB: List[B] + ): Unit = () +} +`, + 'src/Elsewhere.scala': `package app +object Elsewhere { + def combineAll(xs: List[A]): Int = 1 + def lift(implicit F: FlatMap): Int = 1 +} +`, + 'src/FlatMap.scala': `package app +trait FlatMap +`, + 'src/Eval.scala': `package app +object Eval { + final case class FlatMap(x: Int) +} +`, + 'src/SyntaxSuite.scala': `package app +class SyntaxSuite { + def testOptionEmpty(): Unit = { + case class B() + val b: B = B() + } +} +`, + }); + try { + expect(typeTargets(cg, 'zipWith')).toEqual([]); + expect(typeTargets(cg, '|+|')).toEqual([]); + expect(typeTargets(cg, '*')).toEqual([]); + // A specialized parameter of the enclosing trait. + expect(typeTargets(cg, 'and')).toEqual([]); + // A def's own value parameters: `f(…)` calls the argument, `G` is the implicit. + const folds = cg.getNodesByName('foldMap')[0]!; + const fromFolds = cg.getOutgoingEdgesFrom([folds.id], ['calls', 'references']).map((e) => cg.getNode(e.target)!.name); + expect(fromFolds).not.toContain('f'); + expect(fromFolds).not.toContain('G'); + // Another type's member is only in scope inside it, a subtype, or after an import. + expect(typeTargets(cg, 'combineAll')).toEqual([]); + // …and the one in scope is found instead. + const lift = cg.getNodesByName('lift')[0]!; + const liftTargets = cg.getOutgoingEdgesFrom([lift.id], ['references']).map((e) => cg.getNode(e.target)!); + expect(liftTargets.map((n) => `${n.kind}:${n.qualifiedName}`)).toContain('trait:FlatMap'); + expect(liftTargets.map((n) => n.qualifiedName)).not.toContain('Eval::FlatMap'); + expect(typeTargets(cg, 'check')).toContain('type_alias:L'); + expect(typeTargets(cg, 'viaImport')).toContain('type_alias:A'); + // A lower bound's `>:` is not a closing bracket. + expect(typeTargets(cg, 'updatedRef')).toEqual([]); + // A parameter list written one per line. + expect(typeTargets(cg, 'unorderedTraverse')).toEqual([]); + // Inside its own method, the local class is still the one meant. + expect(typeTargets(cg, 'testOptionEmpty')).toContain('class:B'); + } finally { + cg.close(); + } + }); + + it('Java, TypeScript and Dart: declared before or after the name', async () => { + const cg = await project({ + 'src/T.java': `package app; +public class T {} +`, + 'src/Foo.java': `package app; +public class Foo {} +`, + 'src/Util.java': `package app; +import java.util.List; +import java.util.Map; +public class Util { + public static > T max(List xs) { return null; } + public static Map index(List xs) { return null; } +} +`, + 'src/wrap.ts': `export class T {} +export function wrap(x: T): T { return x; } +`, + 'lib/first.dart': `const T = 1; +T first(List xs) => xs.first; +`, + }); + try { + expect(typeTargets(cg, 'max')).toEqual([]); + // A generic type's ARGUMENTS are not parameters: `Map` still names Foo. + expect(typeTargets(cg, 'index')).toContain('class:Foo'); + expect(typeTargets(cg, 'wrap')).toEqual([]); + expect(typeTargets(cg, 'first')).toEqual([]); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 2230bab76..168079640 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -25,6 +25,7 @@ import { matchJsStoreBindingCall, isUnresolvedJsMemberCall, isVisibleAcrossFiles import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility'; import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor'; import { gateSwiftTypeTarget, clearSwiftTypeVisibility, swiftExtendedConformances } from './swift-type-visibility'; +import { gateTypeParameter, clearTypeParameterMemos } from './type-parameters'; import { resolveViaImport, resolvePhpImportedStaticCall, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isBoundToOutOfRepoImport, clearImportResolverMemos, resolveImportPath, isExternalImport } from './import-resolver'; import { ResolverPool, minRefsForPool } from './resolver-pool'; import { resolveAliasBinding } from './alias-binding'; @@ -448,6 +449,7 @@ export class ReferenceResolver { clearNameMatcherMemos(this.context); clearCppMacroVisibility(this.context); clearSwiftTypeVisibility(this.context); + clearTypeParameterMemos(this.context); } } @@ -985,7 +987,13 @@ export class ReferenceResolver { if (isVisibleCppMacro(ref, this.context)) return null; // A Swift type reference never lands on an `extension X {}` node, nor on a // nested type it cannot name bare (see ./swift-type-visibility). - const candidate = gateSwiftTypeTarget(this.gateTargetKind(this.resolveOneInner(ref), ref), ref, this.context); + // A name a declaration around the reference declares as a type parameter + // (`def f[A]`, `class Foo`) is that parameter (see ./type-parameters). + const candidate = gateTypeParameter( + gateSwiftTypeTarget(this.gateTargetKind(this.resolveOneInner(ref), ref), ref, this.context), + ref, + this.context, + ); const resolved = candidate?.resolvedBy === 'framework' ? this.gateFrameworkLanguage(candidate, ref) : this.gateLanguage(candidate, ref); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 629eb70d3..b0c669995 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -597,6 +597,8 @@ export function matchFunctionRef( /** Languages with no nested named functions: nesting in the graph is never a scope. */ const NO_NESTED_FUNCTIONS = new Set(['c', 'cpp']); +/** Types a function body can declare for itself. */ +const LOCAL_TYPE_KINDS = new Set(['class', 'struct', 'enum', 'interface', 'trait', 'type_alias']); /** * A function nested inside another FUNCTION is only callable from within its @@ -614,7 +616,9 @@ function isLexicallyReachable( ref: UnresolvedRef, context: ResolutionContext ): boolean { - if (candidate.kind !== 'function') return true; + // A function — or a type (`case class B()` in a test method) — declared + // inside a function is only in scope in there. + if (candidate.kind !== 'function' && !LOCAL_TYPE_KINDS.has(candidate.kind)) return true; // C and C++ have no nested named functions, so a function the graph shows // inside another is an extraction artifact, not a scope: tree-sitter-c // cannot parse a macro call whose arguments are designated initializers @@ -1247,6 +1251,11 @@ export function matchByExactName( const candidates = sameName.filter((n) => !(cMacroCall && n.kind !== 'function' && n.kind !== 'method') && !(typeRef && !canNameInTypePosition(n)) && + // A Scala type position (`Arbitrary[B]`) never names a method: an + // `implicit def A: Order[A]` shares its name with half of cats' type + // parameters. Scala's value references only read a file's own vals. + !(ref.language === 'scala' && ref.referenceKind === 'references' && /^[A-Z]/.test(ref.referenceName) && + (n.kind === 'method' || n.kind === 'function')) && // Type/value references retain same-family eligibility: a native namesake // must not hide the actual web type. Calls still gate only the winner. (!valueRef || sameLanguageFamily(n.language, ref.language)) && diff --git a/src/resolution/type-parameters.ts b/src/resolution/type-parameters.ts new file mode 100644 index 000000000..8ad1aaa04 --- /dev/null +++ b/src/resolution/type-parameters.ts @@ -0,0 +1,284 @@ +/** + * A name an enclosing declaration declares as a TYPE PARAMETER means that + * parameter, never a project symbol that happens to share it. + * + * `def zipWith[A, B, C](fa: ISeq[A], …)` in cats sent its `A` to an + * `implicit def A` elsewhere (4,916 dependents) and its `B` to a + * `case class B()` declared inside some test; getx's `T` landed on a constant + * named `T`. The index does not record type parameters, so they are read + * from the declaration heads around the reference: after the declared name + * (`class Foo`, `fn f`, `func F[T any]`, `def f[F[_]: Monad, A]`, + * `struct Stack`), before it where the language puts them + * (Java's ` T max(…)`, Kotlin's `fun f()`), and a C++ + * `template ` line above it. Applied at resolveOne's seam, so every + * strategy's result obeys it. + */ +import type { Node } from '../types'; +import type { ResolutionContext, ResolvedRef, UnresolvedRef } from './types'; + +const GENERIC_LANGUAGES: ReadonlySet = new Set([ + 'scala', 'java', 'kotlin', 'csharp', 'typescript', 'tsx', 'swift', 'rust', 'dart', 'go', 'cpp', 'vbnet', 'arkts', +]); + +/** Where a reference names a type. */ +const TYPE_REF_KINDS: ReadonlySet = new Set(['references', 'type_of', 'returns', 'instantiates']); + +/** Declarations that can carry type parameters. */ +const DECLARATION_KINDS: ReadonlySet = new Set([ + 'class', 'struct', 'interface', 'trait', 'protocol', 'enum', 'type_alias', 'function', 'method', 'union', 'module', +]); + +/** How far past a declaration's first line its head can run: a parameter list may take a line per parameter. */ +const HEAD_LINES = 40; + +const memos = new WeakMap>>(); + +/** Drop the memos (see ReferenceResolver.clearCaches). */ +export function clearTypeParameterMemos(context: ResolutionContext): void { + memos.delete(context); + valueMemos.delete(context); +} + +/** + * The resolved reference, or null when a declaration around it declares its + * name: as a type parameter (a type reference), or — Scala — as a value + * parameter of the enclosing `def` (`f(a)` where `f: A => B` is the def's own + * argument; cats bound 846 such calls to a case class's `f` field). + */ +export function gateTypeParameter(resolved: ResolvedRef | null, ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + if (!resolved) return resolved; + let result = resolved; + if (ref.language === 'scala' && (ref.referenceKind === 'calls' || ref.referenceKind === 'references') && + /^[A-Za-z_]\w*$/.test(ref.referenceName)) { + for (const declaration of enclosingDeclarations(ref, context)) { + if ((declaration.kind === 'function' || declaration.kind === 'method') && + declaredValueParameters(declaration, context).has(ref.referenceName)) return null; + } + } + if (ref.language === 'scala' && !scalaMemberInScope(result, ref, context)) { + // Out of scope: the type of that name that IS in scope — cats' own + // `trait FlatMap`, not `Eval`'s nested `FlatMap` case class — when one fits. + const target = context.getNodeById?.(result.targetNodeId); + if (!target || !SCALA_TYPE_KINDS.has(target.kind)) return null; + const inScope = context + .getNodesByName(ref.referenceName) + .filter((n) => n.id !== target.id && n.language === 'scala' && SCALA_TYPE_KINDS.has(n.kind)) + .filter((n) => scalaMemberInScope({ ...result, targetNodeId: n.id }, ref, context)); + const chosen = inScope.length === 1 ? inScope[0]! : inScope.find((n) => n.filePath === ref.filePath); + if (!chosen) return null; + result = { ...result, targetNodeId: chosen.id }; + } + if (!GENERIC_LANGUAGES.has(ref.language) || !TYPE_REF_KINDS.has(ref.referenceKind)) return result; + const name = ref.referenceName; + if (!/^[A-Z][A-Za-z0-9_]*$/.test(name)) return result; + for (const declaration of enclosingDeclarations(ref, context)) { + if (declaredTypeParameters(declaration, context).has(name)) return null; + } + return result; +} + +const SCALA_OWNER_KINDS: ReadonlySet = new Set(['class', 'trait', 'module', 'interface', 'struct', 'enum']); +const SCALA_TYPE_KINDS: ReadonlySet = new Set(['class', 'trait', 'interface', 'struct', 'enum', 'type_alias']); + +/** + * A bare Scala name reaches a member of another type — `FreeT.FlatMapped`'s + * `type A`, a test suite's `val P` — only from inside that type, a type that + * extends it, or a file importing its members (`import Owner._`, + * `Owner.{A}`, `Owner.*`). cats' bare type parameters landed on such members + * across the library once the test-local classes stopped catching them. + */ +function scalaMemberInScope(result: ResolvedRef, ref: UnresolvedRef, context: ResolutionContext): boolean { + if (!/^[A-Za-z_]\w*$/.test(ref.referenceName) || ref.referenceKind !== 'references') return true; + // Bare in the SOURCE: the index keeps `SttpClient.Service` by its last + // segment, and a qualified name reaches any member. + const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split(/\r?\n/)[ref.line - 1]; + if (line === undefined || !line.startsWith(ref.referenceName, ref.column) || /\.\s*$/.test(line.slice(0, ref.column))) return true; + const target = context.getNodeById?.(result.targetNodeId); + if (!target || target.language !== 'scala') return true; + const cut = target.qualifiedName.lastIndexOf('::'); + if (cut < 0) return true; + const ownerQn = target.qualifiedName.slice(0, cut); + const containers = context + .getNodesByQualifiedName(ownerQn) + .filter((o) => o.filePath === target.filePath && o.startLine <= target.startLine && o.endLine >= target.endLine); + const inside = (o: Node): boolean => ref.filePath === o.filePath && ref.line >= o.startLine && ref.line <= o.endLine; + // A def's local (`case class B()` in a test method): only in there. + const def = containers.find((o) => o.kind === 'function' || o.kind === 'method'); + if (def) return inside(def); + const owner = containers.find((o) => SCALA_OWNER_KINDS.has(o.kind)); + if (!owner) return true; // a package member + if (inside(owner)) return true; + // Inherited: the reference sits in a type that extends the owner. + for (const around of enclosingDeclarations(ref, context)) { + if (!SCALA_OWNER_KINDS.has(around.kind)) continue; + if (around.name === owner.name) return true; + if (context.getSupertypes?.(around.name, 'scala').includes(owner.name)) return true; + } + // Imported: `import cats.data.FreeT.FlatMapped._`, `Owner.{A, B}`, `Owner.*`. + const text = context.readFile(ref.filePath) ?? ''; + const imports = new RegExp( + String.raw`\bimport\s+[\w.]*\b${owner.name}\.(?:_|\*|given\b|\{[^}]*\b(?:_|\*|${target.name})\b[^}]*\}|${target.name}\b)`, + ); + return /^[A-Za-z_]\w*$/.test(owner.name) && /^[A-Za-z_]\w*$/.test(target.name) ? imports.test(text) : true; +} + +function enclosingDeclarations(ref: UnresolvedRef, context: ResolutionContext): Node[] { + return context + .getNodesInFile(ref.filePath) + .filter((n) => DECLARATION_KINDS.has(n.kind) && n.startLine <= ref.line && n.endLine >= ref.line); +} + +function declaredTypeParameters(node: Node, context: ResolutionContext): ReadonlySet { + let memo = memos.get(context); + if (!memo) { + memo = new Map(); + memos.set(context, memo); + } + const hit = memo.get(node.id); + if (hit) return hit; + const names = new Set(); + const lines = context.getFileLines?.(node.filePath) ?? context.readFile(node.filePath)?.split(/\r?\n/) ?? null; + if (lines) { + const head = lines.slice(node.startLine - 1, Math.min(node.endLine, node.startLine - 1 + HEAD_LINES)).join('\n'); + const at = nameIndex(head, node.name); + if (at >= 0) { + // After the declared name: `Foo`, `f[A]`. + let open = at + node.name.length; + while (open < head.length && /[ \t]/.test(head[open]!)) open++; + if (head[open] === '<' || head[open] === '[') collect(head, open, names); + // Before it, detached from any type name: `static T max(`, `fun f(`. + const before = head.slice(0, at); + for (let i = 0; i < before.length; i++) { + if (before[i] === '<' && (i === 0 || /[\s(,]/.test(before[i - 1]!))) collect(before, i, names); + } + } + // C++: `template ` on the lines just above. + if (node.language === 'cpp') { + const above = lines.slice(Math.max(0, node.startLine - 3), node.startLine).join('\n'); + const template = /\btemplate\s*>>(); + +/** + * A Scala def's value parameters, every curried list: `def f[A](fa: F[A])(f: + * A => B)(implicit G: Applicative[G])` → fa, f, G. + */ +function declaredValueParameters(node: Node, context: ResolutionContext): ReadonlySet { + let memo = valueMemos.get(context); + if (!memo) { + memo = new Map(); + valueMemos.set(context, memo); + } + const hit = memo.get(node.id); + if (hit) return hit; + const names = new Set(); + const lines = context.getFileLines?.(node.filePath) ?? context.readFile(node.filePath)?.split(/\r?\n/) ?? null; + if (lines) { + const head = lines.slice(node.startLine - 1, Math.min(node.endLine, node.startLine - 1 + HEAD_LINES)).join('\n'); + const at = nameIndex(head, node.name); + if (at >= 0) { + let i = at + node.name.length; + const skipSpace = (): void => { + while (i < head.length && /\s/.test(head[i]!)) i++; + }; + skipSpace(); + if (head[i] === '[') { + const close = matchingBracket(head, i); + i = close < 0 ? head.length : close + 1; + } + for (skipSpace(); head[i] === '('; skipSpace()) { + const close = matchingBracket(head, i); + if (close < 0) break; + for (const item of splitTopLevel(head.slice(i + 1, close), false)) { + const bare = item.replace(/^\s*(?:@[\w.]+(?:\([^()]*\))?\s*)+/, ''); + const param = /^\s*(?:(?:implicit|using|val|var|override|private|protected|final|inline|erased)\s+)*([A-Za-z_]\w*)\s*:/.exec(bare)?.[1]; + if (param) names.add(param); + } + i = close + 1; + } + } + } + memo.set(node.id, names); + return names; +} + +/** + * The declared name's position in its head: the whole word, past attributes + * and modifiers. A plain search — a declared name can be an operator (Scala's + * `*`, `|+|`), which no pattern built from it could match safely. + */ +function nameIndex(head: string, name: string): number { + if (!name) return -1; + const word = /[\w$]/; + for (let at = head.indexOf(name); at >= 0; at = head.indexOf(name, at + 1)) { + const before = at > 0 ? head[at - 1]! : ''; + const after = head[at + name.length] ?? ''; + if (!word.test(before) && !word.test(after)) return at; + } + return -1; +} + +/** Add the parameter names of the bracket group opening at `open`. */ +function collect(text: string, open: number, names: Set): void { + const close = matchingBracket(text, open); + if (close < 0) return; + for (const item of splitTopLevel(text.slice(open + 1, close), text[open] === '<')) { + // Past annotations (`@sp(Int, Long) A`, `@NonNull T`), variance and keywords. + const bare = item.replace(/^\s*(?:@[\w.]+(?:\([^()]*\))?\s*)+/, ''); + const param = /^\s*(?:(?:in|out|reified|const|typename|class|struct)\s+|[+\-']\s*)*([A-Za-z_]\w*)/.exec(bare)?.[1]; + if (param && param !== 'typename' && param !== 'class') names.add(param); + } +} + +/** + * The bracket closing the one at `open`. A `[…]` list counts only square + * brackets and parentheses — Scala's bounds `B >: A` and `A <: AnyRef` are not + * angle brackets — and a `<…>` list skips the `->` / `=>` inside its bounds. + */ +function matchingBracket(text: string, open: number): number { + const angle = text[open] === '<'; + const closer = angle ? '>' : text[open] === '[' ? ']' : ')'; + let depth = 0; + for (let i = open; i < text.length; i++) { + const c = text[i]!; + if (c === '{' || c === ';') return -1; + if (isOpener(c, angle)) depth++; + else if (isCloser(c, angle, text[i - 1])) { + depth--; + if (depth === 0) return c === closer ? i : -1; + } + } + return -1; +} + +function splitTopLevel(inner: string, angle: boolean): string[] { + const out: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < inner.length; i++) { + const c = inner[i]!; + if (isOpener(c, angle)) depth++; + else if (isCloser(c, angle, inner[i - 1])) depth--; + else if (c === ',' && depth === 0) { + out.push(inner.slice(start, i)); + start = i + 1; + } + } + out.push(inner.slice(start)); + return out; +} + +function isOpener(c: string, angle: boolean): boolean { + return c === '[' || c === '(' || (angle && c === '<'); +} + +function isCloser(c: string, angle: boolean, previous: string | undefined): boolean { + if (c === ']' || c === ')') return true; + return angle && c === '>' && previous !== '-' && previous !== '='; +}